From 1c359065dd50ba3bdc3adb1d5a1bb42b1b4ce9fa Mon Sep 17 00:00:00 2001 From: Marek Libra Date: Wed, 27 May 2026 12:24:26 +0200 Subject: [PATCH 1/2] Ticket #73: do not intercept 'cd' in compound commands Skip internal cd handling when the command line contains unquoted shell metacharacters (; | &) or command substitution syntax ($( or `), letting the shell execute it instead. Double quotes suppress operators but not command substitution, matching real shell behavior. Signed-off-by: Marek Libra --- src/filemanager/command.c | 88 +++++++++- src/filemanager/command.h | 1 + tests/src/filemanager/Makefile.am | 4 + .../filemanager/has_unquoted_metacharacters.c | 157 ++++++++++++++++++ 4 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 tests/src/filemanager/has_unquoted_metacharacters.c diff --git a/src/filemanager/command.c b/src/filemanager/command.c index fd68b2b1df..f4592d97cd 100644 --- a/src/filemanager/command.c +++ b/src/filemanager/command.c @@ -102,7 +102,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 +208,91 @@ 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) + * + * 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 (in_double_quote) + { + if (*p == '\\' && p[1] != '\0') + p++; + else if (*p == '"') + in_double_quote = FALSE; + else if (*p == '`') + return TRUE; + else if (*p == '$' && p[1] == '(') + return TRUE; + continue; + } + + if (*p == '\\' && p[1] != '\0') + { + p++; + continue; + } + + if (*p == '\'') + { + in_single_quote = TRUE; + continue; + } + + if (*p == '"') + { + in_double_quote = TRUE; + continue; + } + + if (*p == ';' || *p == '|' || *p == '&') + return TRUE; + + if (*p == '`') + return TRUE; + + if (*p == '$' && p[1] == '(') + 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 0a321d3e0b..fa2eb55f3a 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 aecd7496d8..1875313201 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 0000000000..7eccca1fee --- /dev/null +++ b/tests/src/filemanager/has_unquoted_metacharacters.c @@ -0,0 +1,157 @@ +/* + src/filemanager - tests for command_has_unquoted_metacharacters() + + Copyright (C) 2026 + Free Software Foundation, Inc. + + 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 }, + + /* 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); +} + +/* --------------------------------------------------------------------------------------------- */ From c0209d189946914ee96f82602f406c57fe70e2b9 Mon Sep 17 00:00:00 2001 From: Marek Libra Date: Sun, 7 Jun 2026 21:18:36 +0200 Subject: [PATCH 2/2] review Signed-off-by: Marek Libra --- src/filemanager/command.c | 34 +++++++++---------- .../filemanager/has_unquoted_metacharacters.c | 8 +++++ 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/src/filemanager/command.c b/src/filemanager/command.c index f4592d97cd..90e82487ff 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 @@ -217,6 +218,9 @@ command_callback (Widget *w, Widget *sender, widget_msg_t msg, int parm, void *d * - 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. @@ -247,22 +251,22 @@ command_has_unquoted_metacharacters (const char *cmd) continue; } - if (in_double_quote) + if (*p == '\\' && p[1] != '\0') { - if (*p == '\\' && p[1] != '\0') - p++; - else if (*p == '"') - in_double_quote = FALSE; - else if (*p == '`') - return TRUE; - else if (*p == '$' && p[1] == '(') - return TRUE; + p++; continue; } - if (*p == '\\' && p[1] != '\0') + if (*p == '`') + return TRUE; + + if (*p == '$' && p[1] == '(') + return TRUE; + + if (in_double_quote) { - p++; + if (*p == '"') + in_double_quote = FALSE; continue; } @@ -276,16 +280,10 @@ command_has_unquoted_metacharacters (const char *cmd) { in_double_quote = TRUE; continue; - } + } if (*p == ';' || *p == '|' || *p == '&') return TRUE; - - if (*p == '`') - return TRUE; - - if (*p == '$' && p[1] == '(') - return TRUE; } return FALSE; diff --git a/tests/src/filemanager/has_unquoted_metacharacters.c b/tests/src/filemanager/has_unquoted_metacharacters.c index 7eccca1fee..e6ad6eb783 100644 --- a/tests/src/filemanager/has_unquoted_metacharacters.c +++ b/tests/src/filemanager/has_unquoted_metacharacters.c @@ -4,6 +4,9 @@ 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 @@ -91,6 +94,11 @@ static const struct test_metacharacters_ds /* 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 },