Skip to content

Commit 1c35906

Browse files
committed
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 <marek.libra@gmail.com>
1 parent 4115fb9 commit 1c35906

4 files changed

Lines changed: 249 additions & 1 deletion

File tree

src/filemanager/command.c

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,8 @@ enter (WInput *lc_cmdline)
102102
if (*cmd == '\0')
103103
return MSG_HANDLED;
104104

105-
if (strncmp (cmd, "cd", 2) == 0 && (cmd[2] == '\0' || whitespace (cmd[2])))
105+
if (strncmp (cmd, "cd", 2) == 0 && (cmd[2] == '\0' || whitespace (cmd[2]))
106+
&& !command_has_unquoted_metacharacters (cmd))
106107
{
107108
cd_to (cmd + 2);
108109
input_clean (lc_cmdline);
@@ -207,6 +208,91 @@ command_callback (Widget *w, Widget *sender, widget_msg_t msg, int parm, void *d
207208
/*** public functions ****************************************************************************/
208209
/* --------------------------------------------------------------------------------------------- */
209210

211+
/**
212+
* Detect shell syntax that the internal cd handler cannot process.
213+
*
214+
* Returns TRUE when any of the following appears outside of single quotes
215+
* and backslash escaping:
216+
* - Command separators/operators: ; | &
217+
* - Command substitution: $( or ` (including inside double quotes,
218+
* where the shell still expands them)
219+
*
220+
* Motivation: the internal cd handler cannot expand command substitutions
221+
* nor execute compound commands. When any of these constructs are present,
222+
* the entire command line must be passed to the shell.
223+
*
224+
* Only single quotes and backslash fully suppress detection. Double quotes
225+
* suppress ; | & but NOT $( and ` because the shell expands command
226+
* substitution inside double quotes.
227+
*
228+
* Parsing strategy: forward scan tracking quote state.
229+
* We treat $( and ` as immediate indicators rather than parsing their
230+
* contents, because internal cd cannot handle them regardless
231+
* of what is inside.
232+
*/
233+
234+
gboolean
235+
command_has_unquoted_metacharacters (const char *cmd)
236+
{
237+
gboolean in_single_quote = FALSE;
238+
gboolean in_double_quote = FALSE;
239+
const char *p;
240+
241+
for (p = cmd; *p != '\0'; p++)
242+
{
243+
if (in_single_quote)
244+
{
245+
if (*p == '\'')
246+
in_single_quote = FALSE;
247+
continue;
248+
}
249+
250+
if (in_double_quote)
251+
{
252+
if (*p == '\\' && p[1] != '\0')
253+
p++;
254+
else if (*p == '"')
255+
in_double_quote = FALSE;
256+
else if (*p == '`')
257+
return TRUE;
258+
else if (*p == '$' && p[1] == '(')
259+
return TRUE;
260+
continue;
261+
}
262+
263+
if (*p == '\\' && p[1] != '\0')
264+
{
265+
p++;
266+
continue;
267+
}
268+
269+
if (*p == '\'')
270+
{
271+
in_single_quote = TRUE;
272+
continue;
273+
}
274+
275+
if (*p == '"')
276+
{
277+
in_double_quote = TRUE;
278+
continue;
279+
}
280+
281+
if (*p == ';' || *p == '|' || *p == '&')
282+
return TRUE;
283+
284+
if (*p == '`')
285+
return TRUE;
286+
287+
if (*p == '$' && p[1] == '(')
288+
return TRUE;
289+
}
290+
291+
return FALSE;
292+
}
293+
294+
/* --------------------------------------------------------------------------------------------- */
295+
210296
WInput *
211297
command_new (int y, int x, int cols)
212298
{

src/filemanager/command.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ extern WInput *cmdline;
2121

2222
WInput *command_new (int y, int x, int len);
2323
void command_insert (WInput *in, const char *text, gboolean insert_extra_space);
24+
gboolean command_has_unquoted_metacharacters (const char *cmd);
2425

2526
/*** inline functions ****************************************************************************/
2627
#endif

tests/src/filemanager/Makefile.am

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ endif
1717

1818
TESTS = \
1919
cd_to \
20+
has_unquoted_metacharacters \
2021
examine_cd \
2122
exec_get_export_variables_ext \
2223
ext__exec_make_shell_string \
@@ -28,6 +29,9 @@ check_PROGRAMS = $(TESTS)
2829
cd_to_SOURCES = \
2930
cd_to.c
3031

32+
has_unquoted_metacharacters_SOURCES = \
33+
has_unquoted_metacharacters.c
34+
3135
examine_cd_SOURCES = \
3236
examine_cd.c
3337

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
/*
2+
src/filemanager - tests for command_has_unquoted_metacharacters()
3+
4+
Copyright (C) 2026
5+
Free Software Foundation, Inc.
6+
7+
This file is part of the Midnight Commander.
8+
9+
The Midnight Commander is free software: you can redistribute it
10+
and/or modify it under the terms of the GNU General Public License as
11+
published by the Free Software Foundation, either version 3 of the License,
12+
or (at your option) any later version.
13+
14+
The Midnight Commander is distributed in the hope that it will be useful,
15+
but WITHOUT ANY WARRANTY; without even the implied warranty of
16+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17+
GNU General Public License for more details.
18+
19+
You should have received a copy of the GNU General Public License
20+
along with this program. If not, see <https://www.gnu.org/licenses/>.
21+
*/
22+
23+
#define TEST_SUITE_NAME "/src/filemanager"
24+
25+
#include "tests/mctest.h"
26+
27+
#include "src/filemanager/command.h"
28+
29+
/* --------------------------------------------------------------------------------------------- */
30+
31+
/* @DataSource("test_metacharacters_ds") */
32+
static const struct test_metacharacters_ds
33+
{
34+
const char *input_value;
35+
gboolean expected_result;
36+
} test_metacharacters_ds[] = {
37+
/* simple commands - no metacharacters */
38+
{ "cd", FALSE },
39+
{ "cd /tmp", FALSE },
40+
{ "cd ..", FALSE },
41+
{ "cd ~/projects", FALSE },
42+
{ "cd $HOME", FALSE },
43+
{ "cd -", FALSE },
44+
{ "cd /path/to/dir", FALSE },
45+
{ "cd foo bar", FALSE },
46+
47+
/* semicolons */
48+
{ "cd ..; make", TRUE },
49+
{ "cd ..; make; cd -", TRUE },
50+
{ "cd /tmp; ls", TRUE },
51+
{ "cd ..;ls", TRUE },
52+
{ "cd /tmp;ls", TRUE },
53+
54+
/* && */
55+
{ "cd .. && make", TRUE },
56+
{ "cd /tmp && ls && pwd", TRUE },
57+
{ "cd ..&&ls", TRUE },
58+
59+
/* || */
60+
{ "cd .. || echo fail", TRUE },
61+
{ "cd ..||ls", TRUE },
62+
63+
/* pipe */
64+
{ "cd foo | bar", TRUE },
65+
{ "cd ..|ls", TRUE },
66+
67+
/* background / single & */
68+
{ "cd &", TRUE },
69+
{ "cd a&b", TRUE },
70+
71+
/* quoted paths - metacharacters inside quotes are literal */
72+
{ "cd 'foo bar'", FALSE },
73+
{ "cd \"foo bar\"", FALSE },
74+
{ "cd 'foo;bar'", FALSE },
75+
{ "cd 'foo|bar'", FALSE },
76+
{ "cd 'foo&bar'", FALSE },
77+
{ "cd \"foo;bar\"", FALSE },
78+
{ "cd \"foo|bar\"", FALSE },
79+
{ "cd \"foo&bar\"", FALSE },
80+
81+
/* quoted path followed by a real separator */
82+
{ "cd 'foo'; ls", TRUE },
83+
84+
/* backslash-escaped metacharacters are literal */
85+
{ "cd foo\\ bar", FALSE },
86+
{ "cd foo\\;bar", FALSE },
87+
{ "cd foo\\|bar", FALSE },
88+
{ "cd foo\\&bar", FALSE },
89+
{ "cd path\\;with\\;semicolons", FALSE },
90+
91+
/* escaped metacharacter plus a real separator */
92+
{ "cd foo\\;bar; make", TRUE },
93+
94+
/* command substitution $(...) - internal cd cannot expand */
95+
{ "cd $(echo /tmp)", TRUE },
96+
{ "cd $(pwd)", TRUE },
97+
{ "cd $(echo foo;echo bar)", TRUE },
98+
99+
/* command substitution with backticks */
100+
{ "cd `echo /tmp`", TRUE },
101+
{ "cd `pwd`", TRUE },
102+
103+
/* single-quoted command substitution - literal, not metacharacters */
104+
{ "cd '$(echo /tmp)'", FALSE },
105+
{ "cd '`echo /tmp`'", FALSE },
106+
107+
/* double-quoted command substitution - shell still expands these */
108+
{ "cd \"$(echo /tmp)\"", TRUE },
109+
{ "cd \"`echo /tmp`\"", TRUE },
110+
111+
/* escaped $ and backtick */
112+
{ "cd \\$(echo /tmp)", FALSE },
113+
{ "cd \\`echo /tmp\\`", FALSE },
114+
115+
/* empty and whitespace-only strings */
116+
{ "", FALSE },
117+
{ " ", FALSE },
118+
119+
/* parameter expansion - not command substitution */
120+
{ "cd ${HOME}", FALSE },
121+
122+
/* arithmetic expansion - triggers $( detection */
123+
{ "cd $((1+2))", TRUE },
124+
125+
/* unterminated quotes - no metachar, shell would error anyway */
126+
{ "cd 'foo", FALSE },
127+
{ "cd \"foo", FALSE },
128+
};
129+
130+
/* @Test(dataSource = "test_metacharacters_ds") */
131+
START_PARAMETRIZED_TEST (test_metacharacters, test_metacharacters_ds)
132+
{
133+
gboolean actual_result;
134+
135+
actual_result = command_has_unquoted_metacharacters (data->input_value);
136+
137+
ck_assert_int_eq (actual_result, data->expected_result);
138+
}
139+
END_PARAMETRIZED_TEST
140+
141+
/* --------------------------------------------------------------------------------------------- */
142+
143+
int
144+
main (void)
145+
{
146+
TCase *tc_core;
147+
148+
tc_core = tcase_create ("Core");
149+
150+
/* Add new tests here: *************** */
151+
mctest_add_parameterized_test (tc_core, test_metacharacters, test_metacharacters_ds);
152+
/* *********************************** */
153+
154+
return mctest_run_all (tc_core);
155+
}
156+
157+
/* --------------------------------------------------------------------------------------------- */

0 commit comments

Comments
 (0)