feat(export): sanitize CSV filename + Open-folder button

The transactions CSV export now sanitizes the typed filename (basename only,
illegal chars → '_', no leading dots/trailing dots-or-spaces, length-capped,
always .csv) so it can't be pointed outside the config dir, and the success
screen gets an "Open folder" button. (All-vs-filter / date-range export scope is
left for a follow-up — it needs tx-tab filter-snapshot plumbing.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 23:51:07 -05:00
co-authored by Claude Opus 4.8
parent 794150b11b
commit 8961cbdfc9
+34 -1
View File
@@ -18,6 +18,8 @@
#include <string>
#include <ctime>
#include <algorithm>
#include <cctype>
namespace dragonx {
namespace ui {
@@ -28,6 +30,31 @@ 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
// Make a user-typed filename safe to write into the config dir: basename only, illegal chars → '_',
// no leading dots / trailing dots-or-spaces, length-capped, always ending in .csv.
static std::string sanitizeExportFilename(std::string name) {
const size_t slash = name.find_last_of("/\\");
if (slash != std::string::npos) name = name.substr(slash + 1);
auto notspace = [](unsigned char c) { return !std::isspace(c); };
name.erase(name.begin(), std::find_if(name.begin(), name.end(), notspace));
name.erase(std::find_if(name.rbegin(), name.rend(), notspace).base(), name.end());
for (char& c : name) {
const unsigned char u = static_cast<unsigned char>(c);
if (u < 0x20 || c == '<' || c == '>' || c == ':' || c == '"' || c == '|' || c == '?' || c == '*')
c = '_';
}
const size_t firstGood = name.find_first_not_of('.');
if (firstGood == std::string::npos) name.clear(); else name.erase(0, firstGood);
while (!name.empty() && (name.back() == '.' || name.back() == ' ')) name.pop_back();
if (name.size() > 120) name.resize(120);
std::string lower = name;
std::transform(lower.begin(), lower.end(), lower.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
if (lower.size() < 4 || lower.compare(lower.size() - 4, 4, ".csv") != 0) name += ".csv";
if (name.empty() || name == ".csv") name = "dragonx_transactions.csv";
return name;
}
void ExportTransactionsDialog::show()
{
s_open = true;
@@ -94,6 +121,8 @@ void ExportTransactionsDialog::render(App* app)
ImGui::SameLine(0, 4.0f * Layout::dpiScale());
ImGui::TextColored(m::SuccessVec4(), "%s", TR("export_tx_success"));
widgets::AddressCopyField("##exportedpath", s_saved_path);
if (m::TactileButton(TR("export_tx_open_folder"), ImVec2(0, 0)))
util::Platform::openFolder(util::Platform::getConfigDir());
} else if (!s_error.empty()) {
ImGui::Spacing();
ImGui::PushTextWrapPos(0.0f);
@@ -113,7 +142,11 @@ void ExportTransactionsDialog::render(App* app)
Notifications::instance().warning(TR("export_tx_none"));
} else {
std::string configDir = util::Platform::getConfigDir();
std::string filepath = configDir + "/" + s_filename;
// Sanitize in-buffer so the user sees exactly what was written (and we can't be pointed outside
// the config dir by a pasted path).
std::string safeName = sanitizeExportFilename(s_filename);
snprintf(s_filename, sizeof(s_filename), "%s", safeName.c_str());
std::string filepath = configDir + "/" + safeName;
// 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);