Files
plezy/scripts/check_windows_installer.py
T
2026-08-10 20:28:41 +02:00

141 lines
4.7 KiB
Python
Executable File

#!/usr/bin/env python3
"""Guard the elevation contract in the generated Windows Inno Setup script.
The installer is generated at build time by windows/build-installer.ps1, so
there is no .iss in the tree to review. These checks pin the parts a silent
in-app update depends on: a per-user default install that can still reach a
machine-wide copy by relaunching itself elevated (issue #1705).
"""
from pathlib import Path
import re
import sys
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_SCRIPT = ROOT / "windows/build-installer.ps1"
if len(sys.argv) > 2:
raise SystemExit(f"Usage: {Path(sys.argv[0]).name} [build-installer-path]")
SCRIPT = Path(sys.argv[1]).resolve() if len(sys.argv) == 2 else DEFAULT_SCRIPT
APP_GUID = "4213385e-f7be-4f2b-95f9-54082a28bb8f"
text = SCRIPT.read_text(encoding="utf-8")
errors: list[str] = []
def require(condition: bool, message: str) -> None:
if not condition:
errors.append(message)
def template() -> str:
"""The Inno Setup script emitted by New-InnoSetupScript."""
match = re.search(r'(?ms)^ return @"\r?\n(.*?)\r?\n"@\r?\n', text)
require(match is not None, "New-InnoSetupScript must return a single here-string template")
return match.group(1) if match else ""
require(
"function New-InnoSetupScript" in text,
"the .iss must be built by New-InnoSetupScript so every architecture shares one template",
)
iss = template()
# A single template prevents architecture copies from drifting.
for once in (
r"^\[Setup\]$",
r"^\[Code\]$",
r"^PrivilegesRequired=",
r"^function InitializeSetup",
):
require(
len(re.findall(once, text, re.MULTILINE)) == 1,
f"{once} must match exactly one line; a second copy of the template will drift",
)
require(
text.count(APP_GUID) == 1,
"the AppId GUID must have a single source; AppId and the uninstall subkey both derive from it",
)
require("AppId={{$AppGuid}" in iss, "AppId must be built from the shared $AppGuid")
require(
r"Uninstall\{$AppGuid}_is1" in iss,
"the uninstall subkey must be the shared AppId with Inno's _is1 suffix",
)
require(
"OutputBaseFilename=plezy-windows-installer" in iss,
"the release asset name is referenced by the appcast, winget and the website",
)
require(
"ArchitecturesAllowed=$ArchAllowed" in iss
and "ArchitecturesInstallIn64BitMode=$ArchAllowed" in iss,
"architectures must come from the template parameter, not be hard-coded",
)
require(
"Check: IsX64" in text and "Check: IsArm64" in text,
"the dual-architecture [Files] entries must keep their architecture checks",
)
# Fresh installs stay per-user; only /ALLUSERS may trigger elevation.
require(
re.search(r"(?m)^PrivilegesRequired=lowest\s*$", iss) is not None,
"a fresh install must stay per-user; PrivilegesRequired=lowest",
)
overrides = re.search(r"(?m)^PrivilegesRequiredOverridesAllowed=(.+)$", iss)
require(
overrides is not None and "commandline" in overrides.group(1),
"PrivilegesRequiredOverridesAllowed must allow commandline or /ALLUSERS is inert",
)
require(
overrides is None or "dialog" not in overrides.group(1),
"allowing dialog makes a silent install with no previous copy prompt; winget installs that way",
)
# Verify the elevation path.
require(
"IsAdminInstallMode" in iss,
"the elevation path must be skipped once Setup already runs in administrative install mode",
)
require(
"{param:ELEVATED|0}" in iss,
"the relaunched instance needs a guard parameter so it cannot elevate again",
)
require(
"SaveStringToFile(Probe" in iss,
"elevation must be driven by probing the install directory for write access",
)
require(
"ShellExec('runas'" in iss and "{srcexe}" in iss,
"a non-writable install directory must relaunch this installer elevated",
)
for parameter in ("/ALLUSERS", "/ELEVATED=1", "/DIR="):
require(
parameter in iss,
f"the elevated relaunch must pass {parameter}",
)
require(
"'/CURRENTUSER'" in iss,
"the forwarded command line must drop /CURRENTUSER, which would undo /ALLUSERS",
)
require(
"CustomMessage('ElevationRequired')" in iss
and re.search(r"(?m)^ElevationRequired=\S", iss) is not None,
"a refused elevation must explain itself instead of failing silently",
)
# Preserve behavior required by release tooling.
require(
"{param:WINGET|0}" in iss and "{app}\\.winget" in iss,
"the winget marker file gates UpdateService.useNativeUpdater",
)
require(
"{param:NORUN|0}" in iss and "Check: not IsNoRun" in iss,
"the winget manifest passes /NORUN=1 and expects the launch entry to honor it",
)
if errors:
for error in errors:
print(f"ERROR: {error}", file=sys.stderr)
sys.exit(1)
print("windows installer elevation checks passed")