fix(export): write the transaction CSV atomically and report failures
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>
This commit is contained in:
@@ -513,6 +513,7 @@ set(APP_SOURCES
|
|||||||
src/wallet/lite_wallet_server_selection_adapter.cpp
|
src/wallet/lite_wallet_server_selection_adapter.cpp
|
||||||
src/wallet/lite_wallet_lifecycle_service.cpp
|
src/wallet/lite_wallet_lifecycle_service.cpp
|
||||||
src/data/wallet_state.cpp
|
src/data/wallet_state.cpp
|
||||||
|
src/data/transaction_csv.cpp
|
||||||
src/data/transaction_history_cache.cpp
|
src/data/transaction_history_cache.cpp
|
||||||
src/data/transaction_history_store_policy.cpp
|
src/data/transaction_history_store_policy.cpp
|
||||||
src/ui/theme.cpp
|
src/ui/theme.cpp
|
||||||
@@ -1261,6 +1262,7 @@ if(BUILD_TESTING)
|
|||||||
src/util/text_format.cpp
|
src/util/text_format.cpp
|
||||||
src/util/image_decode_core.cpp
|
src/util/image_decode_core.cpp
|
||||||
src/data/wallet_state.cpp
|
src/data/wallet_state.cpp
|
||||||
|
src/data/transaction_csv.cpp
|
||||||
src/data/transaction_history_cache.cpp
|
src/data/transaction_history_cache.cpp
|
||||||
src/data/transaction_history_store_policy.cpp
|
src/data/transaction_history_store_policy.cpp
|
||||||
src/data/address_book.cpp
|
src/data/address_book.cpp
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// 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
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// DragonX Wallet - ImGui Edition
|
||||||
|
// Copyright 2024-2026 The Hush Developers
|
||||||
|
// Released under the GPLv3
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "wallet_state.h"
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace dragonx {
|
||||||
|
namespace data {
|
||||||
|
|
||||||
|
// CSV-escape one field: neutralises spreadsheet formula injection (leading = + - @ \t \r gets a
|
||||||
|
// ' prefix) and quotes fields containing , " or \n.
|
||||||
|
std::string escapeCsvField(const std::string& field);
|
||||||
|
|
||||||
|
// "Date,Type,Amount,Address,TXID,Confirmations,Memo\n" + one line per transaction
|
||||||
|
// (local time, 8-decimals amount).
|
||||||
|
std::string formatTransactionsCsv(const std::vector<TransactionInfo>& txs);
|
||||||
|
|
||||||
|
} // namespace data
|
||||||
|
} // namespace dragonx
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
#include "../../app.h"
|
#include "../../app.h"
|
||||||
#include "../../util/i18n.h"
|
#include "../../util/i18n.h"
|
||||||
#include "../../util/platform.h"
|
#include "../../util/platform.h"
|
||||||
|
#include "../../data/transaction_csv.h"
|
||||||
#include "../notifications.h"
|
#include "../notifications.h"
|
||||||
#include "../schema/ui_schema.h"
|
#include "../schema/ui_schema.h"
|
||||||
#include "../material/draw_helpers.h"
|
#include "../material/draw_helpers.h"
|
||||||
@@ -16,10 +17,7 @@
|
|||||||
#include "imgui.h"
|
#include "imgui.h"
|
||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <fstream>
|
|
||||||
#include <sstream>
|
|
||||||
#include <ctime>
|
#include <ctime>
|
||||||
#include <iomanip>
|
|
||||||
|
|
||||||
namespace dragonx {
|
namespace dragonx {
|
||||||
namespace ui {
|
namespace ui {
|
||||||
@@ -29,38 +27,6 @@ static bool s_open = false;
|
|||||||
static char s_filename[256] = "";
|
static char s_filename[256] = "";
|
||||||
static std::string s_saved_path; // full path on success (shown as a copy field); empty otherwise
|
static std::string s_saved_path; // full path on success (shown as a copy field); empty otherwise
|
||||||
static std::string s_error; // error message on failure; empty otherwise
|
static std::string s_error; // error message on failure; empty otherwise
|
||||||
// Re-entrancy guard: true while a CSV write is in flight (disables the
|
|
||||||
// Export button so a second synchronous write can't be kicked off).
|
|
||||||
static bool s_exporting = false;
|
|
||||||
|
|
||||||
// Helper to escape CSV field
|
|
||||||
static std::string escapeCSV(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) {
|
|
||||||
// Escape quotes and wrap in quotes
|
|
||||||
std::string escaped;
|
|
||||||
escaped.reserve(safe.size() + 4);
|
|
||||||
escaped += '"';
|
|
||||||
for (char c : safe) {
|
|
||||||
if (c == '"') escaped += "\"\"";
|
|
||||||
else escaped += c;
|
|
||||||
}
|
|
||||||
escaped += '"';
|
|
||||||
return escaped;
|
|
||||||
}
|
|
||||||
return safe;
|
|
||||||
}
|
|
||||||
|
|
||||||
void ExportTransactionsDialog::show()
|
void ExportTransactionsDialog::show()
|
||||||
{
|
{
|
||||||
@@ -71,7 +37,14 @@ void ExportTransactionsDialog::show()
|
|||||||
// Generate default filename with timestamp
|
// Generate default filename with timestamp
|
||||||
std::time_t now = std::time(nullptr);
|
std::time_t now = std::time(nullptr);
|
||||||
char timebuf[32];
|
char timebuf[32];
|
||||||
std::strftime(timebuf, sizeof(timebuf), "%Y%m%d_%H%M%S", std::localtime(&now));
|
std::tm nowTm{};
|
||||||
|
#ifdef _WIN32
|
||||||
|
const bool nowOk = localtime_s(&nowTm, &now) == 0;
|
||||||
|
#else
|
||||||
|
const bool nowOk = localtime_r(&now, &nowTm) != nullptr;
|
||||||
|
#endif
|
||||||
|
timebuf[0] = '\0';
|
||||||
|
if (nowOk) std::strftime(timebuf, sizeof(timebuf), "%Y%m%d_%H%M%S", &nowTm);
|
||||||
snprintf(s_filename, sizeof(s_filename), "dragonx_transactions_%s.csv", timebuf);
|
snprintf(s_filename, sizeof(s_filename), "dragonx_transactions_%s.csv", timebuf);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +102,7 @@ void ExportTransactionsDialog::render(App* app)
|
|||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
|
|
||||||
bool doExport = false, doClose = false;
|
bool doExport = false, doClose = false;
|
||||||
m::DialogActionFooter(TR("export"), !s_exporting, TR("close"), doExport, doClose);
|
m::DialogActionFooter(TR("export"), true, TR("close"), doExport, doClose);
|
||||||
if (doClose) s_open = false;
|
if (doClose) s_open = false;
|
||||||
if (doExport) {
|
if (doExport) {
|
||||||
s_saved_path.clear();
|
s_saved_path.clear();
|
||||||
@@ -137,35 +110,18 @@ void ExportTransactionsDialog::render(App* app)
|
|||||||
if (state.transactions.empty()) {
|
if (state.transactions.empty()) {
|
||||||
Notifications::instance().warning(TR("export_tx_none"));
|
Notifications::instance().warning(TR("export_tx_none"));
|
||||||
} else {
|
} else {
|
||||||
s_exporting = true;
|
|
||||||
std::string configDir = util::Platform::getConfigDir();
|
std::string configDir = util::Platform::getConfigDir();
|
||||||
std::string filepath = configDir + "/" + s_filename;
|
std::string filepath = configDir + "/" + s_filename;
|
||||||
|
// Build in memory, then write atomically (temp + fsync + rename): a disk-full /
|
||||||
std::ofstream file(filepath);
|
// removable-media failure is reported instead of leaving a truncated CSV behind a success toast.
|
||||||
if (!file.is_open()) {
|
const std::string csv = data::formatTransactionsCsv(state.transactions);
|
||||||
|
if (!util::Platform::writeFileAtomically(filepath, csv)) {
|
||||||
s_error = TR("export_tx_file_fail");
|
s_error = TR("export_tx_file_fail");
|
||||||
Notifications::instance().error(TR("export_tx_file_fail"));
|
Notifications::instance().error(TR("export_tx_file_fail"));
|
||||||
} else {
|
} else {
|
||||||
file << "Date,Type,Amount,Address,TXID,Confirmations,Memo\n";
|
|
||||||
for (const auto& tx : state.transactions) {
|
|
||||||
std::time_t t = static_cast<std::time_t>(tx.timestamp);
|
|
||||||
char datebuf[32];
|
|
||||||
std::strftime(datebuf, sizeof(datebuf), "%Y-%m-%d %H:%M:%S", std::localtime(&t));
|
|
||||||
file << datebuf << ",";
|
|
||||||
file << escapeCSV(tx.type) << ",";
|
|
||||||
std::ostringstream amt;
|
|
||||||
amt << std::fixed << std::setprecision(8) << tx.amount;
|
|
||||||
file << amt.str() << ",";
|
|
||||||
file << escapeCSV(tx.address) << ",";
|
|
||||||
file << escapeCSV(tx.txid) << ",";
|
|
||||||
file << tx.confirmations << ",";
|
|
||||||
file << escapeCSV(tx.memo) << "\n";
|
|
||||||
}
|
|
||||||
file.close();
|
|
||||||
s_saved_path = filepath;
|
s_saved_path = filepath;
|
||||||
Notifications::instance().success(TR("export_tx_success"), 5.0f);
|
Notifications::instance().success(TR("export_tx_success"), 5.0f);
|
||||||
}
|
}
|
||||||
s_exporting = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
#endif
|
#endif
|
||||||
#include "data/exchange_info.h"
|
#include "data/exchange_info.h"
|
||||||
#include "util/text_format.h"
|
#include "util/text_format.h"
|
||||||
|
#include "data/transaction_csv.h"
|
||||||
#include "chat/chat_crypto.h"
|
#include "chat/chat_crypto.h"
|
||||||
#include "chat/chat_identity.h"
|
#include "chat/chat_identity.h"
|
||||||
#include "chat/chat_service.h"
|
#include "chat/chat_service.h"
|
||||||
@@ -18129,6 +18130,20 @@ void testContactsTextHelpers() {
|
|||||||
EXPECT_EQ(dragonx::util::trimmedClipboardText(std::string(1024, 'a').c_str()).size(), (size_t)1024);
|
EXPECT_EQ(dragonx::util::trimmedClipboardText(std::string(1024, 'a').c_str()).size(), (size_t)1024);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void testTransactionCsv() {
|
||||||
|
std::vector<dragonx::TransactionInfo> txs(2);
|
||||||
|
txs[0].type = "receive"; txs[0].amount = 1.5; txs[0].address = "zs1x"; txs[0].txid = "ab"; txs[0].confirmations = 7;
|
||||||
|
txs[0].timestamp = 1700000000; txs[0].memo = "=HYPERLINK(\"x\")";
|
||||||
|
txs[1].type = "send"; txs[1].amount = -0.00000001; txs[1].memo = "a,b\"c";
|
||||||
|
const std::string csv = dragonx::data::formatTransactionsCsv(txs);
|
||||||
|
EXPECT_TRUE(csv.rfind("Date,Type,Amount,Address,TXID,Confirmations,Memo\n", 0) == 0);
|
||||||
|
EXPECT_TRUE(csv.find(",1.50000000,zs1x,ab,7,\"'=HYPERLINK(\"\"x\"\")\"\n") != std::string::npos);
|
||||||
|
EXPECT_TRUE(csv.find(",-0.00000001,") != std::string::npos);
|
||||||
|
EXPECT_TRUE(csv.find("\"a,b\"\"c\"\n") != std::string::npos);
|
||||||
|
EXPECT_EQ(dragonx::data::escapeCsvField("+1"), std::string("'+1"));
|
||||||
|
EXPECT_EQ(dragonx::data::escapeCsvField("plain"), std::string("plain"));
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
int main()
|
int main()
|
||||||
@@ -18438,6 +18453,7 @@ int main()
|
|||||||
testWriteFileIfDifferent();
|
testWriteFileIfDifferent();
|
||||||
testCaptureExtent();
|
testCaptureExtent();
|
||||||
testResolveExchangeSelection();
|
testResolveExchangeSelection();
|
||||||
|
testTransactionCsv();
|
||||||
|
|
||||||
if (g_failures != 0) {
|
if (g_failures != 0) {
|
||||||
std::cerr << g_failures << " test assertion(s) failed\n";
|
std::cerr << g_failures << " test assertion(s) failed\n";
|
||||||
|
|||||||
Reference in New Issue
Block a user