fix(settings): tolerate wrongly-typed values instead of quarantining

Several settings were read with json's value()/get<T>() helpers that
assume the stored type. A hand-edited settings file, or one re-saved
after an inf/NaN float was written back as JSON null, could therefore
hold a value of the wrong type and throw during load. The whole load is
wrapped so a throw quarantines the entire settings file to a
.corrupt- copy, meaning one bad field reset every unrelated setting.

- ui_opacity is accepted only when it is actually a number, and it is
  clamped to the same 0.3..1.0 range the setter enforces; the legacy
  reduced_transparency migration likewise runs only when that key is a
  boolean.
- A non-string name in a blocked chat conversation, and a non-string
  scope in a portfolio entry, now fall back to an empty string instead
  of throwing.

The file loads with the offending field defaulted, and the rest of the
settings survive.

Tests cover ui_opacity as a string/null/int/float/bool, a non-string
blocked-conversation name and portfolio scope, and the legacy
reduced_transparency migration, asserting no quarantine file is written.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 22:02:59 -05:00
co-authored by Claude Opus 4.8
parent f47528de53
commit 2396ed33db
2 changed files with 51 additions and 6 deletions
+9 -6
View File
@@ -177,7 +177,8 @@ bool Settings::load(const std::string& path)
for (const auto& c : j["blocked_chat_convs"]) for (const auto& c : j["blocked_chat_convs"])
if (c.is_object() && c.contains("cid") && c["cid"].is_string()) if (c.is_object() && c.contains("cid") && c["cid"].is_string())
blocked_chat_convs_.push_back({c["cid"].get<std::string>(), blocked_chat_convs_.push_back({c["cid"].get<std::string>(),
c.value("name", std::string())}); (c.contains("name") && c["name"].is_string())
? c["name"].get<std::string>() : std::string()});
} }
// Chat-tab customization (re-clamped through the setters so hand-edited JSON stays in range). // Chat-tab customization (re-clamped through the setters so hand-edited JSON stays in range).
loadScalar(j, "chat_emoji_color", chat_emoji_color_); loadScalar(j, "chat_emoji_color", chat_emoji_color_);
@@ -194,10 +195,12 @@ bool Settings::load(const std::string& path)
loadScalar(j, "blur_multiplier", blur_multiplier_); loadScalar(j, "blur_multiplier", blur_multiplier_);
loadScalar(j, "noise_opacity", noise_opacity_); loadScalar(j, "noise_opacity", noise_opacity_);
loadScalar(j, "gradient_background", gradient_background_); loadScalar(j, "gradient_background", gradient_background_);
// Migrate legacy reduced_transparency bool -> ui_opacity float // Migrate legacy reduced_transparency bool -> ui_opacity float (type-tolerant: a hand-edited or
if (j.contains("ui_opacity")) { // inf->null re-saved value must not quarantine the whole settings file).
ui_opacity_ = j["ui_opacity"].get<float>(); if (j.contains("ui_opacity") && j["ui_opacity"].is_number()) {
} else if (j.contains("reduced_transparency") && j["reduced_transparency"].get<bool>()) { ui_opacity_ = std::clamp(j["ui_opacity"].get<float>(), 0.3f, 1.0f); // matches setUIOpacity()
} else if (j.contains("reduced_transparency") && j["reduced_transparency"].is_boolean()
&& j["reduced_transparency"].get<bool>()) {
ui_opacity_ = 1.0f; // legacy: reduced = fully opaque ui_opacity_ = 1.0f; // legacy: reduced = fully opaque
} }
loadScalar(j, "window_opacity", window_opacity_); loadScalar(j, "window_opacity", window_opacity_);
@@ -421,7 +424,7 @@ bool Settings::load(const std::string& path)
entry.showSparkline = e["show_sparkline"].get<bool>(); entry.showSparkline = e["show_sparkline"].get<bool>();
if (e.contains("sparkline_interval") && e["sparkline_interval"].is_number_integer()) if (e.contains("sparkline_interval") && e["sparkline_interval"].is_number_integer())
entry.sparklineInterval = e["sparkline_interval"].get<int>(); entry.sparklineInterval = e["sparkline_interval"].get<int>();
entry.scope = e.value("scope", ""); if (e.contains("scope") && e["scope"].is_string()) entry.scope = e["scope"].get<std::string>();
if (e.contains("grid_col") && e["grid_col"].is_number_integer()) if (e.contains("grid_col") && e["grid_col"].is_number_integer())
entry.gridCol = e["grid_col"].get<int>(); entry.gridCol = e["grid_col"].get<int>();
if (e.contains("grid_row") && e["grid_row"].is_number_integer()) if (e.contains("grid_row") && e["grid_row"].is_number_integer())
+42
View File
@@ -7918,6 +7918,47 @@ void testPortfolioScopeVisibility()
std::error_code ec; fs::remove_all(dir, ec); std::error_code ec; fs::remove_all(dir, ec);
} }
// Settings::load must tolerate wrongly-typed values without quarantining the whole file.
void testSettingsLoadTypeTolerance()
{
namespace fs = std::filesystem;
const fs::path dir = fs::temp_directory_path() / "dragonx_settings_type_tolerance_test";
std::error_code ec; fs::remove_all(dir, ec); fs::create_directories(dir, ec);
const std::string p = (dir / "settings.json").string();
auto writeAndLoad = [&](const std::string& json, dragonx::config::Settings& s) {
{ std::ofstream o(p, std::ios::binary | std::ios::trunc); o << json; }
return s.load(p);
};
auto noQuarantine = [&]() {
for (const auto& de : fs::directory_iterator(dir, ec))
if (de.path().filename().string().find(".corrupt-") != std::string::npos) return false;
return true;
};
for (const char* bad : {"\"x\"", "null", "5", "0.01", "true"}) {
dragonx::config::Settings s;
const std::string js = std::string("{\"ui_opacity\":") + bad + ",\"chat_density\":1}";
EXPECT_TRUE(writeAndLoad(js, s));
EXPECT_TRUE(noQuarantine());
EXPECT_TRUE(s.getUIOpacity() >= 0.3f && s.getUIOpacity() <= 1.0f);
}
{
dragonx::config::Settings s;
EXPECT_TRUE(writeAndLoad(
"{\"blocked_chat_convs\":[{\"cid\":\"abc\",\"name\":7}],"
"\"portfolio_entries\":[{\"label\":\"L\",\"scope\":7}]}", s));
EXPECT_TRUE(noQuarantine());
EXPECT_EQ(s.blockedChatConversations().size(), static_cast<size_t>(1));
EXPECT_EQ(s.getPortfolioEntries().size(), static_cast<size_t>(1));
EXPECT_EQ(s.getPortfolioEntries()[0].scope, std::string());
}
{
dragonx::config::Settings s;
EXPECT_TRUE(writeAndLoad("{\"reduced_transparency\":true}", s));
EXPECT_NEAR(s.getUIOpacity(), 1.0f, 1e-6f);
}
fs::remove_all(dir, ec);
}
// Market price-series math (data/market_series.h) — minute-resampling + time-window bucketing // Market price-series math (data/market_series.h) — minute-resampling + time-window bucketing
// that feed the price chart and the portfolio-group sparklines. // that feed the price chart and the portfolio-group sparklines.
void testMarketSeries() void testMarketSeries()
@@ -18669,6 +18710,7 @@ int main()
testHushChatTransport(); testHushChatTransport();
testHushChatShuffledReceive(); testHushChatShuffledReceive();
testWalletFileProbe(); testWalletFileProbe();
testSettingsLoadTypeTolerance();
testAddressChecksumValidation(); testAddressChecksumValidation();
testPrivateKeyImportRecognition(); testPrivateKeyImportRecognition();
testSeedPhraseHelpers(); testSeedPhraseHelpers();