Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .github/workflows/test-installer.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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 }
Expand Down
54 changes: 48 additions & 6 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down Expand Up @@ -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 <folder>`
# 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)
85 changes: 85 additions & 0 deletions tests/msi_package.ps1
Original file line number Diff line number Diff line change
@@ -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: "<platform>;<languages>".
$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
6 changes: 4 additions & 2 deletions tests/test_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand Down
105 changes: 105 additions & 0 deletions tests/test_package.py
Original file line number Diff line number Diff line change
@@ -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"<ROW PrereqKey=[^>]*>", 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"<ROW SearchKey=[^>]*>", 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)
Loading
Loading