From 8d0afc40d3299e01e06246886924a994c89b30da Mon Sep 17 00:00:00 2001 From: cproudlock Date: Tue, 4 Aug 2026 12:06:36 -0400 Subject: [PATCH] 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. --- deploy/windows/installer/shopdb-admin.ps1 | 1 + deploy/windows/installer/shopdb-install.ps1 | 20 ++++++++++++++--- deploy/windows/installer/shopdb-preflight.ps1 | 2 +- tests/test_installer_defaults.py | 22 +++++++++++++++++++ 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/deploy/windows/installer/shopdb-admin.ps1 b/deploy/windows/installer/shopdb-admin.ps1 index 9d4ad38..7702b74 100644 --- a/deploy/windows/installer/shopdb-admin.ps1 +++ b/deploy/windows/installer/shopdb-admin.ps1 @@ -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' diff --git a/deploy/windows/installer/shopdb-install.ps1 b/deploy/windows/installer/shopdb-install.ps1 index 6b444d1..b43b85a 100644 --- a/deploy/windows/installer/shopdb-install.ps1 +++ b/deploy/windows/installer/shopdb-install.ps1 @@ -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 | diff --git a/deploy/windows/installer/shopdb-preflight.ps1 b/deploy/windows/installer/shopdb-preflight.ps1 index 424afe3..de11872 100644 --- a/deploy/windows/installer/shopdb-preflight.ps1 +++ b/deploy/windows/installer/shopdb-preflight.ps1 @@ -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 | diff --git a/tests/test_installer_defaults.py b/tests/test_installer_defaults.py index a6b39f3..919b5b5 100644 --- a/tests/test_installer_defaults.py +++ b/tests/test_installer_defaults.py @@ -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))