#include "platform/esp/arduino_common/hostlink/hostlink_management_service.h" namespace platform::esp::arduino_common::geocaching::browse_runtime { bool webLease(bool, bool) { return true; } WebReadResult requestWebPublicationStop(const uint8_t*, const uint8_t*) { return WebReadResult::Unavailable; } WebReadResult readWebPublicationJob(const uint8_t*, const uint8_t*, uint8_t*, size_t, size_t& written) { written = 0; return WebReadResult::Unavailable; } WebReadResult readWebPublicationHistory(const uint8_t*, const uint8_t*, uint8_t*, size_t, size_t& written) { written = 0; return WebReadResult::Unavailable; } bool queueWebDraftPublication(const uint8_t*, uint64_t, const uint8_t*, const uint8_t*, uint32_t, const uint8_t*, const uint8_t*) { return false; } bool publicationContext(uint8_t*, uint64_t&) { return false; } WebReadResult readWebPublicationBinding(const uint8_t*, uint64_t, const uint8_t*, const uint8_t*, uint8_t*, size_t) { return WebReadResult::Unavailable; } WebReadResult readWebLatestRevision(const uint8_t*, const uint8_t*, uint8_t*, size_t) { return WebReadResult::Unavailable; } WebReadResult readWebDirectories(uint32_t, uint8_t*, size_t, size_t& written) { written = 0; return WebReadResult::Unavailable; } bool fake_release_draft_save = true; bool releaseWebDraftSave() { return fake_release_draft_save; } WebReadResult fake_owner_result = WebReadResult::Unavailable; WebReadResult checkWebDraftEditOwner(const uint8_t*, uint64_t, const uint8_t*, const uint8_t*) { return fake_owner_result; } WebReadResult queueWebDraftFile(const uint8_t*, uint32_t, uint64_t, const uint8_t*, const uint8_t*, uint32_t) { return WebReadResult::Unavailable; } WebReadResult readWebDraftCatalog(uint32_t, bool, uint64_t, uint8_t*, size_t, size_t& written) { written = 0; return WebReadResult::Unavailable; } WebReadResult readWebDraftChunk(const uint8_t*, uint32_t, uint16_t, uint64_t, uint8_t*, size_t, size_t& written) { written = 0; return WebReadResult::Unavailable; } } // namespace platform::esp::arduino_common::geocaching::browse_runtime #include "geocaching/storage/draft_record.h" #include #include #include static std::vector response; struct RecoveryAuthor final : chat::IMeshAdapter { uint32_t generation = 7; chat::DeviceAuthorResult result = chat::DeviceAuthorResult::Ok; chat::DeviceAuthorResult readDeviceAuthorKey(uint8_t* key, uint32_t& actual) const override { if (result != chat::DeviceAuthorResult::Ok) return result; std::memset(key, 0, 64); actual = generation; return result; } bool sendText(chat::ChannelId, const std::string&, chat::MessageId*, chat::NodeId) override { return false; } bool pollIncomingText(chat::MeshIncomingText*) override { return false; } bool sendAppData(chat::ChannelId, uint32_t, const uint8_t*, size_t, chat::NodeId, bool, chat::MessageId, bool) override { return false; } bool pollIncomingData(chat::MeshIncomingData*) override { return false; } void applyConfig(const chat::MeshConfig&) override {} bool isReady() const override { return true; } bool pollIncomingRawPacket(uint8_t*, size_t&, size_t) override { return false; } }; namespace hostlink { bool enqueue_event(uint8_t, const uint8_t* bytes, size_t size, bool, uint16_t) { response.assign(bytes, bytes + size); return true; } } // namespace hostlink int main() { using namespace hostlink::management; using namespace platform::esp::arduino_common::storage; std::array owner{}, request{}, id{}; owner[0] = 1; id[0] = 2; const auto original = fake_file; const std::string candidate = "TMSET7\nschema.version=u16:7\ndocument.kind=enum:working\ndevice.node_name=str:NewDevice\nEND\n"; uint8_t scratch[512], base[32], hash[32]; assert(web_config::hash_file("/trailmate/config.tms", scratch, base)); mbedtls_sha256_context ctx; platform::esp::common::crypto::sha256_update(&ctx, reinterpret_cast(candidate.data()), candidate.size()); platform::esp::common::crypto::sha256_finish(&ctx, hash); auto invoke = [&](Operation op, auto body) {uint8_t input[512];geocaching::protocol::CmpWriter writer(input,sizeof(input));body(writer);assert(writer.good());hostlink::PendingCommand command;size_t size;assert(encodeEnvelope(owner.data(),request.data(),static_cast(op),input,writer.size(),command.payload,sizeof(command.payload),size));command.payload_len=size;assert(execute_management(command)==hostlink::ErrorCode::Ok);EnvelopeView result;assert(decodeEnvelope(response.data(),response.size(),result));return result.code; }; auto begin = [&](auto& w) {w.array(4);w.binary({id.data(),16});w.unsignedInteger(1);w.unsignedInteger(candidate.size());w.binary({hash,32}); }; assert(invoke(Operation::TransferBegin, begin) == 0); assert(invoke(Operation::TransferBegin, begin) == 0); auto chunk = [&](auto& w) {w.array(3);w.binary({id.data(),16});w.unsignedInteger(0);w.binary({reinterpret_cast(candidate.data()),candidate.size()}); }; assert(invoke(Operation::TransferChunk, chunk) == 0); assert(invoke(Operation::TransferChunk, chunk) == 0); auto only_id = [&](auto& w) {w.array(1);w.binary({id.data(),16}); }; assert(invoke(Operation::TransferFinish, only_id) == 0); fake_file[0] ^= 1; auto commit = [&](auto& w) {w.array(2);w.binary({id.data(),16});w.binary({base,32}); }; assert(invoke(Operation::ConfigCommit, commit) == 4); fake_file = original; assert(invoke(Operation::ConfigCommit, commit) == 0); assert(fake_file == candidate && app::sd_tms::fake_commits == 1); assert(invoke(Operation::ConfigCommit, commit) == 0); assert(app::sd_tms::fake_commits == 1); owner[0] = 9; assert(invoke(Operation::TransferStatus, only_id) == 2); owner[0] = 1; id[0] = 3; app::sd_tms::fake_restart = false; const auto config_after_commit = fake_file; std::array encoded_draft{}; std::string description(650, 'g'); geocaching::storage::DraftView draft; draft.generation = 1; draft.name = "Web draft"; draft.description = description; size_t draft_size = 0; assert(geocaching::storage::encodeDraft({id.data(), 16}, draft, encoded_draft.data(), encoded_draft.size(), draft_size)); mbedtls_sha256_context draft_ctx; platform::esp::common::crypto::sha256_update(&draft_ctx, encoded_draft.data(), draft_size); platform::esp::common::crypto::sha256_finish(&draft_ctx, hash); auto draft_begin = [&](auto& w) {w.array(4);w.binary({id.data(),16});w.unsignedInteger(2);w.unsignedInteger(draft_size);w.binary({hash,32}); }; assert(invoke(Operation::TransferBegin, draft_begin) == 0); assert(invoke(Operation::TransferBegin, draft_begin) == 0); auto same_content_wrong_kind = [&](auto& w) {w.array(4);w.binary({id.data(),16});w.unsignedInteger(1);w.unsignedInteger(draft_size);w.binary({hash,32}); }; assert(invoke(Operation::TransferBegin, same_content_wrong_kind) == 4); // One shared slot prevents a configuration upload from replacing a draft. assert(invoke(Operation::TransferBegin, begin) == 4); for (size_t offset = 0; offset < draft_size; offset += 320) { const size_t length = std::min(size_t(320), draft_size - offset); auto draft_chunk = [&](auto& w) {w.array(3);w.binary({id.data(),16});w.unsignedInteger(offset);w.binary({encoded_draft.data()+offset,length}); }; assert(invoke(Operation::TransferChunk, draft_chunk) == 0); assert(invoke(Operation::TransferChunk, draft_chunk) == 0); } fake_files[web_config::candidate_path(2)][0] ^= 1; assert(invoke(Operation::TransferFinish, only_id) == 8); fake_files[web_config::candidate_path(2)][0] ^= 1; assert(invoke(Operation::TransferFinish, only_id) == 0); web_config::Metadata metadata; assert(web_config::load(metadata, scratch) && metadata.kind == 2 && metadata.phase == 1 && metadata.offset == draft_size); const auto original_draft_input = fake_files.at(web_config::candidate_path(2)); assert(fake_files[web_config::kMetaPath].size() == 111); assert(invoke(Operation::ConfigCommit, commit) == 3); assert(invoke(Operation::ConfigValidate, only_id) == 3); assert(fake_file == config_after_commit && app::sd_tms::fake_commits == 1); owner[0] = 9; assert(invoke(Operation::TransferAbort, only_id) == 2); owner[0] = 1; assert(invoke(Operation::TransferAbort, only_id) == 0); assert(!sd_exists(web_config::candidate_path(2)) && !sd_exists(web_config::kMetaPath)); id[0] = 4; auto signature_begin = [&](auto& w) { w.array(4); w.binary({id.data(),16}); w.unsignedInteger(5); w.unsignedInteger(draft_size); w.binary({hash,32}); }; assert(invoke(Operation::TransferBegin, signature_begin) == 0); assert(web_config::load(metadata, scratch) && metadata.kind == 5 && metadata.phase == 0); for (size_t offset = 0; offset < draft_size; offset += 320) { const auto count = std::min(size_t(320), draft_size - offset); auto chunk = [&](auto& w) { w.array(3); w.binary({id.data(),16}); w.unsignedInteger(offset); w.binary({encoded_draft.data()+offset,count}); }; assert(invoke(Operation::TransferChunk, chunk) == 0); } assert(invoke(Operation::TransferFinish, only_id) == 0); assert(web_config::load(metadata, scratch) && metadata.kind == 5 && metadata.phase == 1); assert(static_cast(fake_files[web_config::kMetaPath][76]) == 0x41); assert(fake_files[web_config::candidate_path(5)].size() == draft_size); assert(std::memcmp(fake_files[web_config::candidate_path(5)].data(), encoded_draft.data(), draft_size) == 0); assert(invoke(Operation::ConfigValidate, only_id) == 3 && invoke(Operation::ConfigCommit, commit) == 3); assert(fake_file == config_after_commit && app::sd_tms::fake_commits == 1); metadata.kind = 9; const auto retained_meta = fake_files[web_config::kMetaPath]; assert(!web_config::save(metadata, scratch) && fake_files[web_config::kMetaPath] == retained_meta); assert(invoke(Operation::TransferAbort, only_id) == 0); auto oversized_signature = [&](auto& w) { w.array(4); w.binary({id.data(),16}); w.unsignedInteger(5); w.unsignedInteger(4097); w.binary({hash,32}); }; assert(invoke(Operation::TransferBegin, oversized_signature) == 3); assert(!sd_exists(web_config::candidate_path(5)) && !sd_exists(web_config::kMetaPath)); auto confirmation_begin = [&](auto& w) { w.array(4); w.binary({id.data(),16}); w.unsignedInteger(6); w.unsignedInteger(draft_size); w.binary({hash,32}); }; assert(invoke(Operation::TransferBegin, confirmation_begin) == 0); for (size_t offset = 0; offset < draft_size; offset += 320) { const auto count = std::min(size_t(320), draft_size - offset); auto chunk = [&](auto& w) {w.array(3);w.binary({id.data(),16});w.unsignedInteger(offset);w.binary({encoded_draft.data()+offset,count}); }; assert(invoke(Operation::TransferChunk, chunk) == 0); } assert(invoke(Operation::TransferFinish, only_id) == 0); assert(web_config::load(metadata, scratch) && metadata.kind == 6 && metadata.phase == 1); assert(static_cast(fake_files[web_config::kMetaPath][76]) == 0xc1); assert(fake_files[web_config::candidate_path(6)].size() == draft_size); assert(invoke(Operation::ConfigValidate, only_id) == 3 && invoke(Operation::ConfigCommit, commit) == 3); assert(invoke(Operation::TransferAbort, only_id) == 0); auto oversized_confirmation = [&](auto& w) {w.array(4);w.binary({id.data(),16});w.unsignedInteger(6);w.unsignedInteger(4167);w.binary({hash,32}); }; assert(invoke(Operation::TransferBegin, oversized_confirmation) == 3); namespace runtime = platform::esp::arduino_common::geocaching::browse_runtime; metadata.kind = 2; metadata.phase = 3; web_config::put32(metadata.base_hash + 24, 7); assert(web_config::save(metadata, scratch)); std::array author{}; const auto before_recovery = fake_files; for (const auto result : {runtime::WebReadResult::Pending, runtime::WebReadResult::Ready, runtime::WebReadResult::Forbidden, runtime::WebReadResult::Conflict, runtime::WebReadResult::Unavailable}) { runtime::fake_owner_result = result; assert(web_config::reconcileCommittedDraft(metadata, author.data(), 7, scratch) != Status::Ok); assert(metadata.phase == 3 && fake_files == before_recovery); } runtime::fake_owner_result = runtime::WebReadResult::AlreadySaved; assert(web_config::reconcileCommittedDraft(metadata, author.data(), 8, scratch) == Status::StaleIdentity); assert(fake_files == before_recovery && metadata.phase == 3); assert(web_config::reconcileCommittedDraft(metadata, author.data(), 7, scratch) == Status::Ok); assert(metadata.phase == 4 && web_config::load(metadata, scratch) && metadata.phase == 4); auto new_id = id; new_id[0]++; owner[0] = 9; // A new browser session can reuse an already-committed slot. auto new_signature = [&](auto& w) {w.array(4);w.binary({new_id.data(),16});w.unsignedInteger(5);w.unsignedInteger(draft_size);w.binary({hash,32}); }; assert(invoke(Operation::TransferBegin, new_signature) == 0); assert(web_config::load(metadata, scratch) && metadata.phase == 0 && metadata.kind == 5); assert(!std::memcmp(metadata.owner, owner.data(), 16) && !std::memcmp(metadata.id, new_id.data(), 16)); metadata.kind = 2; metadata.phase = 3; metadata.offset = metadata.total; metadata.id[0] = 44; web_config::put32(metadata.base_hash + 24, 7); assert(web_config::save(metadata, scratch)); const auto stranded_upload = fake_files; RecoveryAuthor recovery; app::fake_messaging.adapter = &recovery; owner[0] = 22; runtime::fake_owner_result = runtime::WebReadResult::Pending; assert(invoke(Operation::TransferBegin, new_signature) == 5 && fake_files == stranded_upload); recovery.generation = 8; assert(invoke(Operation::TransferBegin, new_signature) == 9 && fake_files == stranded_upload); recovery.generation = 7; recovery.result = chat::DeviceAuthorResult::StorageError; assert(invoke(Operation::TransferBegin, new_signature) == 6 && fake_files == stranded_upload); recovery.result = chat::DeviceAuthorResult::Ok; runtime::fake_owner_result = runtime::WebReadResult::Forbidden; assert(invoke(Operation::TransferBegin, new_signature) == 2 && fake_files == stranded_upload); runtime::fake_owner_result = runtime::WebReadResult::AlreadySaved; assert(invoke(Operation::TransferBegin, new_signature) == 0); assert(web_config::load(metadata, scratch) && metadata.kind == 5 && metadata.phase == 0); assert(!std::memcmp(metadata.owner, owner.data(), 16)); // Recovery rotates only transfer metadata, not device config or the saved draft. assert(fake_file == config_after_commit && app::sd_tms::fake_commits == 1); assert(fake_files.count(web_config::candidate_path(2)) == stranded_upload.count(web_config::candidate_path(2))); if (stranded_upload.count(web_config::candidate_path(2))) assert(fake_files.at(web_config::candidate_path(2)) == stranded_upload.at(web_config::candidate_path(2))); app::fake_messaging.adapter = nullptr; // Recovery only exposes the owner of exact sealed unsigned input belonging // to the current author. Query never changes files or signs/deletes input. std::array current_author{}; current_author[0] = 27; std::array nonce{}; nonce[0] = 28; geocaching::protocol::CmpWriter unsigned_writer(encoded_draft.data(), encoded_draft.size()); assert(unsigned_writer.array(16) && unsigned_writer.unsignedInteger(1) && unsigned_writer.binary({current_author.data(), 64}) && unsigned_writer.binary({nonce.data(), 16})); for (unsigned i = 0; i < 13; ++i) assert(unsigned_writer.nil()); const auto raw_size = unsigned_writer.size(); fake_files[web_config::candidate_path(5)].assign(reinterpret_cast(encoded_draft.data()), raw_size); metadata.kind = 5; metadata.phase = 1; metadata.total = metadata.offset = raw_size; std::memcpy(metadata.owner, owner.data(), 16); std::memcpy(metadata.id, id.data(), 16); assert(web_config::hash_file(web_config::candidate_path(5), scratch, metadata.hash, raw_size)); assert(web_config::save(metadata, scratch)); const uint8_t query_body[]{0x92, 5, 0xc0}; EnvelopeView query; query.session = request.data(); // A different session is allowed to query. query.body = query_body; query.body_size = sizeof(query_body); size_t written = 0; const auto sealed_files = fake_files; auto query_upload = [&] { return web_config::listSharedSignatureUpload(query, current_author.data(), 7, 11, scratch, sizeof(scratch), written); }; assert(query_upload() == Status::Ok && fake_files == sealed_files); geocaching::protocol::CmpReader recovery_reader({scratch, written}); size_t fields = 0; uint64_t value = 0; geocaching::ByteView view; assert(recovery_reader.array(fields, 3) && fields == 3 && recovery_reader.unsignedInteger(value) && value == 11 && recovery_reader.nil()); assert(recovery_reader.array(fields, 9) && fields == 9 && recovery_reader.binary(view, 16) && !std::memcmp(view.data, metadata.id, 16)); assert(recovery_reader.unsignedInteger(value) && value == 5 && recovery_reader.unsignedInteger(value) && value == raw_size); assert(recovery_reader.binary(view, 32) && !std::memcmp(view.data, metadata.hash, 32)); assert(recovery_reader.unsignedInteger(value) && value == 7 && recovery_reader.unsignedInteger(value) && value == 1 && recovery_reader.unsignedInteger(value) && value == raw_size); assert(recovery_reader.binary(view, 16) && !std::memcmp(view.data, owner.data(), 16)); assert(recovery_reader.binary(view, 16) && !std::memcmp(view.data, nonce.data(), 16) && recovery_reader.finished()); current_author[0]++; assert(query_upload() == Status::Ok && scratch[written - 1] == 0xc0 && fake_files == sealed_files); current_author[0]--; fake_files[web_config::candidate_path(5)].back() ^= 1; const auto corrupted_files = fake_files; assert(query_upload() == Status::HashMismatch && written == 0 && fake_files == corrupted_files); fake_files = sealed_files; for (const auto phase : {0, 2}) { metadata.phase = phase; assert(web_config::save(metadata, scratch)); const auto before = fake_files; assert(query_upload() == Status::Ok && scratch[written - 1] == 0xc0 && fake_files == before); } metadata.kind = 6; metadata.phase = 1; assert(web_config::save(metadata, scratch)); const auto confirmation_files = fake_files; assert(query_upload() == Status::Ok && scratch[written - 1] == 0xc0 && fake_files == confirmation_files); query.body_size--; assert(query_upload() == Status::InvalidField && written == 0 && fake_files == confirmation_files); const uint8_t confirm_query[]{0x92, 6, 0xc0}; query.body = confirm_query; query.body_size = sizeof(confirm_query); std::array signed_bytes{}; std::array signature{}; geocaching::protocol::CmpWriter signed_writer(signed_bytes.data(), signed_bytes.size()); assert(signed_writer.array(2) && signed_writer.binary({encoded_draft.data(), raw_size}) && signed_writer.binary({signature.data(), signature.size()})); metadata.total = metadata.offset = signed_writer.size(); fake_files[web_config::candidate_path(6)].assign(reinterpret_cast(signed_bytes.data()), metadata.total); assert(web_config::hash_file(web_config::candidate_path(6), scratch, metadata.hash, metadata.total)); assert(web_config::save(metadata, scratch)); const auto sealed_confirmation = fake_files; assert(query_upload() == Status::Ok && fake_files == sealed_confirmation); geocaching::protocol::CmpReader confirmed_row({scratch, written}); assert(confirmed_row.array(fields, 3) && fields == 3 && confirmed_row.unsignedInteger(value) && confirmed_row.nil()); assert(confirmed_row.array(fields, 9) && fields == 9 && confirmed_row.binary(view, 16) && confirmed_row.unsignedInteger(value) && value == 6); auto completion = [&](unsigned phase, bool wrong_hash = false) { geocaching::protocol::CmpWriter writer(scratch, sizeof(scratch)); auto hash = metadata.hash; uint8_t bad_hash[32]{}; assert(writer.array(8) && writer.binary({metadata.id, 16}) && writer.binary({wrong_hash ? bad_hash : hash, 32}) && writer.unsignedInteger(7) && writer.unsignedInteger(phase)); if (phase == 1) assert(writer.binary({base, 32}) && writer.unsignedInteger(1) && writer.binary({base, 32}) && writer.unsignedInteger(1)); else assert(writer.nil() && writer.nil() && writer.nil() && writer.unsignedInteger(0)); written = writer.size(); return web_config::finishConfirmedUpload(query, scratch, sizeof(scratch), written); }; assert(completion(1) == Status::Conflict && written == 0 && fake_files == sealed_confirmation); // foreign correlation query.session = metadata.owner; assert(completion(1, true) == Status::Conflict && written == 0 && fake_files == sealed_confirmation); assert(completion(0) == Status::Ok && fake_files == sealed_confirmation); fake_write_failure = true; assert(completion(1) == Status::StorageUnavailable && written == 0); fake_write_failure = false; assert(web_config::load(metadata, scratch) && metadata.phase == 1); assert(completion(1) == Status::Ok); assert(web_config::load(metadata, scratch) && metadata.kind == 6 && metadata.phase == 2); const auto finished_confirmation = fake_files; assert(completion(1) == Status::Ok && fake_files == finished_confirmation); // exact lost-reply replay assert(query_upload() == Status::Ok && scratch[written - 1] == 0xc0); id[0]++; assert(invoke(Operation::TransferBegin, draft_begin) == 0); assert(web_config::load(metadata, scratch) && metadata.kind == 2 && metadata.phase == 0); assert(fake_file == config_after_commit && app::sd_tms::fake_commits == 1); // A failed draft save leaves bound input in phase 2. A new browser must be // able to retry those exact bytes without knowing the abandoned session ID. recovery.result = chat::DeviceAuthorResult::Ok; recovery.generation = 7; app::fake_messaging.adapter = &recovery; fake_files[web_config::candidate_path(2)] = original_draft_input; mbedtls_sha256_context retry_hash; platform::esp::common::crypto::sha256_update(&retry_hash, reinterpret_cast(original_draft_input.data()), original_draft_input.size()); platform::esp::common::crypto::sha256_finish(&retry_hash, hash); const auto new_owner = owner; const auto new_transfer = id; for (const uint8_t phase : {uint8_t(2), uint8_t(3)}) { metadata = {}; metadata.kind = 2; metadata.phase = phase; metadata.total = metadata.offset = static_cast(original_draft_input.size()); std::memcpy(metadata.owner, new_owner.data(), 16); metadata.owner[0] ^= 0x40; std::memcpy(metadata.id, new_transfer.data(), 16); metadata.id[0] ^= 0x40; std::memcpy(metadata.hash, hash, 32); metadata.base_hash[0] = 17; // Original draft and compare-and-swap version. metadata.base_hash[16] = 1; web_config::put32(metadata.base_hash + 24, 7); assert(web_config::save(metadata, scratch)); const auto bound = metadata; const auto pending_files = fake_files; runtime::fake_owner_result = runtime::WebReadResult::Ready; runtime::fake_release_draft_save = false; assert(invoke(Operation::TransferBegin, draft_begin) == 5 && fake_files == pending_files); runtime::fake_release_draft_save = true; hash[0] ^= 1; assert(invoke(Operation::TransferBegin, draft_begin) == 5 && fake_files == pending_files); hash[0] ^= 1; runtime::fake_owner_result = runtime::WebReadResult::Forbidden; assert(invoke(Operation::TransferBegin, draft_begin) == 2 && fake_files == pending_files); runtime::fake_owner_result = runtime::WebReadResult::Ready; recovery.generation = 8; assert(invoke(Operation::TransferBegin, draft_begin) == 9 && fake_files == pending_files); recovery.generation = 7; assert(invoke(Operation::TransferBegin, draft_begin) == 0); assert(web_config::load(metadata, scratch) && metadata.phase == 2); assert(!std::memcmp(metadata.owner, new_owner.data(), 16) && !std::memcmp(metadata.id, new_transfer.data(), 16)); assert(!std::memcmp(metadata.base_hash, bound.base_hash, 32) && !std::memcmp(metadata.hash, hash, 32)); assert(metadata.total == bound.total && metadata.offset == bound.offset); assert(fake_files.at(web_config::candidate_path(2)) == original_draft_input); assert(invoke(Operation::TransferBegin, draft_begin) == 0); // Exact retry is idempotent. assert(fake_file == config_after_commit && app::sd_tms::fake_commits == 1); } app::fake_messaging.adapter = nullptr; }