[PATCH v3 0/1] MR9943: msiexec: Fix escaping quoted arguments passed from Shell.
Properties with spaces passed to `msiexec` from shells like Bash may result in re-escaping the string. This results in wrong new quote position and so in MSI error `ERROR_INVALID_COMMAND_LINE`. This patch moves the quote back to its legal position. Step-by-step behavior after fixes in commit `14b718b69bb8d` (linked with Wine bug https://bugs.winehq.org/show_bug.cgi?id=57024): 1. Shell input: wine msiexec /i some.msi INSTALLDIR="C:\\Path with spaces\\data" 2. Shell args parser may remove double quote escaping: args\[3\] == "some.msi" args\[4\] == "INSTALLDIR=C:\\Path with spaces\\data" 3. `GetCommandLineW()` returns string with quote moved to the start: "msiexec ... some.msi "INSTALLDIR=C:\\Path with spaces\\data"" 4. Then MSI parser truncates beginning of the string up to character '=', leading to unbalanced quotes in result value: C:\\Path with spaces\\data" 5. MSI fails with error `ERROR_INVALID_COMMAND_LINE` (1639). Wine-Bug: https://bugs.winehq.org/show_bug.cgi?id=57024 Tests in bug 57024 are fine to reproduce the behavior, but note that `system()` and `execvp()` function passes their input on without extra parsing (without item 2 in the fail trace algorithm above). An example of such test: ``` execlp("msiexec", "msiexec", "/i", msi_path, "/qn", "\"MY_PROPERTY=foo bar\"", NULL); ``` -- v3: msiexec: Fix escaping quoted arguments passed from Shell. https://gitlab.winehq.org/wine/wine/-/merge_requests/9943
From: Yaroslav Osipov <mcm@etersoft.ru> Properties with spaces passed to `msiexec` from shells like Bash may result in re-escaping the string. This results in wrong new quote position and so in MSI error `ERROR_INVALID_COMMAND_LINE`. This patch moves the quote back to its legal position. Step-by-step behavior after fixes in commit `14b718b69bb8d` (linked with Wine bug https://bugs.winehq.org/show_bug.cgi?id=57024): 1. Shell input: wine msiexec /i some.msi INSTALLDIR="C:\\Path with spaces\\data" 2. Shell args parser may remove double quote escaping: args[3] == "some.msi" args[4] == "INSTALLDIR=C:\\Path with spaces\\data" 3. `GetCommandLineW()` returns string with quote moved to the start: "msiexec ... some.msi \"INSTALLDIR=C:\\Path with spaces\\data\"" 4. Then MSI parser truncates beginning of the string up to character '=', leading to unbalanced quotes in result value: C:\\Path with spaces\\data" 5. MSI fails with error `ERROR_INVALID_COMMAND_LINE` (1639). Wine-Bug: https://bugs.winehq.org/show_bug.cgi?id=57024 Tests in bug 57024 are fine to reproduce the behavior, but note that `system()` and `execvp()` function passes their input on without extra parsing (without item 2 in the fail trace algorithm above). An example of such test: execlp("msiexec", "msiexec", "/i", msi_path, "/qn", "\"MY_PROPERTY=foo bar\"", NULL); --- programs/msiexec/msiexec.c | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/programs/msiexec/msiexec.c b/programs/msiexec/msiexec.c index b4fe9a7e6e8..192663f125e 100644 --- a/programs/msiexec/msiexec.c +++ b/programs/msiexec/msiexec.c @@ -139,7 +139,7 @@ static LPWSTR build_properties(struct string_list *property_list) struct string_list *list; LPWSTR ret, p, value; DWORD len; - BOOL needs_quote; + BOOL needs_quote, needs_move_first_quote; if(!property_list) return NULL; @@ -160,13 +160,21 @@ static LPWSTR build_properties(struct string_list *property_list) continue; len = value - list->str; *p++ = ' '; - memcpy(p, list->str, len * sizeof(WCHAR)); - p += len; + + /* In props like " \"INSTALLDIR=path\" ", move first quote after the '=' (with a result + of " INSTALLDIR=\"path\" ") to save the quotes balance when MSI parser cuts off + characters up to first '='. */ + needs_move_first_quote = (*list->str == '"'); + + memcpy(p, list->str + needs_move_first_quote, (len - needs_move_first_quote) * sizeof(WCHAR)); + p += len - needs_move_first_quote; *p++ = '='; + if(needs_move_first_quote) + *p++ = '\"'; /* check if the value contains spaces and maybe quote it */ value++; - needs_quote = *value != '"' && wcschr(value, ' '); + needs_quote = (!needs_move_first_quote && *value != '"' && wcschr(value, ' ')); if(needs_quote) *p++ = '"'; len = lstrlenW(value); -- GitLab https://gitlab.winehq.org/wine/wine/-/merge_requests/9943
On Mon Jan 26 09:37:44 2026 +0000, Hans Leidekker wrote:
I doubt that native msiexec handles the command line like this. To avoid bash moving quotes you can pipe the command to cmd: ``` $ echo 'msiexec /i some.msi INSTALLDIR="C:\Path with spaces\data"' | wine cmd ``` I re-checked behavior on Windows and Wine and agree that current Wine-msiexec implementation (not in my branch) is better in emulating the original program.
The solution proposed is actually good, I didn't know that stdin could be used in that way. One small, but sometimes critical, issue that you cannot receive exit code other than "0" from `cmd msiexec` because, as far as I understand, `cmd` ends immediately, without waiting for the spawned subprocess. Should I close this merge request? -- https://gitlab.winehq.org/wine/wine/-/merge_requests/9943#note_127993
One small, but sometimes critical, issue that you cannot receive exit code other than "0" from `cmd msiexec` because, as far as I understand, `cmd` ends immediately, without waiting for the spawned subprocess.
that's normal behavior for cmd not no wait for a program in windows subsystem (which msiexec is) (untested) this may do what you expect (wait for program termination and return msiexec exit code) `echo 'start /wait /b msiexec /i some.msi INSTALLDIR="C:\Path with spaces\data"' | wine cmd` -- https://gitlab.winehq.org/wine/wine/-/merge_requests/9943#note_127994
On Mon Jan 26 09:54:25 2026 +0000, eric pouech wrote:
One small, but sometimes critical, issue that you cannot receive exit code other than "0" from `cmd msiexec` because, as far as I understand, `cmd` ends immediately, without waiting for the spawned subprocess. that's normal behavior for cmd not no wait for a program in windows subsystem (which msiexec is) (untested) this may do what you expect (wait for program termination and return msiexec exit code) `echo 'start /wait /b msiexec /i some.msi INSTALLDIR="C:\Path with spaces\data"' | wine cmd` Thank you! I found the next solution:
```sh echo 'start /wait /b msiexec /p some.msi INSTALLDIR="C:\Path with spaces\data" & exit !errorlevel!' | wine cmd /v:on MSIEXEC_ERRORCODE=$? ``` "`/v:on`" functions similar to `SETLOCAL EnableDelayedExpansion`, so `exit !errorlevel!` will exit with the error code of msiexec. -- https://gitlab.winehq.org/wine/wine/-/merge_requests/9943#note_128534
On Mon Feb 2 11:36:56 2026 +0000, Yaroslav Osipov wrote:
Thank you! I found the next solution: ```sh echo 'start /wait /b msiexec /p some.msi INSTALLDIR="C:\Path with spaces\data" & exit !errorlevel!' | wine cmd /v:on MSIEXEC_ERRORCODE=$? ``` "`/v:on`" functions similar to `SETLOCAL EnableDelayedExpansion`, so `exit !errorlevel!` will exit with the error code of msiexec. hmm looks like a bug in cmd.exe implementation. quick tests on win10 show that native cmd.exe's exit code (from a process point of view) is latest errorlevel
-- https://gitlab.winehq.org/wine/wine/-/merge_requests/9943#note_128539
On Mon Feb 2 12:17:22 2026 +0000, eric pouech wrote:
hmm looks like a bug in cmd.exe implementation. quick tests on win10 show that native cmd.exe's exit code (from a process point of view) is latest errorlevel retested carefully, and exit code is not propagated (sorry for the false hope)
you should simplify a bit though with (untested in full) ```shell echo 'start /wait /b msiexec /p some.msi INSTALLDIR="C:\Path with spaces\data"' | wine cmd /k ``` (`cmd` doesn't propagate last errorlevel, while `cmd /k `does...) -- https://gitlab.winehq.org/wine/wine/-/merge_requests/9943#note_128566
On Mon Feb 2 16:56:14 2026 +0000, eric pouech wrote:
retested carefully, and exit code is not propagated (sorry for the false hope) you should simplify a bit though with (untested in full) ```shell echo 'start /wait /b msiexec /p some.msi INSTALLDIR="C:\Path with spaces\data"' | wine cmd /k ``` (`cmd` doesn't propagate last errorlevel, while `cmd /k `does...) Sorry for the late answer... I found some interesting things for this issue. I'm not absolutely sure this is not intended, but I see that all proxy programs (including `cmd /k "..."` or `start "..."`) preserve the quotes, which leads to double-escaping. The entire msiexec parser is built around the case without quotes, it adds escaping itself. It treat args in `cmdline` as a string without quotes, and does not check whether the quotes are already in place.
I inserted a dump of the input arguments along the call chain and eventually reached `__wine_main()` in "dlls/ntdll/unix/loader.c" (characters `«…»` are also quotes in some languages): ``` for ( int i = 1; i < argc; i++ ) printf( "__wine_main: argv[%i] == «%s»\n", i, main_argv[ i ] ); ``` I made dumps of `msiexec.exe` output when called via several methods with the same parameters. Note the quotes in "\_\_wine_main() argv[5] ==" in logs below. Similar output with "unbalanced quotes" in: - Shell input like `wine msiexec "/i" "Z:\\tmp\\winehq-57024_my_property.msi" "/qn" 'MY_PROPERTY="C:\\Program Files\\MyApp"'` - `echo 'msiexec' | wine cmd /k` - `echo 'start /wait msiexec' | wine cmd /k` - `cmd /k "msiexec"` in \*.bat file - `start /wait "msiexec"` in \*.bat file: ``` __wine_main() argv[0] == «/tmp/winehq-build/loader/wine» __wine_main() argv[1] == «C:\windows\system32\msiexec.exe» __wine_main() argv[2] == «/i» __wine_main() argv[3] == «Z:\tmp\winehq-57024_my_property.msi» __wine_main() argv[4] == «/qn» __wine_main() argv[5] == «MY_PROPERTY="C:\\Program Files\\MyApp"» fixme:msiexec:process_args cmdline == L"\"C:\\windows\\system32\\msiexec.exe\" /i Z:\\tmp\\winehq-57024_my_property.msi /qn \"MY_PROPERTY=\\\"C:\\\\Program Files\\\\MyApp\\\"\"" trace:msiexec:WinMain argvW[1] = L"/i" trace:msiexec:WinMain argvW[2] = L"Z:\\tmp\\winehq-57024_my_property.msi" trace:msiexec:WinMain argvW[3] = L"/qn" trace:msiexec:WinMain argvW[4] = L"\"MY_PROPERTY=\\\"" trace:msiexec:WinMain argvW[5] = L"C:\\\\Program" trace:msiexec:WinMain argvW[6] = L"Files\\\\MyApp\\\"\"" trace:msiexec:build_properties properties -> L" \"MY_PROPERTY=\\\"" warn:msi:msi_parse_command_line unbalanced quotes ``` But the log differs when calling: - `msiexec` via `execvp()` (and similar WinAPI functions? Checked with [generaltest_execvp-user-defined-prop.c](/uploads/e871021d437a8c04fc4a91f866f91661/generaltest_execvp-user-defined-prop.c), see below) - `msiexec` from \*.bat file directly: ``` __wine_main() argv[0] == «/tmp/winehq-build/loader/wine» __wine_main() argv[1] == «msiexec» __wine_main() argv[2] == «/i» __wine_main() argv[3] == «Z:\tmp\winehq-57024_my_property.msi» __wine_main() argv[4] == «/qn» __wine_main() argv[5] == «MY_PROPERTY=C:\Program Files\MyApp» fixme:msiexec:process_args cmdline == L"msiexec /i Z:\\tmp\\winehq-57024_my_property.msi /qn MY_PROPERTY=\"C:\\Program Files\\MyApp\"" trace:msiexec:WinMain argvW[1] = L"/i" trace:msiexec:WinMain argvW[2] = L"Z:\\tmp\\winehq-57024_my_property.msi" trace:msiexec:WinMain argvW[3] = L"/qn" trace:msiexec:WinMain argvW[4] = L"MY_PROPERTY=\"C:\\Program Files\\MyApp\"" trace:msiexec:build_properties properties -> L" MY_PROPERTY=\"C:\\Program Files\\MyApp\"" ``` So the first difference is in the `__wine_main()` 5th arg: ``` Shell and proxy programs : __wine_main() argv[5] == «MY_PROPERTY="C:\\Program Files\\MyApp"» execvp() or *.bat direct call: __wine_main() argv[5] == «MY_PROPERTY=C:\Program Files\MyApp» ``` <br> <br> Program with `execvp()`: [generaltest_execvp-user-defined-prop.c](/uploads/e871021d437a8c04fc4a91f866f91661/generaltest_execvp-user-defined-prop.c). On Windows I compile it via Cygwin without any special flags: ``` x86_64-w64-mingw32-gcc.exe generaltest_execvp-user-defined-prop.c -o generaltest_execvp.exe -Wall -Wextra -Wpedantic ``` "winehq-57024_my_property.msi" can be found in [bug 57024, comment 1](https://bugs.winehq.org/show_bug.cgi?id=57024#c1). The program has extra output of its own input arguments. -- https://gitlab.winehq.org/wine/wine/-/merge_requests/9943#note_129997
participants (3)
-
eric pouech (@epo) -
Yaroslav Osipov -
Yaroslav Osipov (@mcm)