fix(installer): bundled MySQL install failed on a malformed msiexec command line

Reported from a Windows Server 2019 test: a "Windows Installer" dialog listing
every msiexec /Option appeared, then the wizard reported that the bundled MySQL
database could not be installed. That dialog is msiexec's usage help - it prints
it when the command line does not parse - so the install never started.

Cause: $MysqlRoot defaulted to 'C:\Program Files\MySQL\MySQL Server 8.4', which
contains spaces. Invoke-Native wraps any argument containing whitespace in
quotes, producing "INSTALLDIR=C:\Program Files\...". msiexec takes public
properties as PROPERTY=value and expects the VALUE quoted -
INSTALLDIR="C:\Program Files\..." - so it rejected the line, printed usage, and
exited non-zero.

This file already carried the rule, next to the Python target: "Never put a
space in a path this installer controls." I broke it setting the 8.4 path.

Two fixes, because one of them alone leaves the trap in place:

- $MysqlRoot is now C:\MySQL84, space-free like C:\Python314. The MySQL client
  search paths in the installer, the preflight and the operator console all look
  there first, keeping backups working against the bundled server.
- Invoke-Native now quotes PROPERTY=value correctly, so passing a spaced path
  explicitly no longer produces an unparseable command line.

tests/test_installer_defaults.py fails if an installer-controlled path default
ever contains a space again.
This commit is contained in:
cproudlock
2026-08-04 12:06:36 -04:00
parent 5321649e02
commit 8d0afc40d3
4 changed files with 41 additions and 4 deletions

View File

@@ -195,6 +195,7 @@ function Get-DbParts {
function Find-MysqlClient {
$candidates = @(
'C:\MySQL84\bin\mysql.exe',
'C:\Program Files\MySQL\MySQL Server 8.4\bin\mysql.exe',
'C:\Program Files\MySQL\MySQL Server 8.0\bin\mysql.exe',
'C:\mysql56\bin\mysql.exe'

View File

@@ -57,7 +57,13 @@ param(
[ValidateSet('0','1','2','3','4','5','all','uninstall')] [string] $Stage = 'all',
# Bundled-MySQL (stage 0) settings. The root and app passwords are GENERATED,
# never supplied: see New-Secret. Root is shown once and not persisted.
[string] $MysqlRoot = 'C:\Program Files\MySQL\MySQL Server 8.4',
# SPACE-FREE, deliberately - same reason as the Python target below. msiexec
# takes public properties as PROPERTY=value on the command line, and a value
# containing spaces has to be quoted as INSTALLDIR="C:\..." - quoting the
# whole PROPERTY=value token instead makes msiexec reject the command line
# and pop its usage dialog, which is what 'the bundled MySQL database could
# not be installed' looked like from the outside.
[string] $MysqlRoot = 'C:\MySQL84',
[string] $MysqlDataDir = 'C:\ProgramData\MySQL\data',
[string] $MysqlIni = 'C:\ProgramData\MySQL\my.ini',
[string] $MysqlService = 'MySQL84',
@@ -196,7 +202,12 @@ function Invoke-Native {
# "IIS AppPool\name:(OI)(CI)RX" became the parameter "IIS" (icacls exit 87),
# and how TargetDir=C:\Program Files\... installed Python into C:\Program\.
$quoted = $Arguments | ForEach-Object {
if ($_ -match '\s' -and $_ -notmatch '^".*"$') { '"' + $_ + '"' } else { $_ }
if ($_ -notmatch '\s' -or $_ -match '^".*"$') { $_ }
# PROPERTY=value (msiexec, msbuild): the VALUE is quoted, never the whole
# token. "INSTALLDIR=C:\Program Files\..." is rejected outright, whereas
# INSTALLDIR="C:\Program Files\..." is what the tool expects.
elseif ($_ -match '^([A-Za-z_][A-Za-z0-9_]*)=(.*)$') { '{0}="{1}"' -f $Matches[1], $Matches[2] }
else { '"' + $_ + '"' }
}
$so = [System.IO.Path]::GetTempFileName()
$se = [System.IO.Path]::GetTempFileName()
@@ -409,7 +420,10 @@ function Find-MysqlTool {
)
foreach ($b in $bundled) { if (Test-Path $b) { return $b } }
$roots = @('C:\Program Files\MySQL', 'C:\mysql56\bin', 'C:\Program Files (x86)\MySQL')
# C:\MySQL84 first: that is where the bundled server installs (space-free,
# see $MysqlRoot). The Program Files paths cover a server MySQL put there
# by hand or by an older build.
$roots = @('C:\MySQL84', 'C:\Program Files\MySQL', 'C:\mysql56\bin', 'C:\Program Files (x86)\MySQL')
foreach ($r in $roots) {
if (Test-Path $r) {
$hit = Get-ChildItem $r -Filter $Name -Recurse -ErrorAction SilentlyContinue |

View File

@@ -387,7 +387,7 @@ Invoke-Check 'MySQL' 'Backup client' {
$found = ''
foreach ($root in @((Join-Path $PSScriptRoot 'mysqlclient'),
(Join-Path $AppRoot 'mysqlclient'),
'C:\Program Files\MySQL', 'C:\mysql56\bin',
'C:\MySQL84', 'C:\Program Files\MySQL', 'C:\mysql56\bin',
'C:\Program Files (x86)\MySQL')) {
if (-not (Test-Path $root)) { continue }
$hit = Get-ChildItem $root -Filter $names[0] -Recurse -ErrorAction SilentlyContinue |

View File

@@ -82,3 +82,25 @@ def test_nothing_the_bundle_supplies_is_a_blocker():
assert not offenders, (
'the installer supplies these, so they must not block the wizard:\n %s'
% '\n '.join(offenders))
def test_installer_controlled_paths_have_no_spaces():
"""msiexec and bootstrapper command lines split on spaces.
The installer passes these as PROPERTY=value / TargetDir=value on a command
line. A value containing a space has to be quoted as PROPERTY="C:\\..." -
quoting the whole token instead makes the tool reject the line and print its
usage dialog, which is what 'the bundled MySQL database could not be
installed' looked like from the outside. The Python target was already
space-free for this exact reason; MySQL was not.
"""
text = PREFLIGHT.parent.joinpath('shopdb-install.ps1').read_text(
encoding='utf-8', errors='replace')
offenders = []
for line in text.splitlines():
match = re.match(r"\s*\[string\]\s*\$(Mysql\w*|PyTarget)\s*=\s*'([^']+)'", line)
if match and ' ' in match.group(2):
offenders.append('%s = %s' % (match.group(1), match.group(2)))
assert not offenders, (
'these are passed on a command line and must not contain spaces:\n %s'
% '\n '.join(offenders))