fix(tools): implement backslash escaping for contact names

Implement backslash based escaping in the command parser and autocompletion
logic to handle contact names containing double quotes.
This stops the parser from splitting nicknames into multiple tokens, which
previously caused "Invalid usage" errors.

The parser now sees \ as an escape character for quotes and spaces in
_parse_args_helper, count_tokens and get_start. Autocomplete results are
escaped when they contain spaces, and search prefixes are unescaped before
matching. strip_arg_quotes() has also been updated to handle unescaping.

Fixes: https://github.com/profanity-im/profanity/issues/1844
Signed-off-by: Michael Vetter <jubalh@iodoru.org>
This commit is contained in:
Michael Vetter
2026-03-29 00:15:23 +01:00
parent 9b2f96fb45
commit bda5b0f844
8 changed files with 191 additions and 49 deletions

View File

@@ -543,26 +543,32 @@ _get_file_or_linked(gchar* loc)
char*
strip_arg_quotes(const char* const input)
{
char* unquoted = strdup(input);
if (unquoted == NULL) {
if (input == NULL) {
return NULL;
}
// Remove starting quote if it exists
if (strchr(unquoted, '"')) {
if (strchr(unquoted, ' ') + 1 == strchr(unquoted, '"')) {
memmove(strchr(unquoted, '"'), strchr(unquoted, '"') + 1, strchr(unquoted, '\0') - strchr(unquoted, '"'));
// Unescape and strip quotes
GString* unescaped = g_string_new("");
for (const char* p = input; *p; p++) {
if (*p == '\\' && (*(p + 1) == '"' || *(p + 1) == '\\')) {
p++;
g_string_append_c(unescaped, *p);
} else if (*p == '"') {
// Only strip if it's the first char or preceded by a space
if (p == input || *(p - 1) == ' ') {
continue;
}
// Or if it's the last char
if (*(p + 1) == '\0') {
continue;
}
g_string_append_c(unescaped, *p);
} else {
g_string_append_c(unescaped, *p);
}
}
// Remove ending quote if it exists
if (strchr(unquoted, '"')) {
if (strchr(unquoted, '\0') - 1 == strchr(unquoted, '"')) {
memmove(strchr(unquoted, '"'), strchr(unquoted, '"') + 1, strchr(unquoted, '\0') - strchr(unquoted, '"'));
}
}
return unquoted;
return g_string_free(unescaped, FALSE);
}
gboolean

View File

@@ -234,7 +234,16 @@ autocomplete_complete(Autocomplete ac, const gchar* search_str, gboolean quote,
FREE_SET_NULL(ac->search_str);
}
ac->search_str = strdup(search_str);
// unescape search string
GString* unescaped = g_string_new("");
for (const char* p = search_str; *p; p++) {
if (*p == '\\' && (*(p + 1) == '"' || *(p + 1) == '\\')) {
p++;
}
g_string_append_c(unescaped, *p);
}
ac->search_str = g_string_free(unescaped, FALSE);
found = _search(ac, ac->items, quote, NEXT);
return found;
@@ -403,7 +412,15 @@ _search(Autocomplete ac, GList* curr, gboolean quote, search_direction direction
// if contains space, quote before returning
if (quote && g_strrstr(curr->data, " ")) {
return g_strdup_printf("\"%s\"", (gchar*)curr->data);
GString* escaped = g_string_new("\"");
for (const char* p = curr->data; *p; p++) {
if (*p == '"' || *p == '\\') {
g_string_append_c(escaped, '\\');
}
g_string_append_c(escaped, *p);
}
g_string_append_c(escaped, '"');
return g_string_free(escaped, FALSE);
// otherwise just return the string
} else {
return strdup(curr->data);

View File

@@ -32,8 +32,7 @@ _parse_args_helper(const char* const inp, int min, int max, gboolean* result, gb
gboolean in_token = FALSE;
gboolean in_freetext = FALSE;
gboolean in_quotes = FALSE;
char* token_start = &copy[0];
int token_size = 0;
GString* current_token = g_string_new("");
int num_tokens = 0;
GSList* tokens = NULL;
@@ -42,6 +41,23 @@ _parse_args_helper(const char* const inp, int min, int max, gboolean* result, gb
gchar* curr_ch = g_utf8_offset_to_pointer(copy, i);
gunichar curr_uni = g_utf8_get_char(curr_ch);
if (curr_uni == '\\' && (i + 1 < inp_size)) {
if (!in_token) {
in_token = TRUE;
if (with_freetext) {
num_tokens++;
}
if (with_freetext && (num_tokens == max + 1)) {
in_freetext = TRUE;
}
}
i++;
gchar* next_ch = g_utf8_offset_to_pointer(copy, i);
gunichar next_uni = g_utf8_get_char(next_ch);
g_string_append_unichar(current_token, next_uni);
continue;
}
if (!in_token) {
if (curr_uni == ' ') {
continue;
@@ -55,58 +71,61 @@ _parse_args_helper(const char* const inp, int min, int max, gboolean* result, gb
} else if (curr_uni == '"') {
in_quotes = TRUE;
i++;
gchar* next_ch = g_utf8_next_char(curr_ch);
gunichar next_uni = g_utf8_get_char(next_ch);
if (i < inp_size) {
gchar* next_ch = g_utf8_offset_to_pointer(copy, i);
gunichar next_uni = g_utf8_get_char(next_ch);
if (next_uni == '"') {
tokens = g_slist_append(tokens, g_strndup(curr_ch,
0));
token_size = 0;
if (next_uni == '"') {
tokens = g_slist_append(tokens, g_strdup(""));
in_token = FALSE;
in_quotes = FALSE;
} else if (next_uni == '\\' && (i + 1 < inp_size)) {
i++;
gchar* escaped_ch = g_utf8_offset_to_pointer(copy, i);
gunichar escaped_uni = g_utf8_get_char(escaped_ch);
g_string_append_unichar(current_token, escaped_uni);
} else {
g_string_append_unichar(current_token, next_uni);
}
} else {
// trailing quote
tokens = g_slist_append(tokens, g_strdup(""));
in_token = FALSE;
in_quotes = FALSE;
} else {
token_start = next_ch;
token_size += g_unichar_to_utf8(next_uni, NULL);
}
}
if (curr_uni == '"') {
gchar* next_ch = g_utf8_next_char(curr_ch);
token_start = next_ch;
} else {
token_start = curr_ch;
token_size += g_unichar_to_utf8(curr_uni, NULL);
if (in_token && curr_uni != '"') {
g_string_append_unichar(current_token, curr_uni);
}
}
} else {
if (in_quotes) {
if (curr_uni == '"') {
tokens = g_slist_append(tokens, g_strndup(token_start,
token_size));
token_size = 0;
tokens = g_slist_append(tokens, g_string_free(current_token, FALSE));
current_token = g_string_new("");
in_token = FALSE;
in_quotes = FALSE;
} else {
if (curr_uni != '"') {
token_size += g_unichar_to_utf8(curr_uni, NULL);
}
g_string_append_unichar(current_token, curr_uni);
}
} else {
if (with_freetext && in_freetext) {
token_size += g_unichar_to_utf8(curr_uni, NULL);
g_string_append_unichar(current_token, curr_uni);
} else if (curr_uni == ' ') {
tokens = g_slist_append(tokens, g_strndup(token_start,
token_size));
token_size = 0;
tokens = g_slist_append(tokens, g_string_free(current_token, FALSE));
current_token = g_string_new("");
in_token = FALSE;
} else {
token_size += g_unichar_to_utf8(curr_uni, NULL);
g_string_append_unichar(current_token, curr_uni);
}
}
}
}
if (in_token) {
tokens = g_slist_append(tokens, g_strndup(token_start, token_size));
tokens = g_slist_append(tokens, g_string_free(current_token, FALSE));
} else {
g_string_free(current_token, TRUE);
}
int num = g_slist_length(tokens) - 1;
@@ -245,6 +264,11 @@ count_tokens(const char* const string)
gchar* curr_ch = g_utf8_offset_to_pointer(string, i);
gunichar curr_uni = g_utf8_get_char(curr_ch);
if (curr_uni == '\\' && (i + 1 < length)) {
i++;
continue;
}
if (curr_uni == ' ') {
if (!in_quotes) {
num_tokens++;
@@ -276,11 +300,21 @@ get_start(const char* const string, int tokens)
gchar* curr_ch = g_utf8_offset_to_pointer(string, i);
gunichar curr_uni = g_utf8_get_char(curr_ch);
if (curr_uni == '\\' && (i + 1 < length)) {
if (num_tokens < tokens) {
g_string_append_unichar(result, curr_uni);
}
i++;
curr_ch = g_utf8_offset_to_pointer(string, i);
curr_uni = g_utf8_get_char(curr_ch);
if (num_tokens < tokens) {
g_string_append_unichar(result, curr_uni);
}
continue;
}
if (num_tokens < tokens) {
auto_gchar gchar* uni_char = g_malloc(7);
int len = g_unichar_to_utf8(curr_uni, uni_char);
uni_char[len] = '\0';
g_string_append(result, uni_char);
g_string_append_unichar(result, curr_uni);
}
if (curr_uni == ' ') {
if (!in_quotes) {

View File

@@ -336,6 +336,18 @@ strip_arg_quotes__returns__stripped_both(void** state)
free(result);
}
void
strip_arg_quotes__unescapes(void** state)
{
char* input = "\"Thor \\\"The Thunderer\\\" Odinson\"";
char* result = strip_arg_quotes(input);
assert_string_equal("Thor \"The Thunderer\" Odinson", result);
free(result);
}
void
valid_tls_policy_option__is__correct_for_various_inputs(void** state)
{

View File

@@ -43,6 +43,7 @@ void strip_arg_quotes__returns__original_when_no_quotes(void** state);
void strip_arg_quotes__returns__stripped_first(void** state);
void strip_arg_quotes__returns__stripped_last(void** state);
void strip_arg_quotes__returns__stripped_both(void** state);
void strip_arg_quotes__unescapes(void** state);
void prof_occurrences__tests__partial(void** state);
void prof_occurrences__tests__whole(void** state);

View File

@@ -390,6 +390,48 @@ parse_args_with_freetext__returns__second_and_third_arg_quoted(void** state)
g_strfreev(args);
}
void
parse_args__returns__escaped_quotes(void** state)
{
char* inp = "/cmd \"Thor \\\"The Thunderer\\\" Odinson\" arg2";
gboolean result = FALSE;
gchar** args = parse_args(inp, 2, 2, &result);
assert_true(result);
assert_int_equal(2, g_strv_length(args));
assert_string_equal("Thor \"The Thunderer\" Odinson", args[0]);
assert_string_equal("arg2", args[1]);
g_strfreev(args);
}
void
parse_args__returns__escaped_spaces(void** state)
{
char* inp = "/cmd Thor\\ The\\ Thunderer\\ Odinson arg2";
gboolean result = FALSE;
gchar** args = parse_args(inp, 2, 2, &result);
assert_true(result);
assert_int_equal(2, g_strv_length(args));
assert_string_equal("Thor The Thunderer Odinson", args[0]);
assert_string_equal("arg2", args[1]);
g_strfreev(args);
}
void
parse_args__returns__escaped_backslash(void** state)
{
char* inp = "/cmd \"Thor \\\\ Odinson\" arg2";
gboolean result = FALSE;
gchar** args = parse_args(inp, 2, 2, &result);
assert_true(result);
assert_int_equal(2, g_strv_length(args));
assert_string_equal("Thor \\ Odinson", args[0]);
assert_string_equal("arg2", args[1]);
g_strfreev(args);
}
void
count_tokens__returns__one_token(void** state)
{
@@ -453,6 +495,15 @@ count_tokens__returns__two_tokens_both_quoted(void** state)
assert_int_equal(2, result);
}
void
count_tokens__handles__escapes(void** state)
{
char* inp = "one\\ two \"three \\\" four\"";
int result = count_tokens(inp);
assert_int_equal(2, result);
}
void
get_start__returns__first_of_one(void** state)
{
@@ -513,6 +564,16 @@ get_start__returns__first_two_of_three_first_and_second_quoted(void** state)
g_free(result);
}
void
get_start__handles__escapes(void** state)
{
char* inp = "one\\ two three";
char* result = get_start(inp, 2);
assert_string_equal("one\\ two ", result);
g_free(result);
}
void
parse_options__returns__empty_hashmap_when_none(void** state)
{

View File

@@ -29,6 +29,9 @@ void parse_args_with_freetext__returns__quoted_freetext(void** state);
void parse_args_with_freetext__returns__third_arg_quoted(void** state);
void parse_args_with_freetext__returns__second_arg_quoted(void** state);
void parse_args_with_freetext__returns__second_and_third_arg_quoted(void** state);
void parse_args__returns__escaped_quotes(void** state);
void parse_args__returns__escaped_spaces(void** state);
void parse_args__returns__escaped_backslash(void** state);
void count_tokens__returns__one_token(void** state);
void count_tokens__returns__one_token_quoted_no_whitespace(void** state);
void count_tokens__returns__one_token_quoted_with_whitespace(void** state);
@@ -36,12 +39,14 @@ void count_tokens__returns__two_tokens(void** state);
void count_tokens__returns__two_tokens_first_quoted(void** state);
void count_tokens__returns__two_tokens_second_quoted(void** state);
void count_tokens__returns__two_tokens_both_quoted(void** state);
void count_tokens__handles__escapes(void** state);
void get_start__returns__first_of_one(void** state);
void get_start__returns__first_of_two(void** state);
void get_start__returns__first_two_of_three(void** state);
void get_start__returns__first_two_of_three_first_quoted(void** state);
void get_start__returns__first_two_of_three_second_quoted(void** state);
void get_start__returns__first_two_of_three_first_and_second_quoted(void** state);
void get_start__handles__escapes(void** state);
void parse_options__returns__empty_hashmap_when_none(void** state);
void parse_options__returns__error_when_opt1_no_val(void** state);
void parse_options__returns__map_when_one(void** state);

View File

@@ -101,6 +101,7 @@ main(int argc, char* argv[])
cmocka_unit_test(strip_arg_quotes__returns__stripped_first),
cmocka_unit_test(strip_arg_quotes__returns__stripped_last),
cmocka_unit_test(strip_arg_quotes__returns__stripped_both),
cmocka_unit_test(strip_arg_quotes__unescapes),
cmocka_unit_test(format_call_external_argv__tests__table_driven),
cmocka_unit_test(unique_filename_from_url__tests__table_driven),
cmocka_unit_test(string_to_verbosity__returns__correct_values),
@@ -206,6 +207,9 @@ main(int argc, char* argv[])
cmocka_unit_test(parse_args_with_freetext__returns__third_arg_quoted),
cmocka_unit_test(parse_args_with_freetext__returns__second_arg_quoted),
cmocka_unit_test(parse_args_with_freetext__returns__second_and_third_arg_quoted),
cmocka_unit_test(parse_args__returns__escaped_quotes),
cmocka_unit_test(parse_args__returns__escaped_spaces),
cmocka_unit_test(parse_args__returns__escaped_backslash),
cmocka_unit_test(count_tokens__returns__one_token),
cmocka_unit_test(count_tokens__returns__one_token_quoted_no_whitespace),
cmocka_unit_test(count_tokens__returns__one_token_quoted_with_whitespace),
@@ -213,12 +217,14 @@ main(int argc, char* argv[])
cmocka_unit_test(count_tokens__returns__two_tokens_first_quoted),
cmocka_unit_test(count_tokens__returns__two_tokens_second_quoted),
cmocka_unit_test(count_tokens__returns__two_tokens_both_quoted),
cmocka_unit_test(count_tokens__handles__escapes),
cmocka_unit_test(get_start__returns__first_of_one),
cmocka_unit_test(get_start__returns__first_of_two),
cmocka_unit_test(get_start__returns__first_two_of_three),
cmocka_unit_test(get_start__returns__first_two_of_three_first_quoted),
cmocka_unit_test(get_start__returns__first_two_of_three_second_quoted),
cmocka_unit_test(get_start__returns__first_two_of_three_first_and_second_quoted),
cmocka_unit_test(get_start__handles__escapes),
cmocka_unit_test(parse_options__returns__empty_hashmap_when_none),
cmocka_unit_test(parse_options__returns__error_when_opt1_no_val),
cmocka_unit_test(parse_options__returns__map_when_one),