Skip to content

Commit c29bec2

Browse files
committed
fix(build,platform): paths with spaces survive both include channels and cmd.exe
Two independent places assumed no path ever contains a space — which on Windows means assuming nobody installs under C:\Program Files and no user name has a space in it. include dirs (#331): the same manifest include_dirs reaches the compiler through the global blob in flags.cppm and through ninja_backend's per-TU $local_includes. Only the first shell-quoted; the second applied ninja's $ escaping alone, so ninja un-escaped the space and handed the shell a word that split. Both now go through mcpp::build::include_token — a shared helper rather than a second copy of the quoting, because a third channel is exactly how this happened. That also fixes the plain half of local_include_flags hardcoding -I while its after-dirs half honoured the dialect. cmd.exe: argv[0] was emitted raw to survive cmd's /c quote stripping, which traded 'the path gets mangled' for 'the path is cut at the first space'. Give cmd the outer quote pair it insists on consuming and the inner quoting arrives intact, so every token can be quoted. run_exec keeps inheriting stdio — sealing its stdin would break interactive `mcpp run`. The Windows command-line shape is now built by host-independent functions so Linux CI exercises it; that branch is not even compiled on the platforms mcpp is developed on, which is how the unquoted argv[0] survived this long.
1 parent 103780b commit c29bec2

6 files changed

Lines changed: 281 additions & 29 deletions

File tree

src/build/flags.cppm

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,26 @@ std::string atomic_link_flag(const std::vector<std::filesystem::path>& linkDirs,
8282
// escaped as `\"`) — cmd.exe/CreateProcess argv convention.
8383
std::string shell_quote_arg(std::string_view arg);
8484

85+
// One include-directory token, fully prepared for a ninja command line:
86+
// dialect prefix, ninja `$` escaping, and shell quoting — in that order.
87+
//
88+
// #331: the same manifest `[build] include_dirs` reaches the compiler through
89+
// two channels — the global blob assembled below, and the per-translation-unit
90+
// `$local_includes` emitted by ninja_backend. Only the first one quoted, so an
91+
// include dir containing a space (`C:\Program Files\...`, or `/home/my dir` on
92+
// Linux) survived one path and split into separate shell words on the other.
93+
// Both channels call this now; adding a third one and forgetting to quote is
94+
// how the bug happened, and a shared helper is the only fix that also covers
95+
// the fourth.
96+
//
97+
// `prefixOverride` replaces `d.includePrefix` for the callers that need a
98+
// different flag for the same kind of path (`-idirafter` for #249's
99+
// after-dirs, plain `-I` for NASM units which would parse `-idirafter<p>` as
100+
// `-i dirafter<p>`).
101+
std::string include_token(const mcpp::toolchain::CommandDialect& d,
102+
const std::filesystem::path& dir,
103+
std::string_view prefixOverride = {});
104+
85105
} // namespace mcpp::build
86106

87107
namespace mcpp::build {
@@ -139,6 +159,19 @@ std::string atomic_link_flag(const std::vector<std::filesystem::path>& linkDirs,
139159
return {};
140160
}
141161

162+
std::string include_token(const mcpp::toolchain::CommandDialect& d,
163+
const std::filesystem::path& dir,
164+
std::string_view prefixOverride) {
165+
std::string_view prefix =
166+
prefixOverride.empty() ? d.includePrefix : prefixOverride;
167+
// Prefix first, then escape+quote the whole token: the prefix and the
168+
// path are ONE argv word, so quoting them separately would put the
169+
// opening quote in the wrong place and re-split exactly what we came to
170+
// join.
171+
return shell_quote_arg(
172+
escape_path(std::filesystem::path(std::string(prefix) + dir.string())));
173+
}
174+
142175
std::string shell_quote_arg(std::string_view arg) {
143176
// Characters that split/alter a word when unquoted in POSIX sh or
144177
// cmd.exe: whitespace plus the common shell metacharacters. Anything
@@ -222,7 +255,7 @@ CompileFlags compute_flags(const BuildPlan& plan) {
222255
std::vector<std::string> includeTokens;
223256
for (auto& inc : plan.manifest.buildConfig.includeDirs) {
224257
std::filesystem::path p = inc.has_root_path() ? inc : (plan.projectRoot / inc);
225-
includeTokens.push_back(std::string(d.includePrefix) + p.string());
258+
includeTokens.push_back(include_token(d, p));
226259
}
227260
// #249: `[build] include_dirs_after` — searched AFTER the toolchain's
228261
// system dirs via -idirafter (gcc+clang), so entries can't shadow
@@ -233,12 +266,13 @@ CompileFlags compute_flags(const BuildPlan& plan) {
233266
for (auto& inc : plan.manifest.buildConfig.includeDirsAfter) {
234267
std::filesystem::path ip(inc);
235268
std::filesystem::path p = ip.has_root_path() ? ip : (plan.projectRoot / ip);
236-
includeTokens.push_back((msvcInclude ? "/I" : "-idirafter") + p.string());
269+
includeTokens.push_back(
270+
include_token(d, p, msvcInclude ? "/I" : "-idirafter"));
237271
}
238272
std::string include_flags;
239273
for (auto& t : includeTokens) {
240274
include_flags += ' ';
241-
include_flags += shell_quote_arg(escape_path(std::filesystem::path(t)));
275+
include_flags += t; // already prefixed, escaped and quoted
242276
}
243277

244278
// Sysroot / payload paths — resolved ONCE by the toolchain link model

src/build/ninja_backend.cppm

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -105,12 +105,20 @@ bool is_nasm_source(const std::filesystem::path& src) {
105105
return src.extension() == ".asm";
106106
}
107107

108-
std::string local_include_flags(const CompileUnit& cu, bool msvcDialect) {
109-
const bool nasmUnit = is_nasm_source(cu.source);
108+
std::string local_include_flags(const CompileUnit& cu,
109+
const mcpp::toolchain::CommandDialect& d) {
110+
const bool nasmUnit = is_nasm_source(cu.source);
111+
const bool msvcDialect = d.includePrefix == std::string_view("/I");
110112
std::string flags;
111113
for (auto const& inc : cu.localIncludeDirs) {
112-
flags += " -I";
113-
flags += escape_flag_path(inc);
114+
// #331: this used to hardcode `-I` and apply only ninja's `$`
115+
// escaping — no shell quoting — while the global channel in
116+
// flags.cppm quoted properly. Same manifest include_dirs, two
117+
// derivations, and a directory with a space in it split into
118+
// separate shell words on this path only. Both channels now go
119+
// through mcpp::build::include_token.
120+
flags += ' ';
121+
flags += mcpp::build::include_token(d, inc);
114122
}
115123
// #249: after-dirs are searched AFTER the toolchain's system dirs
116124
// (-idirafter, gcc+clang), so a dep source root that contains a file
@@ -126,8 +134,10 @@ std::string local_include_flags(const CompileUnit& cu, bool msvcDialect) {
126134
// `-idirafter<p>` as its `-i` option with value `dirafter<p>` —
127135
// a silently wrong search dir — so nasm units get plain -I.
128136
for (auto const& inc : cu.localIncludeDirsAfter) {
129-
flags += nasmUnit ? " -I" : (msvcDialect ? " /I" : " -idirafter");
130-
flags += escape_flag_path(inc);
137+
std::string_view pfx =
138+
nasmUnit ? "-I" : (msvcDialect ? "/I" : "-idirafter");
139+
flags += ' ';
140+
flags += mcpp::build::include_token(d, inc, pfx);
131141
}
132142
return flags;
133143
}
@@ -994,7 +1004,7 @@ std::string emit_ninja_string(const BuildPlan& plan) {
9941004
append(std::format("build {} : cxx_scan {}{}\n", escape_ninja_path(ddi),
9951005
escape_ninja_path(cu.source), stagedOrderOnly));
9961006
append(std::format(" compile_target = {}\n", escape_ninja_path(cu.object)));
997-
if (auto includes = local_include_flags(cu, msvcDeps); !includes.empty())
1007+
if (auto includes = local_include_flags(cu, dial); !includes.empty())
9981008
append(std::format(" local_includes ={}\n", includes));
9991009
if (auto flags = join_flags(cu.packageCxxflags); !flags.empty())
10001010
append(std::format(" unit_cxxflags ={}\n", flags));
@@ -1076,7 +1086,7 @@ std::string emit_ninja_string(const BuildPlan& plan) {
10761086
} else {
10771087
out_line += stagedOrderOnly + "\n";
10781088
}
1079-
if (auto includes = local_include_flags(cu, msvcDeps); !includes.empty())
1089+
if (auto includes = local_include_flags(cu, dial); !includes.empty())
10801090
out_line += " local_includes =" + includes + "\n";
10811091
if (is_gas_source(cu.source) || is_nasm_source(cu.source)) {
10821092
if (auto flags = join_flags(asm_unit_flags(cu)); !flags.empty())
@@ -1128,7 +1138,7 @@ std::string emit_ninja_string(const BuildPlan& plan) {
11281138
out_line += " |" + implicit;
11291139
out_line += stagedOrderOnly;
11301140
out_line += "\n";
1131-
if (auto includes = local_include_flags(cu, msvcDeps); !includes.empty())
1141+
if (auto includes = local_include_flags(cu, dial); !includes.empty())
11321142
out_line += " local_includes =" + includes + "\n";
11331143
if (is_gas_source(cu.source) || is_nasm_source(cu.source)) {
11341144
if (auto flags = join_flags(asm_unit_flags(cu)); !flags.empty())

src/platform/process.cppm

Lines changed: 92 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -128,12 +128,48 @@ int run_passthrough(std::string_view command,
128128
// a wait-status word requiring WIFEXITED/WEXITSTATUS unwrapping.
129129
int extract_exit_code(int raw_status);
130130

131+
// ─── Windows command-line shaping (host-independent, for testing) ─────────
132+
//
133+
// `cmd.exe /c <string>` applies a quote rule that silently mangles most
134+
// command lines (`cmd /?`, /C section): unless the whole string is exactly
135+
// one quoted executable name, cmd removes the FIRST character and the LAST
136+
// quote character, then runs the remainder. A correctly quoted line like
137+
//
138+
// "C:\Program Files\gcc\g++.exe" -c "main.cpp"
139+
//
140+
// therefore arrives as
141+
//
142+
// C:\Program Files\gcc\g++.exe" -c "main.cpp
143+
//
144+
// The fix is to hand cmd an outer pair to consume. These two functions build
145+
// exactly that shape and are compiled on every platform so the rule can be
146+
// unit-tested from Linux/macOS — the Windows branch below is otherwise
147+
// unreachable in every environment mcpp is normally developed on, which is
148+
// how the unquoted-argv[0] bug survived.
149+
std::string windows_command_from_argv(const std::vector<std::string>& argv);
150+
std::string windows_wrap_for_cmd_c(std::string_view cmd);
151+
131152
} // namespace mcpp::platform::process
132153

133154
// ─── Implementation ──────────────────────────────────────────────────────
134155

135156
namespace mcpp::platform::process {
136157

158+
// Host-independent (see the declarations): always the Windows shape.
159+
std::string windows_command_from_argv(const std::vector<std::string>& argv) {
160+
if (argv.empty()) return "";
161+
std::string cmd = mcpp::platform::shell::quote_windows(argv[0]);
162+
for (std::size_t i = 1; i < argv.size(); ++i) {
163+
cmd += ' ';
164+
cmd += mcpp::platform::shell::quote_windows(argv[i]);
165+
}
166+
return cmd;
167+
}
168+
169+
std::string windows_wrap_for_cmd_c(std::string_view cmd) {
170+
return "\"" + std::string(cmd) + "\"";
171+
}
172+
137173
namespace {
138174

139175
// Append a non-interactive stdin redirect to prevent child processes from
@@ -151,6 +187,44 @@ std::string seal_stdin(std::string_view cmd) {
151187
#endif
152188
}
153189

190+
// Everything that reaches _popen / std::system on Windows is run by
191+
// `cmd.exe /c <string>`, and cmd applies a quote rule that mangles any
192+
// command line carrying more than one pair of quotes (`cmd /?`, the /C
193+
// section): unless the whole string is exactly one quoted executable name,
194+
// cmd strips the FIRST character and the LAST quote character and runs what
195+
// is left. So
196+
//
197+
// "C:\Program Files\gcc\g++.exe" -c "main.cpp"
198+
//
199+
// becomes
200+
//
201+
// C:\Program Files\gcc\g++.exe" -c "main.cpp
202+
//
203+
// which is why command_from_argv used to leave argv[0] unquoted — the
204+
// program path then survived, at the cost of breaking as soon as it
205+
// contained a space, which every default install path does
206+
// (`C:\Program Files\...`, or any user whose account name has a space).
207+
//
208+
// The documented fix is to give cmd an outer pair to eat, so the inner
209+
// quoting arrives intact. Applied at the single point where a command
210+
// string becomes a child process, so no caller has to remember it, and the
211+
// redirects appended by seal_stdin / silent_redirect stay inside the wrap
212+
// where cmd still parses them after stripping.
213+
std::string wrap_for_cmd_c(std::string_view cmd) {
214+
#if defined(_WIN32)
215+
return windows_wrap_for_cmd_c(cmd);
216+
#else
217+
return std::string(cmd);
218+
#endif
219+
}
220+
221+
// Seal stdin AND wrap. Kept separate from wrap_for_cmd_c because run_exec
222+
// deliberately inherits stdio — `mcpp run` hands the terminal to the program
223+
// being run, and sealing its stdin would break every interactive one.
224+
std::string finalize_shell_command(std::string_view cmd) {
225+
return wrap_for_cmd_c(seal_stdin(cmd));
226+
}
227+
154228
int normalize_exit_code(int rc) {
155229
#if defined(_WIN32)
156230
return rc;
@@ -227,22 +301,27 @@ std::string spawn_failure(std::string_view program, int error) {
227301
}
228302
#else
229303
// Build a shell command line from an argv vector (Windows + residual non-POSIX
230-
// fallback only; Linux/macOS exec directly, #248). The first token (program)
231-
// is kept RAW on Windows — quoting it would make cmd.exe's `/c "..."` strip the
232-
// outer quotes and mangle the path (see platform.shell) — and shell-quoted
233-
// otherwise. Remaining args are always shell-quoted.
304+
// fallback only; Linux/macOS exec directly, #248). EVERY token is shell-quoted,
305+
// including the program — a payload under `C:\Program Files\...` or a home
306+
// directory with a space in the user name is otherwise cut at the first space
307+
// and reported as `'C:\Program' is not recognized`.
308+
//
309+
// argv[0] used to be left raw here to survive cmd.exe's /c quote stripping.
310+
// That traded one bug for another; finalize_shell_command now feeds cmd the
311+
// outer quote pair it insists on eating, so the quoting below arrives intact.
234312
std::string command_from_argv(const std::vector<std::string>& argv) {
235-
if (argv.empty()) return "";
236313
#if defined(_WIN32)
237-
std::string cmd = argv[0];
314+
// One derivation: the tested, host-independent shaper above.
315+
return windows_command_from_argv(argv);
238316
#else
317+
if (argv.empty()) return "";
239318
std::string cmd = mcpp::platform::shell::quote(argv[0]);
240-
#endif
241319
for (std::size_t i = 1; i < argv.size(); ++i) {
242320
cmd += ' ';
243321
cmd += mcpp::platform::shell::quote(argv[i]);
244322
}
245323
return cmd;
324+
#endif
246325
}
247326
#endif
248327

@@ -253,7 +332,7 @@ int extract_exit_code(int raw_status) {
253332
}
254333

255334
RunResult capture(std::string_view command) {
256-
auto cmd = seal_stdin(command);
335+
auto cmd = finalize_shell_command(command);
257336
RunResult result;
258337

259338
std::FILE* fp = ::popen(cmd.c_str(), "r");
@@ -306,14 +385,14 @@ RunResult capture_with_env(
306385
}
307386

308387
int run_silent(std::string_view command) {
309-
auto cmd = seal_stdin(command);
388+
auto cmd = finalize_shell_command(command);
310389
return normalize_exit_code(std::system(cmd.c_str()));
311390
}
312391

313392
int run_streaming(std::string_view command,
314393
std::function<void(std::string_view line)> on_line)
315394
{
316-
auto cmd = seal_stdin(command);
395+
auto cmd = finalize_shell_command(command);
317396
std::FILE* fp = ::popen(cmd.c_str(), "r");
318397
if (!fp) return -1;
319398

@@ -342,7 +421,7 @@ int run_streaming(std::string_view command,
342421
}
343422

344423
int run_passthrough(std::string_view command, std::string* output) {
345-
auto cmd = seal_stdin(command);
424+
auto cmd = finalize_shell_command(command);
346425
std::FILE* fp = ::popen(cmd.c_str(), "r");
347426
if (!fp) return -1;
348427

@@ -397,7 +476,8 @@ int run_exec(const std::vector<std::string>& argv,
397476
return normalize_exit_code(status);
398477
#else
399478
std::string prefix = mcpp::platform::env::build_env_prefix(extraEnv);
400-
std::string cmd = prefix + command_from_argv(argv);
479+
// wrap only — run_exec inherits stdio on purpose (see finalize_shell_command).
480+
std::string cmd = wrap_for_cmd_c(prefix + command_from_argv(argv));
401481
return normalize_exit_code(std::system(cmd.c_str()));
402482
#endif
403483
}

src/platform/shell.cppm

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ export namespace mcpp::platform::shell {
2020
// Platform-aware shell argument quoting.
2121
std::string quote(std::string_view s);
2222

23+
// The two halves of `quote`, callable regardless of host. Exposed so the
24+
// Windows command-line shape can be built and unit-tested from any platform
25+
// — the cmd.exe quoting rules are the easiest thing in mcpp to get wrong and
26+
// the hardest to notice, since a Linux/macOS run never executes that code.
27+
std::string quote_windows(std::string_view s);
28+
std::string quote_posix(std::string_view s);
29+
2330
// Silent redirect — stdout + stderr → /dev/null (or NUL on Windows).
2431
// stdin is NOT touched here; that's the responsibility of
2532
// mcpp::platform::process::seal_stdin, which is auto-applied by capture /
@@ -36,25 +43,36 @@ constexpr std::string_view silent_redirect = ">/dev/null 2>&1";
3643

3744
namespace mcpp::platform::shell {
3845

39-
std::string quote(std::string_view s) {
46+
std::string quote_windows(std::string_view s) {
4047
std::string out;
4148
out.reserve(s.size() + 2);
42-
#if defined(_WIN32)
4349
out.push_back('"');
4450
for (char c : s) {
4551
if (c == '"') out += "\\\"";
4652
else out.push_back(c);
4753
}
4854
out.push_back('"');
49-
#else
55+
return out;
56+
}
57+
58+
std::string quote_posix(std::string_view s) {
59+
std::string out;
60+
out.reserve(s.size() + 2);
5061
out.push_back('\'');
5162
for (char c : s) {
5263
if (c == '\'') out += "'\\''";
5364
else out.push_back(c);
5465
}
5566
out.push_back('\'');
56-
#endif
5767
return out;
5868
}
5969

70+
std::string quote(std::string_view s) {
71+
#if defined(_WIN32)
72+
return quote_windows(s);
73+
#else
74+
return quote_posix(s);
75+
#endif
76+
}
77+
6078
} // namespace mcpp::platform::shell

0 commit comments

Comments
 (0)