Close the remaining installer review findings
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s

Eleven findings, grouped by the root cause each belongs to.

Wizard input reaching a command line unchecked (ShopDBFlask.iss). Port fields
were spliced in bare and arrive as [int] parameters, so a blank or mistyped
port shifted every argument after it; both port fields are now validated as
1-65535 digits. A path ending in a backslash, which is what a drive root looks
like, ended its argument with \" and CommandLineToArgvW read that as an escaped
quote, so paths are now quoted through a helper that doubles the trailing
backslash. A drive root is refused outright as well: uninstall deletes the
application directory recursively, so installing to D:\ would have wiped the
drive on removal. The password handoff was written with SaveStringToFile, which
writes an AnsiString, and read back as UTF-8, so a correct non-ASCII password
was reported as wrong; it now goes out as UTF-8 without a BOM.

Launching without checking the result. Plugin deregistration invoked "flask
plugin uninstall" without --yes, and the command carries a click
confirmation_option that aborts with exit 1 when nothing can answer the prompt,
so it could never once have succeeded; the bare 2>&1 under EAP Stop then turned
that into a terminating error which the catch downgraded to a warning while the
plugin directory was deleted regardless. It now passes --yes, brackets the
error preference, restores the location in a finally, and keeps the code on
disk unless deregistration actually succeeded. MarkShortcutRunAs had four
quotes where it needed three, which kept the whole command inside one Pascal
literal so LnkPath was never interpolated and no shortcut ever got the
elevation flag; its exit code is now logged too.

Comparing IIS physical paths as raw strings. IIS stores the path as typed, so
it may carry environment variables or a trailing backslash. A Test-SamePath
helper now normalises both sides. That closes a real hazard in uninstall, which
matched applications on alias alone and would remove an unrelated application
of the same name under another site, unattended, since -OnFailure never
suppresses the confirmation.

Accepting existing IIS state without reconciling it. "Site already exists" took
the site however it was, so re-running with a different port left the old
binding while CORS_ORIGINS, the firewall rule and the smoke test all used the
new one, failing a working server. It now refuses with both ports named rather
than silently re-binding, and refuses a site of that name serving a different
directory.

Preflight rows drawn past the panel. The failures loop had no cap at all and
the warnings loop capped at 6, a number unrelated to the panel, which holds
about three rows. The cap is now measured from the panel height, applies to
both loops, and the footer counts what was actually left out instead of
inferring it.

Also: a failed upgrade now says the application pool is still stopped and how
to start it, rather than only "part-configured", since stage 2 stops a pool
that was serving. It is deliberately not restarted automatically, because after
a stage 3 failure the deployed code and the schema may disagree. shopdb-admin
Restart-App starts a stopped pool or site instead of recycling, which is a
no-op on a stopped pool and then reported the application as unresponsive. A
dead Write-Log line that parsed as three arguments is gone, and a preflight
warning no longer tells the operator to add a directory to a compiled exe.
This commit is contained in:
cproudlock
2026-08-04 21:16:46 -04:00
parent ce521e84a5
commit 1c04ff28b9
4 changed files with 251 additions and 24 deletions

View File

@@ -655,7 +655,7 @@ end;
// If nothing is wrong the page collapses to a single reassuring statement.
procedure RenderPreflight(Lines: TArrayOfString; var HasBlockers: Boolean);
var
I, Y, Shown, Fails, Warns, Passes: Integer;
I, Y, Shown, Fails, Warns, Passes, RowHeight, MaxRows, Hidden: Integer;
Parts: TArrayOfString;
Check, Detail, Fix: String;
begin
@@ -663,6 +663,16 @@ begin
Y := ScaleY(4);
Shown := 0;
// How many rows actually FIT, measured, not guessed. The old cap of 6 bore no
// relation to the panel, which holds roughly three: at four or five notes the
// rows below were drawn past the bottom edge and silently vanished - taking
// with them the warning that the bundled MySQL collides on port 3306, which is
// exactly what the operator needs before the very next page.
RowHeight := ScaleY(46) + ScaleY(ROW_GAP);
if RowHeight < 1 then RowHeight := 1;
MaxRows := DetailPanel.Height div RowHeight;
if MaxRows < 1 then MaxRows := 1;
// Blockers first, then warnings - a second pass rather than one, so severity
// ordering does not depend on the order the checks happen to run in.
for I := 0 to GetArrayLength(Lines) - 1 do
@@ -676,6 +686,9 @@ begin
for I := 0 to GetArrayLength(Lines) - 1 do
begin
// Blockers are capped too. Without this the FAIL loop drew every failure,
// however many, straight off the bottom of the panel.
if Shown >= MaxRows then Break;
Parts := StringSplit(Lines[I], ['|'], stAll);
if GetArrayLength(Parts) < 4 then Continue;
if Parts[0] <> 'FAIL' then Continue;
@@ -690,7 +703,7 @@ begin
for I := 0 to GetArrayLength(Lines) - 1 do
begin
if Shown >= 6 then Break;
if Shown >= MaxRows then Break;
Parts := StringSplit(Lines[I], ['|'], stAll);
if GetArrayLength(Parts) < 4 then Continue;
if Parts[0] <> 'WARN' then Continue;
@@ -727,8 +740,13 @@ begin
+ 'verified. Nothing needs your attention.');
end;
if (Shown >= 6) and (Warns > 6 - Fails) then
FooterText.Caption := 'Some notes are not shown. The full check is in the install log.'
// Count what was actually left out rather than inferring it from the old cap,
// which under-reported: with 2 failures and 5 warnings it claimed everything
// was shown while four rows had been dropped.
Hidden := (Fails + Warns) - Shown;
if Hidden > 0 then
FooterText.Caption := IntToStr(Hidden) + ' further item(s) are not shown here. '
+ 'All of them are in the install log.'
else
FooterText.Caption := IntToStr(Passes) + ' checks passed. Nothing has been changed on this server.';
end;
@@ -837,10 +855,76 @@ begin
Log('[shopdb] pre-fill base directory: ' + Result);
end;
// Everything typed in the wizard ends up on a command line that
// CommandLineToArgvW parses, and reaches PowerShell parameters typed [int] or
// [string]. A blank, a space, or a trailing backslash therefore has to be caught
// HERE - by the time the script sees it, the arguments have already shifted.
function IsValidPort(const S: String): Boolean;
var
I, N: Integer;
begin
Result := False;
if (S = '') or (Length(S) > 5) then Exit;
for I := 1 to Length(S) do
if (S[I] < '0') or (S[I] > '9') then Exit; // also rejects spaces and signs
N := StrToIntDef(S, -1);
Result := (N >= 1) and (N <= 65535);
end;
function IsAsciiOnly(const S: String): Boolean;
var
I: Integer;
begin
Result := True;
for I := 1 to Length(S) do
if Ord(S[I]) > 126 then
begin
Result := False;
Exit;
end;
end;
// A path that ends in a backslash - a drive root such as D:\ - closes the
// argument with \" , which CommandLineToArgvW reads as an ESCAPED quote. The
// argument never terminates and every argument after it shifts one place.
// Doubling the backslash inside the quotes is the documented way out.
function QuotePathArg(const P: String): String;
begin
if (P <> '') and (P[Length(P)] = '\') then
Result := '"' + P + '\' + '"'
else
Result := '"' + P + '"';
end;
function NextButtonClick(CurPageID: Integer): Boolean;
begin
Result := True;
// A drive root is refused outright rather than quoted around. Uninstall
// removes the application directory recursively, so accepting D:\ here would
// mean uninstalling ShopDB-Flask wipes the whole drive.
if CurPageID = wpSelectDir then
begin
if Length(WizardDirValue) <= 3 then
begin
MsgBox('Choose a folder rather than a whole drive.' + #13#10#13#10
+ 'Removing ShopDB-Flask deletes the folder it was installed into, '
+ 'so installing to a drive root would delete everything on that '
+ 'drive when it is uninstalled.', mbError, MB_OK);
Result := False;
end;
end;
if CurPageID = SitePage.ID then
begin
if not IsValidPort(SitePage.Values[1]) then
begin
MsgBox('Enter the port as a number between 1 and 65535.', mbError, MB_OK);
Result := False;
end;
end;
if CurPageID = wpWelcome then
RunPreflight;
@@ -872,6 +956,13 @@ begin
begin
MsgBox('Enter the database host.', mbError, MB_OK);
Result := False;
end
else if not IsValidPort(DbDetailsPage.Values[1]) then
begin
MsgBox('Enter the database port as a number between 1 and 65535.'
+ #13#10#13#10 + 'MySQL uses 3306 unless it was changed.',
mbError, MB_OK);
Result := False;
end;
end;
end;
@@ -1056,22 +1147,31 @@ var
Cmd: String;
begin
if not FileExists(LnkPath) then Exit;
// THREE quotes each side, not four. Four is an escaped quote twice over, which
// keeps the whole expression inside one Pascal literal: LnkPath was never
// interpolated, PowerShell got the bare word "LnkPath", failed to parse, and
// no shortcut ever received the elevation flag. Self-elevation hid it - the
// only symptom was a second window after an extra prompt.
Cmd := '-NoProfile -ExecutionPolicy Bypass -Command "'
+ '$p='''' + LnkPath + ''''; '
+ '$p=''' + LnkPath + '''; '
+ '$b=[IO.File]::ReadAllBytes($p); '
+ '$b[21]=$b[21] -bor 0x20; '
+ '[IO.File]::WriteAllBytes($p,$b)"';
Exec(PowerShellPath, Cmd, '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
if ResultCode <> 0 then
Log('[shopdb] could not mark ' + LnkPath + ' run-as-administrator (exit '
+ IntToStr(ResultCode) + '); the console will self-elevate instead');
end;
function RunInstallStages: String;
var
ResultCode: Integer;
PwFile, Args, Common: String;
PwLines: TArrayOfString;
begin
Result := '';
Common := '-BundleRoot "' + ExpandConstant('{tmp}\shopdb-bundle') + '"' +
' -AppRoot "' + ExpandConstant('{app}') + '"' +
Common := '-BundleRoot ' + QuotePathArg(ExpandConstant('{tmp}\shopdb-bundle')) +
' -AppRoot ' + QuotePathArg(ExpandConstant('{app}')) +
' -SiteHost "' + SitePage.Values[0] + '"' +
' -SitePort ' + SitePage.Values[1] +
' -OnFailure never' +
@@ -1110,7 +1210,13 @@ begin
if DbCredsPage.Values[1] <> '' then
begin
PwFile := ExpandConstant('{tmp}\dbpw.txt');
SaveStringToFile(PwFile, DbCredsPage.Values[1] + #13#10, False);
// UTF-8, no BOM. SaveStringToFile writes an AnsiString, but the installer
// reads this back with -Encoding UTF8 - so any non-ASCII character in the
// password came out mangled and the database rejected a password that was
// typed correctly, reported as "wrong username or password".
SetArrayLength(PwLines, 1);
PwLines[0] := DbCredsPage.Values[1];
SaveStringsToUTF8FileWithoutBOM(PwFile, PwLines, False);
end
else
PwFile := '';