#include #include "bank.hpp" #include #include #include namespace utils::bank { namespace { constexpr size_t max_body_size = 0x5000; constexpr size_t max_file_size = 16 * 1024 * 1024; constexpr size_t max_reports = 4096; // Stable, compact filename for arbitrary server names and endpoints. // The envelope also checks both strings, so a collision fails closed. std::string scope_name(const std::string& scope) { uint64_t hash = 14695981039346656037ull; for (const unsigned char byte : scope) hash = (hash ^ byte) * 1099511628211ull; std::ostringstream result; result << std::hex << std::setfill('0') << std::setw(16) << hash; return result.str(); } } acknowledgement parse_acknowledgement(const std::string& response) { auto invalid = [](const char* reason) { acknowledgement value; value.reason = reason; return value; }; if (response.empty()) return invalid("empty_body"); if (response.size() >= max_body_size) return invalid("body_limit"); const auto data = nlohmann::json::parse(response, nullptr, false); if (data.is_discarded()) return invalid("invalid_json"); if (!data.is_object()) return invalid("schema"); try { acknowledgement result; if (!data.at("ok").is_boolean()) return invalid("schema"); result.state = data.at("state").get(); result.code = data.at("code").get(); result.id = data.at("request_id").get(); if (result.id.size() > 64 || result.code.size() > 64) return invalid("field_limit"); if (result.state != "balance" && result.state != "prepared" && result.state != "committed" && result.state != "cancelled" && result.state != "rejected") return invalid("state"); for (const auto* key : {"amount", "balance"}) if (!data.at(key).is_number_integer() || data.at(key) < 0 || data.at(key) > 1000000) return invalid("amount_range"); result.amount = data.at("amount").get(); result.balance = data.at("balance").get(); if (data.at("ok").get() != (result.state != "rejected")) return invalid("ok_state"); if (result.state != "balance" && result.id.empty()) return invalid("missing_id"); if ((result.state == "prepared" || result.state == "committed") && result.amount == 0) return invalid("zero_transfer"); result.valid = true; result.reason = "ok"; return result; } catch (...) { return invalid("schema"); } } outbox::outbox(std::filesystem::path root, std::string server, std::string endpoint) : server_(std::move(server)), endpoint_(std::move(endpoint)) { file_ = root / (scope_name(server_ + '\0' + endpoint_) + ".json"); } report outbox::decode(const std::string& body) const { if (body.empty() || body.size() >= max_body_size) throw std::runtime_error("invalid bank request size"); const auto data = nlohmann::json::parse(body); if (!data.is_object() || data.at("server") != server_) throw std::runtime_error("invalid bank scope"); report value; value.id = data.at("request_id").get(); value.decision = data.at("decision").get(); if (value.id.empty() || value.id.size() > 64 || (value.decision != "cancel" && value.decision != "commit")) throw std::runtime_error("invalid bank decision"); value.body = body; return value; } std::vector outbox::load() const { if (!std::filesystem::exists(file_)) return {}; if (std::filesystem::file_size(file_) > max_file_size) throw std::runtime_error("bank outbox exceeds size limit"); std::ifstream stream(file_, std::ios::binary); if (!stream) throw std::runtime_error("cannot open bank outbox"); const auto data = nlohmann::json::parse(stream); if (stream.bad() || !data.is_object() || data.at("version") != 1 || data.at("server") != server_ || data.at("endpoint") != endpoint_ || !data.at("reports").is_array() || data.at("reports").size() > max_reports) throw std::runtime_error("invalid bank outbox; preserve file for recovery"); std::vector reports; std::set identities; for (const auto& body : data.at("reports")) { auto value = decode(body.get()); if (!identities.emplace(value.id).second) throw std::runtime_error("duplicate identity in bank outbox"); reports.push_back(std::move(value)); } return reports; } void outbox::save(const std::vector& reports) const { if (reports.size() > max_reports) throw std::runtime_error("bank outbox is full; delivery blocked"); nlohmann::json data = {{"version", 1}, {"server", server_}, {"endpoint", endpoint_}, {"reports", nlohmann::json::array()}}; for (const auto& value : reports) data["reports"].push_back(value.body); const auto encoded = data.dump(); if (encoded.size() > max_file_size) throw std::runtime_error("bank outbox is full; delivery blocked"); std::filesystem::create_directories(file_.parent_path()); auto temporary = file_; temporary += ".tmp"; std::ofstream stream(temporary, std::ios::binary | std::ios::trunc); stream.write(encoded.data(), static_cast(encoded.size())); stream.flush(); if (!stream) throw std::runtime_error("cannot write bank outbox; old file retained"); stream.close(); if (stream.fail()) throw std::runtime_error("cannot close bank outbox; old file retained"); #ifdef _WIN32 if (!MoveFileExW(temporary.c_str(), file_.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) throw std::runtime_error("cannot replace bank outbox; old file retained"); #else // Used by the portable tests; the production plugin targets Windows. std::filesystem::rename(temporary, file_); #endif } void outbox::store(const std::string& body) const { const auto incoming = decode(body); auto reports = load(); for (auto& value : reports) { if (value.id != incoming.id) continue; auto old_data = nlohmann::json::parse(value.body); auto new_data = nlohmann::json::parse(body); old_data.erase("decision"); new_data.erase("decision"); if (old_data != new_data || (value.decision == "commit" && incoming.decision != "commit")) throw std::runtime_error("bank identity/decision conflict"); if (value.body != body) { value = incoming; save(reports); } return; } reports.push_back(incoming); save(reports); } void outbox::erase(const std::string& id) const { auto reports = load(); const auto end = std::remove_if(reports.begin(), reports.end(), [&](const report& value) { return value.id == id; }); if (end == reports.end()) return; reports.erase(end, reports.end()); save(reports); } }