Files
tws-zombies-scripts/t6/plugins/tws-t6-gsc-utils/src/utils/bank.cpp
T
josh 463b4bcc51 Add safe bank failure diagnostics for T5 and T6
Address #14 with request correlation, bounded parser reasons, startup version reporting, and rate-limited storage and settlement logs. Preserve accounting and API identity contracts, extend portable tests, and update release versions and rollout guidance.
2026-10-13 10:02:36 -04:00

188 lines
7.7 KiB
C++

#include <stdinc.hpp>
#include "bank.hpp"
#include <cstdint>
#include <iomanip>
#include <set>
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<std::string>();
result.code = data.at("code").get<std::string>();
result.id = data.at("request_id").get<std::string>();
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<int>();
result.balance = data.at("balance").get<int>();
if (data.at("ok").get<bool>() != (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<std::string>();
value.decision = data.at("decision").get<std::string>();
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<report> 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<report> reports;
std::set<std::string> identities;
for (const auto& body : data.at("reports"))
{
auto value = decode(body.get<std::string>());
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<report>& 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<std::streamsize>(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);
}
}