From bbe2bf94dccee927a8cad7930099355ab059762d Mon Sep 17 00:00:00 2001 From: DanS Date: Tue, 13 Oct 2026 16:00:10 -0500 Subject: [PATCH] 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 --- CMakeLists.txt | 2 + src/data/transaction_csv.cpp | 69 ++++++++++++++++++ src/data/transaction_csv.h | 23 ++++++ src/ui/windows/export_transactions_dialog.cpp | 72 ++++--------------- tests/test_phase4.cpp | 16 +++++ 5 files changed, 124 insertions(+), 58 deletions(-) create mode 100644 src/data/transaction_csv.cpp create mode 100644 src/data/transaction_csv.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 5d131ee..e7278c0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -513,6 +513,7 @@ set(APP_SOURCES src/wallet/lite_wallet_server_selection_adapter.cpp src/wallet/lite_wallet_lifecycle_service.cpp src/data/wallet_state.cpp + src/data/transaction_csv.cpp src/data/transaction_history_cache.cpp src/data/transaction_history_store_policy.cpp src/ui/theme.cpp @@ -1261,6 +1262,7 @@ if(BUILD_TESTING) src/util/text_format.cpp src/util/image_decode_core.cpp src/data/wallet_state.cpp + src/data/transaction_csv.cpp src/data/transaction_history_cache.cpp src/data/transaction_history_store_policy.cpp src/data/address_book.cpp diff --git a/src/data/transaction_csv.cpp b/src/data/transaction_csv.cpp new file mode 100644 index 0000000..98e76a6 --- /dev/null +++ b/src/data/transaction_csv.cpp @@ -0,0 +1,69 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 + +#include "transaction_csv.h" + +#include +#include + +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& 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(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 diff --git a/src/data/transaction_csv.h b/src/data/transaction_csv.h new file mode 100644 index 0000000..2d3035c --- /dev/null +++ b/src/data/transaction_csv.h @@ -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 +#include + +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& txs); + +} // namespace data +} // namespace dragonx diff --git a/src/ui/windows/export_transactions_dialog.cpp b/src/ui/windows/export_transactions_dialog.cpp index b391dca..216090f 100644 --- a/src/ui/windows/export_transactions_dialog.cpp +++ b/src/ui/windows/export_transactions_dialog.cpp @@ -6,6 +6,7 @@ #include "../../app.h" #include "../../util/i18n.h" #include "../../util/platform.h" +#include "../../data/transaction_csv.h" #include "../notifications.h" #include "../schema/ui_schema.h" #include "../material/draw_helpers.h" @@ -16,10 +17,7 @@ #include "imgui.h" #include -#include -#include #include -#include namespace dragonx { namespace ui { @@ -29,38 +27,6 @@ static bool s_open = false; 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_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() { @@ -71,7 +37,14 @@ void ExportTransactionsDialog::show() // Generate default filename with timestamp std::time_t now = std::time(nullptr); 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); } @@ -129,7 +102,7 @@ void ExportTransactionsDialog::render(App* app) ImGui::Spacing(); 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 (doExport) { s_saved_path.clear(); @@ -137,35 +110,18 @@ void ExportTransactionsDialog::render(App* app) if (state.transactions.empty()) { Notifications::instance().warning(TR("export_tx_none")); } else { - s_exporting = true; std::string configDir = util::Platform::getConfigDir(); std::string filepath = configDir + "/" + s_filename; - - std::ofstream file(filepath); - if (!file.is_open()) { + // Build in memory, then write atomically (temp + fsync + rename): a disk-full / + // removable-media failure is reported instead of leaving a truncated CSV behind a success toast. + const std::string csv = data::formatTransactionsCsv(state.transactions); + if (!util::Platform::writeFileAtomically(filepath, csv)) { s_error = TR("export_tx_file_fail"); Notifications::instance().error(TR("export_tx_file_fail")); } else { - file << "Date,Type,Amount,Address,TXID,Confirmations,Memo\n"; - for (const auto& tx : state.transactions) { - std::time_t t = static_cast(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; Notifications::instance().success(TR("export_tx_success"), 5.0f); } - s_exporting = false; } } diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index d5d11d6..12b8d9c 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -14,6 +14,7 @@ #endif #include "data/exchange_info.h" #include "util/text_format.h" +#include "data/transaction_csv.h" #include "chat/chat_crypto.h" #include "chat/chat_identity.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); } +void testTransactionCsv() { + std::vector 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 int main() @@ -18438,6 +18453,7 @@ int main() testWriteFileIfDifferent(); testCaptureExtent(); testResolveExchangeSelection(); + testTransactionCsv(); if (g_failures != 0) { std::cerr << g_failures << " test assertion(s) failed\n";