From 48eceb659303c879d07cd8cabfbd72881645452e Mon Sep 17 00:00:00 2001 From: DanS Date: Sun, 12 Jul 2026 17:49:02 -0500 Subject: [PATCH] fix(console): don't coerce quoted or malformed numeric args to JSON numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BuildConsoleRpcCall pushed any bare token that "looked numeric" as a JSON number, and lost the tokenizer's quoting decision — so a genuinely-string argument that happened to be all digits (e.g. a label) was sent with the wrong type. std::stoll also silently truncated "1e999" to 1 (stops at the first non-digit) and std::stod could yield inf/subnormal. - ParseConsoleCommandArgsTagged returns {text, quoted}; quoted tokens are sent verbatim as JSON strings, never coerced. (ParseConsoleCommandArgs kept as a text-only wrapper so existing callers/tests are unchanged.) - Bare tokens become a number only when the WHOLE token parses to a finite int/double; otherwise they're sent as a string. Adds test coverage for quoted-numeric-string, bare-number, 1e999, and 123abc. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ui/windows/console_input_model.cpp | 71 +++++++++++++++++++------- src/ui/windows/console_input_model.h | 7 +++ tests/test_phase4.cpp | 17 ++++++ 3 files changed, 76 insertions(+), 19 deletions(-) diff --git a/src/ui/windows/console_input_model.cpp b/src/ui/windows/console_input_model.cpp index 983c596..a705310 100644 --- a/src/ui/windows/console_input_model.cpp +++ b/src/ui/windows/console_input_model.cpp @@ -2,6 +2,7 @@ #include #include +#include #include namespace dragonx { @@ -116,9 +117,9 @@ std::vector FormatConsoleCompletionLines(const std::vector ParseConsoleCommandArgs(const std::string& command) +std::vector ParseConsoleCommandArgsTagged(const std::string& command) { - std::vector args; + std::vector args; std::size_t index = 0; while (index < command.size()) { while (index < command.size() && (command[index] == ' ' || command[index] == '\t')) { @@ -126,11 +127,12 @@ std::vector ParseConsoleCommandArgs(const std::string& command) } if (index >= command.size()) break; - std::string token; + ConsoleArg arg; if (command[index] == '"' || command[index] == '\'') { + arg.quoted = true; char quote = command[index++]; while (index < command.size() && command[index] != quote) { - token += command[index++]; + arg.text += command[index++]; } if (index < command.size()) ++index; } else if (command[index] == '[' || command[index] == '{') { @@ -140,30 +142,69 @@ std::vector ParseConsoleCommandArgs(const std::string& command) while (index < command.size()) { if (command[index] == open) ++depth; else if (command[index] == close) --depth; - token += command[index++]; + arg.text += command[index++]; if (depth == 0) break; } } else { while (index < command.size() && command[index] != ' ' && command[index] != '\t') { - token += command[index++]; + arg.text += command[index++]; } } - if (!token.empty()) args.push_back(token); + // A quoted empty string ("") is a real argument; only skip genuinely-empty unquoted tokens. + if (!arg.text.empty() || arg.quoted) args.push_back(std::move(arg)); } return args; } +std::vector ParseConsoleCommandArgs(const std::string& command) +{ + std::vector args; + for (auto& a : ParseConsoleCommandArgsTagged(command)) args.push_back(std::move(a.text)); + return args; +} + +namespace { +// Interpret a bare token as a JSON number only when the WHOLE token is a valid, finite number; +// otherwise return it as a string. Guards against std::stoll silently truncating "1e9"/"123abc" +// (it stops at the first non-digit) and std::stod yielding inf/subnormal for out-of-range input. +nlohmann::json ParseConsoleNumberOrString(const std::string& s) +{ + if (!s.empty()) { + try { + std::size_t pos = 0; + long long ll = std::stoll(s, &pos); + if (pos == s.size()) return ll; + } catch (...) {} + try { + std::size_t pos = 0; + double d = std::stod(s, &pos); + if (pos == s.size() && std::isfinite(d)) return d; + } catch (...) {} + } + return s; +} +} // namespace + ConsoleRpcCall BuildConsoleRpcCall(const std::string& command) { - auto args = ParseConsoleCommandArgs(command); + auto args = ParseConsoleCommandArgsTagged(command); ConsoleRpcCall call; if (args.empty()) return call; call.valid = true; - call.method = args.front(); + call.method = args.front().text; for (std::size_t argIndex = 1; argIndex < args.size(); ++argIndex) { - const std::string& arg = args[argIndex]; + const ConsoleArg& a = args[argIndex]; + const std::string& arg = a.text; + + // A quoted token is always a string — never coerced — so a genuinely-string argument that + // happens to look numeric (e.g. an all-digit label) is sent as a string, not a number. + if (a.quoted) { + call.params.push_back(arg); + continue; + } + if (!arg.empty() && (arg[0] == '{' || arg[0] == '[')) { auto parsed = nlohmann::json::parse(arg, nullptr, false); if (!parsed.is_discarded()) { @@ -181,15 +222,7 @@ ConsoleRpcCall BuildConsoleRpcCall(const std::string& command) } else if (arg == "false") { call.params.push_back(false); } else { - try { - if (arg.find('.') != std::string::npos) { - call.params.push_back(std::stod(arg)); - } else { - call.params.push_back(std::stoll(arg)); - } - } catch (...) { - call.params.push_back(arg); - } + call.params.push_back(ParseConsoleNumberOrString(arg)); } } diff --git a/src/ui/windows/console_input_model.h b/src/ui/windows/console_input_model.h index 4b606a2..969f67c 100644 --- a/src/ui/windows/console_input_model.h +++ b/src/ui/windows/console_input_model.h @@ -46,6 +46,13 @@ std::string ConsoleHistoryEntry(const std::vector& history, ConsoleCompletionResult CompleteConsoleCommand(const std::string& input); std::vector FormatConsoleCompletionLines(const std::vector& matches, std::size_t maxLineLength = 60); +// A tokenized console argument. `quoted` is true when the token came from a "..."/'...' literal, +// so BuildConsoleRpcCall must send it verbatim as a JSON string (never coerce it to a number/bool). +struct ConsoleArg { + std::string text; + bool quoted = false; +}; +std::vector ParseConsoleCommandArgsTagged(const std::string& command); std::vector ParseConsoleCommandArgs(const std::string& command); ConsoleRpcCall BuildConsoleRpcCall(const std::string& command); std::vector FormatConsoleRpcResultLines(const std::string& result, diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 3ddef9b..b42b904 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -3200,6 +3200,23 @@ void testConsoleInputModel() EXPECT_TRUE(call.params[0].get()); EXPECT_EQ(call.params[1].get(), 4LL); + // Quoted numeric-looking args must stay strings (not coerced to numbers). + auto qcall = dragonx::ui::BuildConsoleRpcCall("setaccount \"123\" \"456.7\""); + EXPECT_TRUE(qcall.valid); + EXPECT_EQ(qcall.params.size(), static_cast(2)); + EXPECT_TRUE(qcall.params[0].is_string()); + EXPECT_EQ(qcall.params[0].get(), std::string("123")); + EXPECT_TRUE(qcall.params[1].is_string()); + // Bare finite numbers become numbers; non-finite / non-whole-token tokens fall back to strings + // (no std::stoll truncation of "1e999" to 1, no inf). + auto ncall = dragonx::ui::BuildConsoleRpcCall("foo 42 3.5 1e999 123abc"); + EXPECT_TRUE(ncall.params[0].is_number_integer()); + EXPECT_EQ(ncall.params[0].get(), 42LL); + EXPECT_TRUE(ncall.params[1].is_number_float()); + EXPECT_TRUE(ncall.params[2].is_string()); + EXPECT_EQ(ncall.params[2].get(), std::string("1e999")); + EXPECT_TRUE(ncall.params[3].is_string()); + auto resultLines = dragonx::ui::FormatConsoleRpcResultLines("{\n \"balance\": 12,\n \"ok\": true\n}", false); EXPECT_EQ(resultLines.size(), static_cast(4)); EXPECT_EQ(resultLines[0].role, dragonx::ui::ConsoleResultLineRole::JsonBrace);