diff --git a/.github/workflows/test-installer.yml b/.github/workflows/test-installer.yml index e0b82f3..37fb753 100644 --- a/.github/workflows/test-installer.yml +++ b/.github/workflows/test-installer.yml @@ -30,6 +30,8 @@ jobs: matrix: include: - { arch: x64, runner: windows-2025, target: VC-WIN64A, vcvars: vcvars64.bat } + # x64-hosted cross compiler first, x86-hosted toolset as fallback. + - { arch: x86, runner: windows-2025, target: VC-WIN32, vcvars: "vcvarsamd64_x86.bat vcvars32.bat" } # Native arm64-hosted toolset first, x86-hosted cross compiler as fallback. - { arch: arm64, runner: windows-11-arm, target: VC-WIN64-ARM, vcvars: "vcvarsarm64.bat vcvarsx86_arm64.bat" } steps: @@ -80,7 +82,7 @@ jobs: - name: install nasm # x86-only assembler; the ARM64 target assembles with MSVC's armasm64. - if: steps.fips_cache.outputs.cache-hit != 'true' && matrix.arch == 'x64' + if: steps.fips_cache.outputs.cache-hit != 'true' && matrix.arch != 'arm64' shell: pwsh run: | $installer = "nasm-3.01-installer-x64.exe" @@ -132,6 +134,8 @@ jobs: crt: - { arch: x64, runner: windows-2025, vcvars: vcvars64.bat, flavor: vs, target: VC-WIN64A, exe_build: ExeBuild, msi_build: MsiBuild, keep: OpenSSL-x64-VS-* } - { arch: x64, runner: windows-2025, vcvars: vcvars64.bat, flavor: hybrid, target: VC-WIN64A-HYBRIDCRT, exe_build: ExeBuild_hybrid, msi_build: MsiBuild_hybrid, keep: OpenSSL-x64-hybridCRT-* } + - { arch: x86, runner: windows-2025, vcvars: "vcvarsamd64_x86.bat vcvars32.bat", flavor: vs, target: VC-WIN32, exe_build: ExeBuild_x86, msi_build: MsiBuild_x86, keep: OpenSSL-x86-VS-* } + - { arch: x86, runner: windows-2025, vcvars: "vcvarsamd64_x86.bat vcvars32.bat", flavor: hybrid, target: VC-WIN32-HYBRIDCRT, exe_build: ExeBuild_x86_hybrid, msi_build: MsiBuild_x86_hybrid, keep: OpenSSL-x86-hybridCRT-* } # Hybrid flavor only: every arm64 Windows release ships the Universal CRT. - { arch: arm64, runner: windows-11-arm, vcvars: "vcvarsarm64.bat vcvarsx86_arm64.bat", flavor: hybrid, target: VC-WIN64-ARM-HYBRIDCRT, exe_build: ExeBuild_arm64_hybrid, msi_build: MsiBuild_arm64_hybrid, keep: OpenSSL-arm64-hybridCRT-* } steps: @@ -199,7 +203,7 @@ jobs: - name: install nasm # x86-only assembler; the ARM64 target assembles with MSVC's armasm64. - if: steps.openssl_cache.outputs.cache-hit != 'true' && matrix.crt.arch == 'x64' + if: steps.openssl_cache.outputs.cache-hit != 'true' && matrix.crt.arch != 'arm64' shell: pwsh run: | $installer = "nasm-3.01-installer-x64.exe" @@ -276,6 +280,10 @@ jobs: - OpenSSL-x64-VS-*.msi - OpenSSL-x64-hybridCRT-*.exe - OpenSSL-x64-hybridCRT-*.msi + - OpenSSL-x86-VS-*.exe + - OpenSSL-x86-VS-*.msi + - OpenSSL-x86-hybridCRT-*.exe + - OpenSSL-x86-hybridCRT-*.msi include: # ARM64 packages only install on ARM64 Windows. - { os: windows-11-arm, openssl-ref: openssl-4.1, installer: OpenSSL-arm64-hybridCRT-*.exe } diff --git a/tests/conftest.py b/tests/conftest.py index 3f9b510..b3702a1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -616,12 +616,8 @@ def check_openssl_crypto(install_dir: Path): # AES-256-CBC encrypt → decrypt must round-trip. pass_args = ["-aes-256-cbc", "-pbkdf2", "-pass", "pass:installer-test"] - ciphertext = subprocess.run( - [str(exe), "enc", "-e", *pass_args], input=data, check=True, capture_output=True - ).stdout - roundtrip = subprocess.run( - [str(exe), "enc", "-d", *pass_args], input=ciphertext, check=True, capture_output=True - ).stdout + ciphertext = subprocess.run([str(exe), "enc", "-e", *pass_args], input=data, check=True, capture_output=True).stdout + roundtrip = subprocess.run([str(exe), "enc", "-d", *pass_args], input=ciphertext, check=True, capture_output=True).stdout assert roundtrip == data, "AES-256-CBC encrypt/decrypt did not round-trip to the original plaintext" @@ -821,3 +817,49 @@ def validated_option_disabled(facts: dict[str, str | None]) -> bool: """ quoted = f'"{facts["build_name"]}"' return quoted in (facts["hide_condition"] or "") + + +# --- MSI package structure (platform, bitness, upgrades, SDK dialog) -------- +# +# Facts a silent install can't observe, read straight from the package's MSI +# database by msi_package.ps1 (see test_package.py). An .exe bootstrapper's +# inner MSI is pulled out with the bootstrapper's own `/extract ` +# switch, so these tests never install anything. + +_MSI_PACKAGE_SCRIPT = Path(__file__).parent / "msi_package.ps1" + + +@pytest.fixture(scope="session") +def package_msi(installer: InstallerInfo, tmp_path_factory) -> Path: + """The installer's MSI database: the .msi itself, or the MSI extracted from + the .exe bootstrapper.""" + if installer.path.suffix.lower() == ".msi": + return installer.path + folder = tmp_path_factory.mktemp("extracted-msi") + subprocess.run([str(installer.path), "/extract", str(folder)], check=True, capture_output=True, text=True) + msis = sorted(folder.glob("*.msi")) + if len(msis) != 1: + pytest.fail(f"expected one MSI from '{installer.path.name} /extract', found {[m.name for m in msis]}") + return msis[0] + + +@pytest.fixture(scope="session") +def package_facts(package_msi: Path) -> dict: + """Template, component bitness, Upgrade rows, features with conditions and + the SDK dialog's feature list, as reported by msi_package.ps1.""" + res = subprocess.run( + [ + "powershell", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + str(_MSI_PACKAGE_SCRIPT), + "-MsiPath", + str(package_msi), + ], + check=True, + capture_output=True, + text=True, + ) + return json.loads(res.stdout) diff --git a/tests/msi_package.ps1 b/tests/msi_package.ps1 new file mode 100644 index 0000000..b6b3c63 --- /dev/null +++ b/tests/msi_package.ps1 @@ -0,0 +1,85 @@ +# Dump the package-structure facts of an MSI database as JSON. +# Consumed by tests/test_package.py through conftest.msi_package_facts(). +# +# Reports what a silent install cannot observe: the platform the package +# declares, the bitness of every component, the upgrade detection rows and the +# feature list the SDK dialog hands to UpdateFeaturesInstallStates. +# +# Driven through reflection (InvokeMember), like msi_query.ps1: that is the +# reliable way to reach MSI's parameterized properties from PowerShell. +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$MsiPath +) +$ErrorActionPreference = "Stop" + +$installer = New-Object -ComObject WindowsInstaller.Installer + +function Invoke-Get($obj, [string]$name, [object[]]$argv) { + return $obj.GetType().InvokeMember($name, "GetProperty", $null, $obj, $argv) +} +function Invoke-Call($obj, [string]$name, [object[]]$argv) { + return $obj.GetType().InvokeMember($name, "InvokeMethod", $null, $obj, $argv) +} + +# OpenDatabase(path, 0): 0 = msiOpenDatabaseModeReadOnly +$database = Invoke-Call $installer "OpenDatabase" @($MsiPath, 0) + +function Get-Rows { + # Rows of a query as arrays of strings. [int] casts matter: COM rejects the + # PSObject-wrapped integers PowerShell would otherwise pass. + param($db, [string]$sql) + $view = Invoke-Call $db "OpenView" @($sql) + [void](Invoke-Call $view "Execute" $null) + $rows = New-Object System.Collections.ArrayList + while ($true) { + $record = Invoke-Call $view "Fetch" $null + if ($null -eq $record) { break } + $count = [int](Invoke-Get $record "FieldCount" $null) + $values = @() + for ($i = 1; $i -le $count; $i++) { $values += [string](Invoke-Get $record "StringData" @([int]$i)) } + [void]$rows.Add($values) + } + [void](Invoke-Call $view "Close" $null) + return $rows # enumerated into the pipeline one row (string[]) at a time +} + +function Test-Table { + param($db, [string]$name) + $state = [int](Invoke-Get $db "TablePersistent" @($name)) # a parameterized property + return ($state -eq 1) # 1 = MSICONDITION_TRUE, the table exists +} + +# Summary information property 7 is the Template: ";". +$summary = Invoke-Get $installer "SummaryInformation" @($MsiPath, 0) +$template = [string](Invoke-Get $summary "Property" @([int]7)) + +# Backticks quote MSI SQL identifiers; doubled (``) in a double-quoted string. +$components = @(Get-Rows $database "SELECT ``Component``, ``Attributes`` FROM ``Component``" | + ForEach-Object { [ordered]@{ name = $_[0]; attributes = [int]$_[1] } }) + +$upgrade = @() +if (Test-Table $database "Upgrade") { + $upgrade = @(Get-Rows $database "SELECT ``ActionProperty``, ``Attributes``, ``VersionMin``, ``VersionMax`` FROM ``Upgrade``" | + ForEach-Object { [ordered]@{ action_property = $_[0]; attributes = [int]$_[1]; version_min = $_[2]; version_max = $_[3] } }) +} + +$features = @(Get-Rows $database "SELECT ``Feature`` FROM ``Feature``" | ForEach-Object { $_[0] }) + +$conditions = @() +if (Test-Table $database "Condition") { + $conditions = @(Get-Rows $database "SELECT ``Feature_``, ``Level``, ``Condition`` FROM ``Condition``" | + ForEach-Object { [ordered]@{ feature = $_[0]; level = [int]$_[1]; condition = $_[2] } }) +} + +$sdkFeatureLists = @(Get-Rows $database "SELECT ``Argument`` FROM ``ControlEvent`` WHERE ``Dialog_``='SDKDlg' AND ``Control_``='Next' AND ``Event``='[CustomActionData]'" | + ForEach-Object { $_[0] }) + +[ordered]@{ + template = $template + components = $components + upgrade = $upgrade + features = $features + feature_conditions = $conditions + sdk_next_custom_action_data = $sdkFeatureLists +} | ConvertTo-Json -Compress -Depth 4 diff --git a/tests/test_gui.py b/tests/test_gui.py index f353709..6f50e7a 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -228,8 +228,9 @@ def _wait_until(predicate: Any, *, timeout: float, what: str, poll: float = 2.0) time.sleep(poll) raise AssertionError(f"timed out after {timeout:.0f}s waiting for {what}") + @pytest.mark.skipif(os.getenv("CI") == "true", reason="GUI tests require interactive session") -def test_wizard_dialog_walk(wizard: Wizard, installer: InstallerInfo) -> None: +def test_wizard_dialog_walk(wizard: Wizard, installer: InstallerInfo, install_dir: Path) -> None: """Walk the wizard from license through the additional-options dialog, asserting expected state (defaults, path, the 'at least one option' validation popup) at each step. @@ -245,7 +246,8 @@ def test_wizard_dialog_walk(wizard: Wizard, installer: InstallerInfo) -> None: # ---- 2. Install path ---- assert _current_static(dlg) == "Choose install location", f"unexpected dialog title: {_current_static(dlg)!r}" - expected_path = f"C:\\Program Files\\OpenSSL Library\\openssl-{installer.short}\\" + # Per-architecture default: x86 packages offer Program Files (x86). + expected_path = f"{install_dir}\\" actual_path = dlg.Edit.get_value() assert actual_path == expected_path, f"install path: expected {expected_path!r}, got {actual_path!r}" dlg = _click_and_advance(dlg, app) diff --git a/tests/test_package.py b/tests/test_package.py new file mode 100644 index 0000000..0b40871 --- /dev/null +++ b/tests/test_package.py @@ -0,0 +1,105 @@ +# Package-structure tests: facts about the installer's MSI database that a +# silent install can't observe. They read the package (or the MSI extracted +# from the .exe bootstrapper) and never install it. +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +from conftest import InstallerInfo + +# Component table: msidbComponentAttributes64bit +_COMPONENT_64BIT = 0x100 +# Upgrade table: msidbUpgradeAttributesOnlyDetect +_UPGRADE_ONLY_DETECT = 0x2 + +# Platform field of the Template summary property per installer architecture. +# Advanced Installer leaves it empty for 32-bit packages, which Windows +# Installer reads as Intel. +_EXPECTED_PLATFORMS = {"x64": {"x64"}, "arm64": {"Arm64"}, "x86": {"", "Intel"}} + +_AIP = Path(__file__).parent.parent / "windows-installer" / "openssl.aip" + + +def test_platform_matches_architecture(installer: InstallerInfo, package_facts: dict) -> None: + """The package must declare the platform its binaries are built for. + Windows Installer rejects an unknown platform with error 1633 before + running anything (an Advanced Installer build once wrote "x86").""" + template = package_facts["template"] + platform = template.split(";", 1)[0] + expected = _EXPECTED_PLATFORMS[installer.arch] + assert platform in expected, f"{installer.arch} package declares Template {template!r}, expected platform {sorted(expected)}" + + +def test_x86_package_has_no_64bit_components(installer: InstallerInfo, package_facts: dict) -> None: + """A 32-bit package must not contain 64-bit components: 32-bit Windows + refuses them, and on 64-bit Windows their registry values land in the + 64-bit view instead of Wow6432Node.""" + if installer.arch != "x86": + pytest.skip("64-bit packages may legitimately contain 32-bit components") + offenders = sorted(c["name"] for c in package_facts["components"] if c["attributes"] & _COMPONENT_64BIT) + assert not offenders, f"x86 package contains 64-bit components: {offenders}" + + +def test_detects_newer_installed_version(installer: InstallerInfo, package_facts: dict) -> None: + """The Upgrade table must detect an installed newer version, so an older + package cannot silently install over it. Advanced Installer's GUI has + dropped this row before without any visible change in the project.""" + rows = [u for u in package_facts["upgrade"] if u["action_property"] == "AI_NEWERPRODUCTFOUND"] + assert rows, f"no AI_NEWERPRODUCTFOUND row in the Upgrade table: {package_facts['upgrade']}" + assert any( + u["attributes"] & _UPGRADE_ONLY_DETECT and u["version_min"] == installer.version for u in rows + ), f"AI_NEWERPRODUCTFOUND must be a detect-only row starting at {installer.version}, got {rows}" + + +def test_sdk_dialog_updates_every_conditioned_feature(package_facts: dict) -> None: + """The SDK dialog's Next button hands UpdateFeaturesInstallStates the list + of features whose install level depends on INSTALL_APP / INSTALL_SDK. + Advanced Installer silently omits that list when a sub-feature of a + conditioned feature has no condition of its own, and then the dialog's + choices stop applying. GUI tests don't run in CI, so check the table. + (Prerequisite features carry Advanced Installer's own conditions and are + not driven by the dialog, hence the property filter.)""" + present = set(package_facts["features"]) + conditioned = sorted( + {c["feature"] for c in package_facts["feature_conditions"] if re.search(r"\bINSTALL_(APP|SDK)\b", c["condition"])} + & present + ) + assert conditioned, "no features conditioned on INSTALL_APP / INSTALL_SDK; the SDK dialog has nothing to drive" + lists = package_facts["sdk_next_custom_action_data"] + assert lists, f"SDKDlg Next passes no feature list to UpdateFeaturesInstallStates; conditioned features: {conditioned}" + listed = set(" ".join(lists).split()) + missing = [f for f in conditioned if f not in listed] + assert not missing, f"conditioned features missing from the SDK dialog's feature list: {missing}" + + +def test_prerequisite_searches_read_their_runtime_registry_view() -> None: + """A VC++ redistributable prerequisite is installed when its registry + search finds no runtime. The x86 runtime records its version in the 32-bit + registry view and the x64 runtime in the 64-bit one, so each search must + read the view of the runtime it detects (Platform="1" = Advanced + Installer's "Use 64-bit locations" option). A 64-bit search for the x86 + runtime finds the x64 runtime instead and skips a needed install. This + lives in the bootstrapper configuration, not the MSI, so it is read from + the project file.""" + src = _AIP.read_text(encoding="utf-8") + runtime_arch = {} + for row in re.findall(r"]*>", src): + arch = re.search(r"VC_redist\.(x86|x64|arm64)\.exe", row) + key = re.search(r'PrereqKey="([^"]*)"', row) + if arch and key: + runtime_arch[key.group(1)] = arch.group(1) + assert runtime_arch, f"no VC++ redistributable prerequisites found in {_AIP}" + problems = [] + for row in re.findall(r"]*>", src): + match = re.search(r'Prereq="([^"]*)"', row) + prereq = match.group(1) if match else "" + if prereq not in runtime_arch: + continue + wants_64bit = runtime_arch[prereq] != "x86" + reads_64bit = 'Platform="1"' in row + if wants_64bit != reads_64bit: + view = "64-bit" if reads_64bit else "32-bit" + problems.append(f"{prereq} ({runtime_arch[prereq]} runtime) searches the {view} registry view") + assert not problems, "; ".join(problems) diff --git a/windows-installer/openssl.aip b/windows-installer/openssl.aip index 9510237..cf04adb 100644 --- a/windows-installer/openssl.aip +++ b/windows-installer/openssl.aip @@ -75,104 +75,88 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + - - + + - - - - - + + + + + + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + - - - + - - - + + @@ -207,12 +191,22 @@ - + - - + + + + + + + + + + + + @@ -242,13 +236,13 @@ - + + + - - - - + + @@ -341,6 +335,8 @@ + + @@ -540,6 +536,7 @@ + @@ -641,6 +638,8 @@ + + @@ -654,46 +653,67 @@ - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -782,25 +802,37 @@ + + + + + + + + + - + + + + @@ -845,6 +877,7 @@ + @@ -855,9 +888,19 @@ + + + + + + + + + + diff --git a/windows-installer/resources/x86/vcruntime140.dll b/windows-installer/resources/x86/vcruntime140.dll new file mode 100644 index 0000000..a14bfc0 Binary files /dev/null and b/windows-installer/resources/x86/vcruntime140.dll differ