From 7cbad21876a1c38272fbd2585de672086cffed71 Mon Sep 17 00:00:00 2001 From: Marek Libra Date: Wed, 27 May 2026 12:24:26 +0200 Subject: [PATCH] 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 ++++++++- tests/src/filemanager/Makefile.am | 6 +- .../filemanager/has_unquoted_metacharacters.c | 167 ++++++++++++++++++ 3 files changed, 259 insertions(+), 2 deletions(-) create mode 100644 tests/src/filemanager/has_unquoted_metacharacters.c diff --git a/src/filemanager/command.c b/src/filemanager/command.c index fd68b2b1df..79c3e88a4e 100644 --- a/src/filemanager/command.c +++ b/src/filemanager/command.c @@ -10,6 +10,7 @@ Written by: Slava Zanko , 2013 Andrew Borodin , 2011-2022 + Marek Libra , 2026 This file is part of the Midnight Commander. @@ -65,6 +66,10 @@ WInput *cmdline; /*** forward declarations (file scope functions) *************************************************/ +#ifdef HAVE_TESTS +MC_TESTABLE gboolean command_has_unquoted_metacharacters (const char *cmd); +#endif + /*** file scope variables ************************************************************************/ /* Color styles command line */ @@ -77,6 +82,86 @@ static const input_colors_t command_colors = { /* --------------------------------------------------------------------------------------------- */ /*** file scope 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. + */ + +MC_TESTABLE gboolean +command_has_unquoted_metacharacters (const char *cmd) +{ + gboolean in_single_quote = FALSE; + gboolean in_double_quote = FALSE; + + for (const char *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; +} + /* --------------------------------------------------------------------------------------------- */ /** Handle Enter on the command line @@ -102,7 +187,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); diff --git a/tests/src/filemanager/Makefile.am b/tests/src/filemanager/Makefile.am index aecd7496d8..7a902ce0bb 100644 --- a/tests/src/filemanager/Makefile.am +++ b/tests/src/filemanager/Makefile.am @@ -21,7 +21,8 @@ TESTS = \ exec_get_export_variables_ext \ ext__exec_make_shell_string \ filegui_is_wildcarded \ - get_random_hint + get_random_hint \ + has_unquoted_metacharacters check_PROGRAMS = $(TESTS) @@ -42,3 +43,6 @@ get_random_hint_SOURCES = \ filegui_is_wildcarded_SOURCES = \ filegui_is_wildcarded.c + +has_unquoted_metacharacters_SOURCES = \ + has_unquoted_metacharacters.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..989e5b03c9 --- /dev/null +++ b/tests/src/filemanager/has_unquoted_metacharacters.c @@ -0,0 +1,167 @@ +/* + 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" + +/* --------------------------------------------------------------------------------------------- */ + +MC_TESTABLE gboolean command_has_unquoted_metacharacters (const char *cmd); + +/* --------------------------------------------------------------------------------------------- */ + +/* @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); +} + +/* --------------------------------------------------------------------------------------------- */