Skip to content
Open
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
86 changes: 85 additions & 1 deletion src/filemanager/command.c
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
Free Software Foundation, Inc.

Written by:
Marek Libra <marek.libra@gmail.com>, 2026
Slava Zanko <slavazanko@gmail.com>, 2013
Andrew Borodin <aborodin@vmail.ru>, 2011-2022

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)
*
Comment thread
mareklibra marked this conversation as resolved.
* 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)
{
Expand Down
1 change: 1 addition & 0 deletions src/filemanager/command.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions tests/src/filemanager/Makefile.am
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ endif

TESTS = \
cd_to \
has_unquoted_metacharacters \
examine_cd \
exec_get_export_variables_ext \
ext__exec_make_shell_string \
Expand All @@ -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

Expand Down
165 changes: 165 additions & 0 deletions tests/src/filemanager/has_unquoted_metacharacters.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/*
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"

#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 },
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);
}

/* --------------------------------------------------------------------------------------------- */