diff --git a/src/filemanager/command.c b/src/filemanager/command.c index fd68b2b1d..90e82487f 100644 --- a/src/filemanager/command.c +++ b/src/filemanager/command.c @@ -8,6 +8,7 @@ Free Software Foundation, Inc. Written by: + Marek Libra , 2026 Slava Zanko , 2013 Andrew Borodin , 2011-2022 @@ -102,7 +103,8 @@ enter (WInput *lc_cmdline) if (*cmd == '\0') return MSG_HANDLED; - if (strncmp (cmd, "cd", 2) == 0 && (cmd[2] == '\0' || whitespace (cmd[2]))) + if (strncmp (cmd, "cd", 2) == 0 && (cmd[2] == '\0' || whitespace (cmd[2])) + && !command_has_unquoted_metacharacters (cmd)) { cd_to (cmd + 2); input_clean (lc_cmdline); @@ -207,6 +209,88 @@ command_callback (Widget *w, Widget *sender, widget_msg_t msg, int parm, void *d /*** public functions ****************************************************************************/ /* --------------------------------------------------------------------------------------------- */ +/** + * Detect shell syntax that the internal cd handler cannot process. + * + * Returns TRUE when any of the following appears outside of single quotes + * and backslash escaping: + * - Command separators/operators: ; | & + * - Command substitution: $( or ` (including inside double quotes, + * where the shell still expands them) + * + * Note: plain parameter expansion ($VAR, ${VAR}) is intentionally excluded + * because the internal cd handler expands environment variables itself. + * + * Motivation: the internal cd handler cannot expand command substitutions + * nor execute compound commands. When any of these constructs are present, + * the entire command line must be passed to the shell. + * + * Only single quotes and backslash fully suppress detection. Double quotes + * suppress ; | & but NOT $( and ` because the shell expands command + * substitution inside double quotes. + * + * Parsing strategy: forward scan tracking quote state. + * We treat $( and ` as immediate indicators rather than parsing their + * contents, because internal cd cannot handle them regardless + * of what is inside. + */ + +gboolean +command_has_unquoted_metacharacters (const char *cmd) +{ + gboolean in_single_quote = FALSE; + gboolean in_double_quote = FALSE; + const char *p; + + for (p = cmd; *p != '\0'; p++) + { + if (in_single_quote) + { + if (*p == '\'') + in_single_quote = FALSE; + continue; + } + + if (*p == '\\' && p[1] != '\0') + { + p++; + continue; + } + + if (*p == '`') + return TRUE; + + if (*p == '$' && p[1] == '(') + return TRUE; + + if (in_double_quote) + { + if (*p == '"') + in_double_quote = FALSE; + continue; + } + + if (*p == '\'') + { + in_single_quote = TRUE; + continue; + } + + if (*p == '"') + { + in_double_quote = TRUE; + continue; + } + + if (*p == ';' || *p == '|' || *p == '&') + return TRUE; + } + + return FALSE; +} + +/* --------------------------------------------------------------------------------------------- */ + WInput * command_new (int y, int x, int cols) { diff --git a/src/filemanager/command.h b/src/filemanager/command.h index 0a321d3e0..fa2eb55f3 100644 --- a/src/filemanager/command.h +++ b/src/filemanager/command.h @@ -21,6 +21,7 @@ extern WInput *cmdline; WInput *command_new (int y, int x, int len); void command_insert (WInput *in, const char *text, gboolean insert_extra_space); +gboolean command_has_unquoted_metacharacters (const char *cmd); /*** inline functions ****************************************************************************/ #endif diff --git a/tests/src/filemanager/Makefile.am b/tests/src/filemanager/Makefile.am index aecd7496d..187531320 100644 --- a/tests/src/filemanager/Makefile.am +++ b/tests/src/filemanager/Makefile.am @@ -17,6 +17,7 @@ endif TESTS = \ cd_to \ + has_unquoted_metacharacters \ examine_cd \ exec_get_export_variables_ext \ ext__exec_make_shell_string \ @@ -28,6 +29,9 @@ check_PROGRAMS = $(TESTS) cd_to_SOURCES = \ cd_to.c +has_unquoted_metacharacters_SOURCES = \ + has_unquoted_metacharacters.c + examine_cd_SOURCES = \ examine_cd.c diff --git a/tests/src/filemanager/has_unquoted_metacharacters.c b/tests/src/filemanager/has_unquoted_metacharacters.c new file mode 100644 index 000000000..e6ad6eb78 --- /dev/null +++ b/tests/src/filemanager/has_unquoted_metacharacters.c @@ -0,0 +1,165 @@ +/* + src/filemanager - tests for command_has_unquoted_metacharacters() + + Copyright (C) 2026 + Free Software Foundation, Inc. + + Written by: + Marek Libra , 2026 + + This file is part of the Midnight Commander. + + The Midnight Commander is free software: you can redistribute it + and/or modify it under the terms of the GNU General Public License as + published by the Free Software Foundation, either version 3 of the License, + or (at your option) any later version. + + The Midnight Commander is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +#define TEST_SUITE_NAME "/src/filemanager" + +#include "tests/mctest.h" + +#include "src/filemanager/command.h" + +/* --------------------------------------------------------------------------------------------- */ + +/* @DataSource("test_metacharacters_ds") */ +static const struct test_metacharacters_ds +{ + const char *input_value; + gboolean expected_result; +} test_metacharacters_ds[] = { + /* simple commands - no metacharacters */ + { "cd", FALSE }, + { "cd /tmp", FALSE }, + { "cd ..", FALSE }, + { "cd ~/projects", FALSE }, + { "cd $HOME", FALSE }, + { "cd -", FALSE }, + { "cd /path/to/dir", FALSE }, + { "cd foo bar", FALSE }, + + /* semicolons */ + { "cd ..; make", TRUE }, + { "cd ..; make; cd -", TRUE }, + { "cd /tmp; ls", TRUE }, + { "cd ..;ls", TRUE }, + { "cd /tmp;ls", TRUE }, + + /* && */ + { "cd .. && make", TRUE }, + { "cd /tmp && ls && pwd", TRUE }, + { "cd ..&&ls", TRUE }, + + /* || */ + { "cd .. || echo fail", TRUE }, + { "cd ..||ls", TRUE }, + + /* pipe */ + { "cd foo | bar", TRUE }, + { "cd ..|ls", TRUE }, + + /* background / single & */ + { "cd &", TRUE }, + { "cd a&b", TRUE }, + + /* quoted paths - metacharacters inside quotes are literal */ + { "cd 'foo bar'", FALSE }, + { "cd \"foo bar\"", FALSE }, + { "cd 'foo;bar'", FALSE }, + { "cd 'foo|bar'", FALSE }, + { "cd 'foo&bar'", FALSE }, + { "cd \"foo;bar\"", FALSE }, + { "cd \"foo|bar\"", FALSE }, + { "cd \"foo&bar\"", FALSE }, + + /* quoted path followed by a real separator */ + { "cd 'foo'; ls", TRUE }, + + /* backslash-escaped metacharacters are literal */ + { "cd foo\\ bar", FALSE }, + { "cd foo\\;bar", FALSE }, + { "cd foo\\|bar", FALSE }, + { "cd foo\\&bar", FALSE }, + { "cd path\\;with\\;semicolons", FALSE }, + + /* escaped metacharacter plus a real separator */ + { "cd foo\\;bar; make", TRUE }, + + /* double-backslash before semicolon: \\; at runtime - first \ escapes second, the ; is real */ + { "cd foo\\\\;ls", TRUE }, + /* triple-backslash before semicolon: \\\; at runtime - \\ + \; (escaped semicolon) */ + { "cd foo\\\\\\;ls", FALSE }, + + /* command substitution $(...) - internal cd cannot expand */ + { "cd $(echo /tmp)", TRUE }, + { "cd $(pwd)", TRUE }, + { "cd $(echo foo;echo bar)", TRUE }, + + /* command substitution with backticks */ + { "cd `echo /tmp`", TRUE }, + { "cd `pwd`", TRUE }, + + /* single-quoted command substitution - literal, not metacharacters */ + { "cd '$(echo /tmp)'", FALSE }, + { "cd '`echo /tmp`'", FALSE }, + + /* double-quoted command substitution - shell still expands these */ + { "cd \"$(echo /tmp)\"", TRUE }, + { "cd \"`echo /tmp`\"", TRUE }, + + /* escaped $ and backtick */ + { "cd \\$(echo /tmp)", FALSE }, + { "cd \\`echo /tmp\\`", FALSE }, + + /* empty and whitespace-only strings */ + { "", FALSE }, + { " ", FALSE }, + + /* parameter expansion - not command substitution */ + { "cd ${HOME}", FALSE }, + + /* arithmetic expansion - triggers $( detection */ + { "cd $((1+2))", TRUE }, + + /* unterminated quotes - no metachar, shell would error anyway */ + { "cd 'foo", FALSE }, + { "cd \"foo", FALSE }, +}; + +/* @Test(dataSource = "test_metacharacters_ds") */ +START_PARAMETRIZED_TEST (test_metacharacters, test_metacharacters_ds) +{ + gboolean actual_result; + + actual_result = command_has_unquoted_metacharacters (data->input_value); + + ck_assert_int_eq (actual_result, data->expected_result); +} +END_PARAMETRIZED_TEST + +/* --------------------------------------------------------------------------------------------- */ + +int +main (void) +{ + TCase *tc_core; + + tc_core = tcase_create ("Core"); + + /* Add new tests here: *************** */ + mctest_add_parameterized_test (tc_core, test_metacharacters, test_metacharacters_ds); + /* *********************************** */ + + return mctest_run_all (tc_core); +} + +/* --------------------------------------------------------------------------------------------- */