Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 87 additions & 1 deletion src/filemanager/command.c
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
Written by:
Slava Zanko <slavazanko@gmail.com>, 2013
Andrew Borodin <aborodin@vmail.ru>, 2011-2022
Marek Libra <marek.libra@gmail.com>, 2026

This file is part of the Midnight Commander.

Expand Down Expand Up @@ -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 */
Expand All @@ -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
Expand All @@ -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);
Expand Down
6 changes: 5 additions & 1 deletion tests/src/filemanager/Makefile.am
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -42,3 +43,6 @@ get_random_hint_SOURCES = \

filegui_is_wildcarded_SOURCES = \
filegui_is_wildcarded.c

has_unquoted_metacharacters_SOURCES = \
has_unquoted_metacharacters.c
167 changes: 167 additions & 0 deletions tests/src/filemanager/has_unquoted_metacharacters.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/*
src/filemanager - tests for command_has_unquoted_metacharacters()

Copyright (C) 2026
Free Software Foundation, Inc.

Written by:
Marek Libra <marek.libra@gmail.com>, 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 <https://www.gnu.org/licenses/>.
*/

#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 },
Comment thread
mareklibra marked this conversation as resolved.

/* 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);
}

/* --------------------------------------------------------------------------------------------- */
Loading