Skip to content

Commit 68692cf

Browse files
authored
fix(StringTools): edge cases in split_up, remove_quotes, append_codepoint, escape_detect (#1364)
🤖 _AI text below_ 🤖 Part of #1357 Fixes four verified string-handling edge cases in `include/CLI/impl/StringTools_inl.hpp`, all confirmed against the current code. ### 1. `split_up` dropped the character after a closing quote/bracket When a token started with a bracket/quote, the code did `output.push_back(str.substr(0, end + 1))` then unconditionally `str = str.substr(end + 2)`, assuming `str[end + 1]` was a delimiter. For e.g. `split_up("\"abc\"def")` the `d` vanished. Now we only skip position `end + 1` when it is an actual delimiter (the local `find_ws` predicate); otherwise we resume from `end + 1`, so the remainder starts the next token and no characters are lost. New tests `SplitUp: TrailingAfterQuote`, `TrailingAfterBracket`, and `DelimiterAfterQuote` in `tests/HelpersTest.cpp`. ### 2. `remove_quotes(std::vector<std::string>&)` called `front()`/`back()` on empty strings (UB) The App parse path erases empty strings first, but `generate_parents` (`Config_inl.hpp`) does not: `split_up(section, '.')` on a section like `[a..b]` yields empty segments that reached `arg.front()` on `""`. Fixed by skipping empty strings in the loop. New test `StringBased: EmptySectionSegment` in `tests/ConfigFileTest.cpp` parses `[a..b]` and asserts it does not crash and still finds the value. ### 3. `append_codepoint` silently emitted nothing for code points above 0x10FFFF A `\U` escape computing a code >= 0x110000 fell off the end of the if/else ladder and disappeared, unlike the surrogate case which throws. Added a final `else` that throws `std::invalid_argument`. Also corrected the surrogate error message (was "are not valid UTF-8" -> "are not valid code points", since these are invalid code points, not bad UTF-8). New assertions in `StringTools: unicode_literals` cover `\U00110000`, `\UFFFFFFFF`, and the largest valid point `\U0010FFFF`. ### 4. `escape_detect` unsigned wraparound at offset 0 When the trigger char (`=`/`:`) is the first character, `str.find_last_of("-/ \"'\`", offset - 1)` passed `npos` and searched the whole string (including characters after the trigger), potentially matching a later `-`/`/` and wrongly rewriting the leading char. Now returns `offset + 1` early when `offset == 0`. New test `StringTools: escape_detect`. ### Test change (old lossy behavior) `AppTest.cpp` `OneStringEqualVersionSingleStringQuotedEscapedCharacters` had a stray trailing `"` after a closing backtick-literal: `` -m=`"quoted\n string"`" ``. That unbalanced quote was previously silently swallowed by the `split_up` bug (item 1). With the fix it is preserved as a token and rejected as an unexpected argument. The trailing `"` is malformed input, so I removed it from the test input; the meaningful assertions are unchanged. All tests pass: full `ctest` is 24/24 green (HelpersTest, ConfigFileTest, StringParseTest, AppTest included). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 5c0ba6a commit 68692cf

4 files changed

Lines changed: 95 additions & 4 deletions

File tree

include/CLI/impl/StringTools_inl.hpp

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@ CLI11_INLINE void append_codepoint(std::string &str, std::uint32_t code) {
254254
str.push_back(make_char(0x80 | (code & 0x3F)));
255255
} else if(code < 0x10000) { // U+0800...U+FFFF
256256
if(0xD800 <= code && code <= 0xDFFF) {
257-
throw std::invalid_argument("[0xD800, 0xDFFF] are not valid UTF-8.");
257+
throw std::invalid_argument("[0xD800, 0xDFFF] are not valid code points.");
258258
}
259259
// 1110yyyy 10yxxxxx 10xxxxxx
260260
str.push_back(make_char(0xE0 | code >> 12));
@@ -266,6 +266,8 @@ CLI11_INLINE void append_codepoint(std::string &str, std::uint32_t code) {
266266
str.push_back(make_char(0x80 | (code >> 12 & 0x3F)));
267267
str.push_back(make_char(0x80 | (code >> 6 & 0x3F)));
268268
str.push_back(make_char(0x80 | (code & 0x3F)));
269+
} else { // code points above U+10FFFF are not valid
270+
throw std::invalid_argument("values above 0x10FFFF are not valid code points.");
269271
}
270272
}
271273

@@ -416,8 +418,17 @@ CLI11_INLINE std::vector<std::string> split_up(std::string str, char delimiter)
416418
str.clear();
417419
} else {
418420
output.push_back(str.substr(0, end + 1));
419-
if(end + 2 < str.size()) {
420-
str = str.substr(end + 2);
421+
// The character following a closing quote/bracket is normally a delimiter and is
422+
// consumed. If it is an ordinary character it must be retained (resume from it) so
423+
// no characters are silently lost (e.g. `"abc"def` -> {"abc", "def"}). However if it
424+
// is itself a quote/bracket opening character, resuming from it would start a fresh
425+
// (potentially unterminated) quoted sequence that could swallow later delimiters, so
426+
// it is skipped like the original delimiter case to keep splitting well behaved.
427+
char follow = str[end + 1];
428+
bool skip_follow = find_ws(follow) || (bracketChars().find_first_of(follow) != std::string::npos);
429+
auto next = skip_follow ? end + 2 : end + 1;
430+
if(next < str.size()) {
431+
str = str.substr(next);
421432
} else {
422433
str.clear();
423434
}
@@ -442,6 +453,10 @@ CLI11_INLINE std::vector<std::string> split_up(std::string str, char delimiter)
442453
CLI11_INLINE std::size_t escape_detect(std::string &str, std::size_t offset) {
443454
auto next = str[offset + 1];
444455
if((next == '\"') || (next == '\'') || (next == '`')) {
456+
if(offset == 0) {
457+
// nothing precedes the trigger character, so there is nothing to reinterpret
458+
return offset + 1;
459+
}
445460
auto astart = str.find_last_of("-/ \"\'`", offset - 1);
446461
if(astart != std::string::npos) {
447462
if(str[astart] == ((str[offset] == '=') ? '-' : '/'))
@@ -542,6 +557,9 @@ CLI11_INLINE std::string extract_binary_string(const std::string &escaped_string
542557

543558
CLI11_INLINE void remove_quotes(std::vector<std::string> &args) {
544559
for(auto &arg : args) {
560+
if(arg.empty()) {
561+
continue;
562+
}
545563
if(arg.front() == '\"' && arg.back() == '\"') {
546564
remove_quotes(arg);
547565
// only remove escaped for string arguments not literal strings

tests/AppTest.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -432,7 +432,7 @@ TEST_CASE_METHOD(TApp, "OneStringEqualVersionSingleStringQuotedEscapedCharacters
432432
app.add_option("-s,--string", str);
433433
app.add_option("-t,--tstr", str2);
434434
app.add_option("-m,--mstr", str3);
435-
app.parse(R"raw(--string="this is my \n\"quoted\" string" -t 'qst\ring 2' -m=`"quoted\n string"`")raw");
435+
app.parse(R"raw(--string="this is my \n\"quoted\" string" -t 'qst\ring 2' -m=`"quoted\n string"`)raw");
436436
CHECK("this is my \n\"quoted\" string" == str); // escaped
437437
CHECK("qst\\ring 2" == str2); // literal
438438
CHECK("\"quoted\\n string\"" == str3); // double quoted literal

tests/ConfigFileTest.cpp

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,31 @@ TEST_CASE("StringBased: First", "[config]") {
9393
CHECK(output.at(1).inputs.at(0) == "four");
9494
}
9595

96+
TEST_CASE("StringBased: EmptySectionSegment", "[config]") {
97+
// a section with consecutive separators (e.g. [a..b]) yields empty parent segments;
98+
// this must not crash when removing quotes from the parents
99+
std::stringstream ofile;
100+
101+
ofile << "[a..b]\n";
102+
ofile << "val=3\n";
103+
104+
ofile.seekg(0, std::ios::beg);
105+
106+
std::vector<CLI::ConfigItem> output;
107+
CHECK_NOTHROW(output = CLI::ConfigINI().from_config(ofile));
108+
REQUIRE_FALSE(output.empty());
109+
// the value should still be present under the (cleaned-up) parent chain
110+
bool found = false;
111+
for(const auto &item : output) {
112+
if(item.name == "val") {
113+
found = true;
114+
REQUIRE(item.inputs.size() == 1u);
115+
CHECK(item.inputs.at(0) == "3");
116+
}
117+
}
118+
CHECK(found);
119+
}
120+
96121
TEST_CASE("StringBased: FirstWithComments", "[config]") {
97122
std::stringstream ofile;
98123

tests/HelpersTest.cpp

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,25 @@ TEST_CASE("StringTools: Modify3", "[helpers]") {
216216
CHECK("aba" == newString);
217217
}
218218

219+
TEST_CASE("StringTools: escape_detect", "[helpers]") {
220+
// a trigger char preceded by a '-' (for '=') is reinterpreted as a space so split_up works
221+
std::string opt{"--opt=\"value\""};
222+
CLI::detail::escape_detect(opt, 5);
223+
CHECK(opt == "--opt \"value\"");
224+
225+
// a leading trigger character has nothing preceding it; it must not be rewritten
226+
// (and must not read out of bounds at offset 0)
227+
std::string leading{"=\"value\""};
228+
auto ret = CLI::detail::escape_detect(leading, 0);
229+
CHECK(ret == 1U);
230+
CHECK(leading == "=\"value\"");
231+
232+
// make sure a later '-' does not get wrongly matched for a leading trigger
233+
std::string leading2{"=\"-value\""};
234+
CLI::detail::escape_detect(leading2, 0);
235+
CHECK(leading2 == "=\"-value\"");
236+
}
237+
219238
TEST_CASE("StringTools: flagValues", "[helpers]") {
220239
errno = 0;
221240
CHECK(-1 == CLI::detail::to_flag_value("0"));
@@ -477,6 +496,11 @@ TEST_CASE("StringTools: unicode_literals", "[helpers]") {
477496

478497
CHECK_THROWS_AS(CLI::detail::remove_escaped_characters("test\\U0001E600\\uD8"), std::invalid_argument);
479498
CHECK_THROWS_AS(CLI::detail::remove_escaped_characters("test\\U0001E60"), std::invalid_argument);
499+
// code points above U+10FFFF are not valid and must throw rather than silently vanish
500+
CHECK_THROWS_AS(CLI::detail::remove_escaped_characters("test\\U00110000"), std::invalid_argument);
501+
CHECK_THROWS_AS(CLI::detail::remove_escaped_characters("test\\UFFFFFFFF"), std::invalid_argument);
502+
// the largest valid code point still works
503+
CHECK(CLI::detail::remove_escaped_characters("test\\U0010FFFF") == from_u8string(u8"test\U0010FFFF"));
480504
}
481505

482506
TEST_CASE("StringTools: close_sequence", "[helpers]") {
@@ -1215,6 +1239,30 @@ TEST_CASE("SplitUp: BadStrings", "[helpers]") {
12151239
CHECK(result == oput);
12161240
}
12171241

1242+
TEST_CASE("SplitUp: TrailingAfterQuote", "[helpers]") {
1243+
// a non-delimiter character following a closing quote must not be dropped
1244+
std::vector<std::string> oput = {"\"abc\"", "def"};
1245+
std::string orig{"\"abc\"def"};
1246+
std::vector<std::string> result = CLI::detail::split_up(orig);
1247+
CHECK(result == oput);
1248+
}
1249+
1250+
TEST_CASE("SplitUp: TrailingAfterBracket", "[helpers]") {
1251+
// a non-delimiter character following a closing bracket must not be dropped
1252+
std::vector<std::string> oput = {"[a, b]", "c"};
1253+
std::string orig{"[a, b]c"};
1254+
std::vector<std::string> result = CLI::detail::split_up(orig, ',');
1255+
CHECK(result == oput);
1256+
}
1257+
1258+
TEST_CASE("SplitUp: DelimiterAfterQuote", "[helpers]") {
1259+
// an actual delimiter following a closing quote is still consumed
1260+
std::vector<std::string> oput = {"\"abc\"", "def"};
1261+
std::string orig{"\"abc\",def"};
1262+
std::vector<std::string> result = CLI::detail::split_up(orig, ',');
1263+
CHECK(result == oput);
1264+
}
1265+
12181266
TEST_CASE("Types: TypeName", "[helpers]") {
12191267
std::string int_name = CLI::detail::type_name<int>();
12201268
CHECK(int_name == "INT");

0 commit comments

Comments
 (0)