The transaction export streamed rows straight into an ofstream. A write that failed partway, on a full disk or removable media, left a truncated CSV on disk behind a success toast. The CSV formatting also lived inline in the dialog with no test coverage. - The CSV is now built in memory and written atomically (temp file plus rename), so a write failure is reported with an error toast and leaves no partial file. - Formatting moves into a reusable helper, and the timestamp conversions use the thread-safe localtime variants and tolerate a conversion failure rather than dereferencing a null result. Tests: field escaping neutralises formula-injection prefixes and quotes special characters, and the full CSV has the expected header and rows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
70 lines
2.2 KiB
C++
70 lines
2.2 KiB
C++
// DragonX Wallet - ImGui Edition
|
|
// Copyright 2024-2026 The Hush Developers
|
|
// Released under the GPLv3
|
|
|
|
#include "transaction_csv.h"
|
|
|
|
#include <cstdio>
|
|
#include <ctime>
|
|
|
|
namespace dragonx {
|
|
namespace data {
|
|
|
|
std::string escapeCsvField(const std::string& field)
|
|
{
|
|
// Neutralize spreadsheet formula injection: a field beginning with '=', '+', '-', '@',
|
|
// tab, or CR is interpreted as a formula by Excel/LibreOffice, letting an attacker-supplied
|
|
// memo/address execute on open. Prefix such fields with a single quote so they render as text.
|
|
std::string safe = field;
|
|
if (!safe.empty()) {
|
|
const char c0 = safe.front();
|
|
if (c0 == '=' || c0 == '+' || c0 == '-' || c0 == '@' || c0 == '\t' || c0 == '\r')
|
|
safe.insert(safe.begin(), '\'');
|
|
}
|
|
if (safe.find(',') != std::string::npos ||
|
|
safe.find('"') != std::string::npos ||
|
|
safe.find('\n') != std::string::npos) {
|
|
std::string escaped;
|
|
escaped.reserve(safe.size() + 4);
|
|
escaped += '"';
|
|
for (char c : safe) {
|
|
if (c == '"') escaped += "\"\"";
|
|
else escaped += c;
|
|
}
|
|
escaped += '"';
|
|
return escaped;
|
|
}
|
|
return safe;
|
|
}
|
|
|
|
std::string formatTransactionsCsv(const std::vector<TransactionInfo>& txs)
|
|
{
|
|
std::string out;
|
|
out.reserve(64 + txs.size() * 200);
|
|
out += "Date,Type,Amount,Address,TXID,Confirmations,Memo\n";
|
|
for (const auto& tx : txs) {
|
|
std::time_t t = static_cast<std::time_t>(tx.timestamp);
|
|
std::tm tmv{};
|
|
#ifdef _WIN32
|
|
const bool okTm = localtime_s(&tmv, &t) == 0;
|
|
#else
|
|
const bool okTm = localtime_r(&t, &tmv) != nullptr;
|
|
#endif
|
|
char datebuf[32] = "";
|
|
if (okTm) std::strftime(datebuf, sizeof(datebuf), "%Y-%m-%d %H:%M:%S", &tmv);
|
|
char amt[48];
|
|
std::snprintf(amt, sizeof(amt), "%.8f", tx.amount);
|
|
out += datebuf; out += ',';
|
|
out += escapeCsvField(tx.type); out += ',';
|
|
out += amt; out += ',';
|
|
out += escapeCsvField(tx.address); out += ',';
|
|
out += escapeCsvField(tx.txid); out += ',';
|
|
out += std::to_string(tx.confirmations); out += ',';
|
|
out += escapeCsvField(tx.memo); out += '\n';
|
|
}
|
|
return out;
|
|
}
|
|
|
|
} // namespace data
|
|
} // namespace dragonx
|