fix(console): don't coerce quoted or malformed numeric args to JSON numbers
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) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
|
#include <cmath>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
|
|
||||||
namespace dragonx {
|
namespace dragonx {
|
||||||
@@ -116,9 +117,9 @@ std::vector<std::string> FormatConsoleCompletionLines(const std::vector<std::str
|
|||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::vector<std::string> ParseConsoleCommandArgs(const std::string& command)
|
std::vector<ConsoleArg> ParseConsoleCommandArgsTagged(const std::string& command)
|
||||||
{
|
{
|
||||||
std::vector<std::string> args;
|
std::vector<ConsoleArg> args;
|
||||||
std::size_t index = 0;
|
std::size_t index = 0;
|
||||||
while (index < command.size()) {
|
while (index < command.size()) {
|
||||||
while (index < command.size() && (command[index] == ' ' || command[index] == '\t')) {
|
while (index < command.size() && (command[index] == ' ' || command[index] == '\t')) {
|
||||||
@@ -126,11 +127,12 @@ std::vector<std::string> ParseConsoleCommandArgs(const std::string& command)
|
|||||||
}
|
}
|
||||||
if (index >= command.size()) break;
|
if (index >= command.size()) break;
|
||||||
|
|
||||||
std::string token;
|
ConsoleArg arg;
|
||||||
if (command[index] == '"' || command[index] == '\'') {
|
if (command[index] == '"' || command[index] == '\'') {
|
||||||
|
arg.quoted = true;
|
||||||
char quote = command[index++];
|
char quote = command[index++];
|
||||||
while (index < command.size() && command[index] != quote) {
|
while (index < command.size() && command[index] != quote) {
|
||||||
token += command[index++];
|
arg.text += command[index++];
|
||||||
}
|
}
|
||||||
if (index < command.size()) ++index;
|
if (index < command.size()) ++index;
|
||||||
} else if (command[index] == '[' || command[index] == '{') {
|
} else if (command[index] == '[' || command[index] == '{') {
|
||||||
@@ -140,30 +142,69 @@ std::vector<std::string> ParseConsoleCommandArgs(const std::string& command)
|
|||||||
while (index < command.size()) {
|
while (index < command.size()) {
|
||||||
if (command[index] == open) ++depth;
|
if (command[index] == open) ++depth;
|
||||||
else if (command[index] == close) --depth;
|
else if (command[index] == close) --depth;
|
||||||
token += command[index++];
|
arg.text += command[index++];
|
||||||
if (depth == 0) break;
|
if (depth == 0) break;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
while (index < command.size() && command[index] != ' ' && command[index] != '\t') {
|
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;
|
return args;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::vector<std::string> ParseConsoleCommandArgs(const std::string& command)
|
||||||
|
{
|
||||||
|
std::vector<std::string> 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)
|
ConsoleRpcCall BuildConsoleRpcCall(const std::string& command)
|
||||||
{
|
{
|
||||||
auto args = ParseConsoleCommandArgs(command);
|
auto args = ParseConsoleCommandArgsTagged(command);
|
||||||
ConsoleRpcCall call;
|
ConsoleRpcCall call;
|
||||||
if (args.empty()) return call;
|
if (args.empty()) return call;
|
||||||
|
|
||||||
call.valid = true;
|
call.valid = true;
|
||||||
call.method = args.front();
|
call.method = args.front().text;
|
||||||
|
|
||||||
for (std::size_t argIndex = 1; argIndex < args.size(); ++argIndex) {
|
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] == '[')) {
|
if (!arg.empty() && (arg[0] == '{' || arg[0] == '[')) {
|
||||||
auto parsed = nlohmann::json::parse(arg, nullptr, false);
|
auto parsed = nlohmann::json::parse(arg, nullptr, false);
|
||||||
if (!parsed.is_discarded()) {
|
if (!parsed.is_discarded()) {
|
||||||
@@ -181,15 +222,7 @@ ConsoleRpcCall BuildConsoleRpcCall(const std::string& command)
|
|||||||
} else if (arg == "false") {
|
} else if (arg == "false") {
|
||||||
call.params.push_back(false);
|
call.params.push_back(false);
|
||||||
} else {
|
} else {
|
||||||
try {
|
call.params.push_back(ParseConsoleNumberOrString(arg));
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,13 @@ std::string ConsoleHistoryEntry(const std::vector<std::string>& history,
|
|||||||
ConsoleCompletionResult CompleteConsoleCommand(const std::string& input);
|
ConsoleCompletionResult CompleteConsoleCommand(const std::string& input);
|
||||||
std::vector<std::string> FormatConsoleCompletionLines(const std::vector<std::string>& matches,
|
std::vector<std::string> FormatConsoleCompletionLines(const std::vector<std::string>& matches,
|
||||||
std::size_t maxLineLength = 60);
|
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<ConsoleArg> ParseConsoleCommandArgsTagged(const std::string& command);
|
||||||
std::vector<std::string> ParseConsoleCommandArgs(const std::string& command);
|
std::vector<std::string> ParseConsoleCommandArgs(const std::string& command);
|
||||||
ConsoleRpcCall BuildConsoleRpcCall(const std::string& command);
|
ConsoleRpcCall BuildConsoleRpcCall(const std::string& command);
|
||||||
std::vector<ConsoleResultLine> FormatConsoleRpcResultLines(const std::string& result,
|
std::vector<ConsoleResultLine> FormatConsoleRpcResultLines(const std::string& result,
|
||||||
|
|||||||
@@ -3200,6 +3200,23 @@ void testConsoleInputModel()
|
|||||||
EXPECT_TRUE(call.params[0].get<bool>());
|
EXPECT_TRUE(call.params[0].get<bool>());
|
||||||
EXPECT_EQ(call.params[1].get<long long>(), 4LL);
|
EXPECT_EQ(call.params[1].get<long long>(), 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<size_t>(2));
|
||||||
|
EXPECT_TRUE(qcall.params[0].is_string());
|
||||||
|
EXPECT_EQ(qcall.params[0].get<std::string>(), 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<long long>(), 42LL);
|
||||||
|
EXPECT_TRUE(ncall.params[1].is_number_float());
|
||||||
|
EXPECT_TRUE(ncall.params[2].is_string());
|
||||||
|
EXPECT_EQ(ncall.params[2].get<std::string>(), std::string("1e999"));
|
||||||
|
EXPECT_TRUE(ncall.params[3].is_string());
|
||||||
|
|
||||||
auto resultLines = dragonx::ui::FormatConsoleRpcResultLines("{\n \"balance\": 12,\n \"ok\": true\n}", false);
|
auto resultLines = dragonx::ui::FormatConsoleRpcResultLines("{\n \"balance\": 12,\n \"ok\": true\n}", false);
|
||||||
EXPECT_EQ(resultLines.size(), static_cast<size_t>(4));
|
EXPECT_EQ(resultLines.size(), static_cast<size_t>(4));
|
||||||
EXPECT_EQ(resultLines[0].role, dragonx::ui::ConsoleResultLineRole::JsonBrace);
|
EXPECT_EQ(resultLines[0].role, dragonx::ui::ConsoleResultLineRole::JsonBrace);
|
||||||
|
|||||||
Reference in New Issue
Block a user