Files
effectcraft/packaging/windows/check-install-policy.ps1
T
Jeremy d0a67ac077 test(installer): cover the compiled folder searches, resolver chains and wizard folder replay
The policy check now reads the compiled AppSearch and RegLocator rows for the
remembered-folder properties (registry root, key, value name and type for both
scopes) and runs the DIR, FOLDER, PATH and legacy exe actions in sequence order
against seeded values, so their precedence and the preset-folder guard cannot
regress. It also replays the wizard folder actions in sequence order (a
remembered folder beats the new-install default, a typed folder beats both) and
requires the machine restore action between the scope page and costing.
2026-10-27 21:13:27 +00:00

371 lines
25 KiB
PowerShell

<#
.SYNOPSIS
Check the compiled installation policy without installing any product.
.DESCRIPTION
Uses Windows Installer's safe session (OpenPackage option 1). Only property-setting custom
actions are exercised. No AppSearch, install, repair, removal or file-writing action runs.
#>
param([Parameter(Mandatory = $true)] [string] $MsiPath)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$policy = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'install-policy.json') | ConvertFrom-Json
function Read-Rows($Database, [string] $Sql, [int] $Columns) {
$view = $Database.OpenView($Sql)
try {
[void]$view.Execute()
while ($null -ne ($record = $view.Fetch())) {
try {
$values = @(for ($i = 1; $i -le $Columns; $i++) {
$record.GetType().InvokeMember('StringData', 'GetProperty', $null, $record, @($i))
})
,$values
} finally { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($record) }
}
} finally {
[void]$view.Close()
[void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($view)
}
}
function Set-SessionProperty($Session, [string] $Name, [string] $Value) {
[void]$Session.GetType().InvokeMember('Property', 'SetProperty', $null, $Session, @($Name, $Value))
}
function Get-SessionProperty($Session, [string] $Name) {
$Session.GetType().InvokeMember('Property', 'GetProperty', $null, $Session, @($Name))
}
function Get-PackageTemplate($Database) {
# Summary information property 7 is the platform: "Intel;1033", "x64;1033" or "Arm64;1033".
$summary = $Database.GetType().InvokeMember('SummaryInformation', 'GetProperty', $null, $Database, @(0))
try { [string]$summary.GetType().InvokeMember('Property', 'GetProperty', $null, $summary, @(7)) }
finally { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($summary) }
}
$installer = $null; $database = $null; $session = $null
try {
$installer = New-Object -ComObject WindowsInstaller.Installer
$file = (Resolve-Path -LiteralPath $MsiPath).Path
$database = $installer.OpenDatabase($file, 0)
$session = $installer.OpenPackage($file, 1)
$properties = @{}
foreach ($row in (Read-Rows $database 'SELECT `Property`, `Value` FROM `Property`' 2)) { $properties[$row[0]] = $row[1] }
$secureProperties = @($properties['SecureCustomProperties'] -split ';')
if ('INSTALLFOLDER' -notin $secureProperties) {
throw 'The selected installation folder must survive elevation (secure INSTALLFOLDER).'
}
if ($policy.mode -eq 'perUser') {
if ($properties.ContainsKey('ALLUSERS')) { throw 'Launcher must remain per-user; machine self-update is not part of this change.' }
if ($properties['UpgradeCode'].Trim('{}') -ne $policy.upgradeCode) { throw 'Launcher upgrade identity changed.' }
$dirs = @{}
foreach ($row in (Read-Rows $database 'SELECT `Directory`, `Directory_Parent`, `DefaultDir` FROM `Directory`' 3)) { $dirs[$row[0]] = @($row[1],$row[2]) }
if ($dirs['INSTALLFOLDER'][0] -ne 'CraftSuiteFolder' -or $dirs['CraftSuiteFolder'][1] -ne 'ArtCraft' -or
$dirs['CraftSuiteFolder'][0] -ne 'LocalProgramsFolder' -or $dirs['LocalProgramsFolder'][0] -ne 'LocalAppDataFolder') {
throw 'Launcher new-install folder must be under the per-user ArtCraft suite root.'
}
foreach ($explicit in @('', 'F:\Chosen exact launcher')) {
Set-SessionProperty $session 'PREVIOUSINSTALLFOLDER' 'E:\Existing exact launcher'
Set-SessionProperty $session 'INSTALLFOLDER' $explicit
$row = @(Read-Rows $database 'SELECT `Condition` FROM `InstallUISequence` WHERE `Action` = ''CraftRestoreLauncherFolder''' 1)[0]
if ($session.EvaluateCondition($row[0]) -eq 1) {
if ($session.DoAction('CraftRestoreLauncherFolder') -ne 1) { throw 'Launcher restore action failed.' }
}
$expected = if ($explicit) { $explicit } else { 'E:\Existing exact launcher' }
if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Launcher custom or existing path was lost.' }
}
$rows = @(Read-Rows $database 'SELECT `Root`, `Key`, `Name`, `Value` FROM `Registry`' 4)
if (@($rows | Where-Object { $_[0] -ne '1' }).Count) { throw 'Launcher registration must stay under the current account.' }
foreach ($entry in $policy.registry) {
if (@($rows | Where-Object { $_[0] -eq $entry[0] -and $_[1] -eq $entry[1] -and $_[2] -eq $entry[2] -and $_[3] -eq $entry[3] }).Count -ne 1) {
throw 'Launcher registration or shortcut preference changed unexpectedly.'
}
}
Write-Output 'PASS ArtCraft Launcher: per-user scope, suite folder, remembered/custom paths and existing registration. No installation performed.'
return
}
if ($properties['ALLUSERS'] -ne '2' -or $properties['MSIINSTALLPERUSER'] -ne '1') {
throw 'Policy regression: new packages must default to the per-user dual-scope context.'
}
if ($properties['UpgradeCode'].Trim('{}') -ne $policy.upgradeCode) { throw 'The existing upgrade identity must not change.' }
$directories = @{}
foreach ($row in (Read-Rows $database 'SELECT `Directory`, `Directory_Parent`, `DefaultDir` FROM `Directory`' 3)) {
$directories[$row[0]] = @($row[1], $row[2])
}
if ($directories['INSTALLFOLDER'][0] -ne 'CraftSuiteFolder' -or
$directories['CraftSuiteFolder'][1] -ne 'ArtCraft') { throw 'Expected the app under the ArtCraft suite folder.' }
$programs = $directories['CraftSuiteFolder'][0]
if ($programs -eq 'ProgramFiles6432Folder') { $programs = $directories[$programs][0] }
if ($programs -notin 'ProgramFilesFolder', 'ProgramFiles64Folder') {
throw 'Use Windows Installer native folder redirection, not a hard-coded profile path.'
}
$guards = @(Read-Rows $database 'SELECT `Condition`, `Description` FROM `LaunchCondition`' 2 |
Where-Object { $_[0] -match 'CRAFT_(USER|MACHINE)_DIR' })
if ($guards.Count -ne 2) { throw 'Expected both duplicate-installation guards.' }
$cases = 0
foreach ($scope in @('', '2', '1')) {
foreach ($user in @('', 'D:\Existing user app')) {
foreach ($machine in @('', 'E:\Existing machine app')) {
foreach ($installed in @('', '1')) {
Set-SessionProperty $session 'ALLUSERS' $scope
Set-SessionProperty $session 'Installed' $installed
Set-SessionProperty $session 'CRAFT_USER_DIR' $user
Set-SessionProperty $session 'CRAFT_MACHINE_DIR' $machine
$allowed = $true
foreach ($guard in $guards) {
$value = $session.EvaluateCondition($guard[0])
if ($value -notin 0, 1) { throw 'Invalid duplicate guard.' }
if ($value -eq 0) { $allowed = $false }
}
$opposite = if ($scope -eq '1') { $user } else { $machine }
$expected = [bool]$installed -or -not [bool]$opposite
if ($allowed -ne $expected) { throw "Scope regression: ALLUSERS='$scope', user=$([bool]$user), machine=$([bool]$machine), maintenance=$([bool]$installed)." }
$cases++
}
}
}
}
# Execute the actual authored type-51 actions in a safe session, not copied resolver logic.
foreach ($scope in @('', '2', '1')) {
foreach ($explicit in @('', 'F:\Chosen exact app')) {
Set-SessionProperty $session 'ALLUSERS' $scope
Set-SessionProperty $session 'INSTALLFOLDER' $explicit
Set-SessionProperty $session 'CRAFT_USER_DIR' 'D:\Existing user app'
Set-SessionProperty $session 'CRAFT_MACHINE_DIR' 'E:\Existing machine app'
$action = if ($scope -eq '1') { 'CraftRestoreMachineFolder' } else { 'CraftRestoreUserFolder' }
# Evaluate the compiled condition, so the test exercises it instead of calling the action directly.
$restore = @(Read-Rows $database ('SELECT `Condition` FROM `InstallExecuteSequence` WHERE `Action` = ''{0}''' -f $action) 1)[0]
$selected = $session.EvaluateCondition($restore[0]) -eq 1
if ($selected -eq [bool]$explicit) { throw "$action must run only when no folder was supplied (condition: $($restore[0]))." }
if ($selected -and $session.DoAction($action) -ne 1) { throw 'Remembered-folder property action failed.' }
$expected = if ($explicit) { $explicit } elseif ($scope -eq '1') { 'E:\Existing machine app' } else { 'D:\Existing user app' }
if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Custom or remembered folder was not preserved.' }
}
}
$ui = @{}
foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallUISequence`' 2)) { $ui[$row[0]] = [int]$row[1] }
if (-not ($ui['AppSearch'] -lt $ui['CraftPreferMachineScope'] -and
$ui['CraftPreferMachineScope'] -lt $ui['CraftScopeDlg'] -and
$ui['CraftScopeDlg'] -lt $ui['FindRelatedProducts'] -and
$ui['FindRelatedProducts'] -lt $ui['LaunchConditions'] -and
$ui['CraftScopeDlg'] -lt $ui['CraftRestoreUserFolder'] -and $ui['CraftRestoreUserFolder'] -lt $ui['CostInitialize'] -and
$ui['CraftScopeDlg'] -lt $ui['CraftRestoreMachineFolder'] -and $ui['CraftRestoreMachineFolder'] -lt $ui['CostInitialize'])) {
throw 'Scope must be chosen after AppSearch and before FindRelatedProducts, launch checks and costing.'
}
# Windows Installer resolves Program Files for the context it started in, before the wizard's scope choice.
# The wizard therefore sets the new-install folder itself, and only in the wizard.
$defaults = @(Read-Rows $database 'SELECT `Action`, `Sequence`, `Condition` FROM `InstallUISequence`' 3 | Where-Object { $_[0] -like 'CraftDefault*' })
if ($defaults.Count -lt 2) { throw 'The wizard must set the new-install folder after the scope page; Program Files is resolved before the choice.' }
foreach ($row in $defaults) {
if (-not ($ui['CraftScopeDlg'] -lt [int]$row[1] -and [int]$row[1] -lt $ui['CostInitialize'])) { throw "$($row[0]) must run after the scope page and before costing." }
}
if (@(Read-Rows $database 'SELECT `Action` FROM `InstallExecuteSequence`' 1 | Where-Object { $_[0] -like 'CraftDefault*' }).Count) {
throw 'Silent installs must not use the wizard-only default folder actions.'
}
# The machine default comes from the environment of the package architecture, not from a folder name.
# x86 packages use Program Files (x86) on 64-bit Windows; x64 and Arm64 packages use the 64-bit root.
$machineRoot = if ((Get-PackageTemplate $database) -match '^Intel') {
if (Get-SessionProperty $session 'VersionNT64') { ${env:ProgramFiles(x86)} } else { $env:ProgramFiles }
} elseif ($env:ProgramW6432) { $env:ProgramW6432 } else { $env:ProgramFiles }
$machineFolder = (Join-Path $machineRoot "ArtCraft\$($policy.name)") + '\'
foreach ($scope in @('1', '')) {
Set-SessionProperty $session 'ALLUSERS' $scope
Set-SessionProperty $session 'INSTALLFOLDER' ''
Set-SessionProperty $session 'CRAFT_MACHINE_DIR' ''
Set-SessionProperty $session 'CRAFT_USER_DIR' ''
foreach ($row in @($defaults | Sort-Object { [int]$_[1] })) {
if ($session.EvaluateCondition($row[2]) -eq 1 -and $session.DoAction($row[0]) -ne 1) { throw "$($row[0]) failed." }
}
$folder = Get-SessionProperty $session 'INSTALLFOLDER'
$wanted = if ($scope -eq '1') { $machineFolder } else { (Join-Path $env:LOCALAPPDATA "Programs\ArtCraft\$($policy.name)") + '\' }
if ($folder -ine $wanted) { throw "Wizard default folder for ALLUSERS='$scope' is '$folder', expected '$wanted'." }
}
# Replay the wizard's compiled folder actions in sequence order, as the full UI runs them. A remembered
# folder must win over the new-install default, and a folder the user typed must survive both.
$uiFolderActions = @(Read-Rows $database 'SELECT `Action`, `Sequence`, `Condition` FROM `InstallUISequence`' 3 |
Where-Object { $_[0] -match '^Craft(Restore|Default)' } | Sort-Object { [int]$_[1] })
$userDefault = (Join-Path $env:LOCALAPPDATA "Programs\ArtCraft\$($policy.name)") + '\'
foreach ($case in @(
@('1', 'D:\Remembered user app', 'E:\Remembered machine app', '', 'E:\Remembered machine app'),
@('', 'D:\Remembered user app', 'E:\Remembered machine app', '', 'D:\Remembered user app'),
@('1', '', '', '', $machineFolder),
@('', '', '', '', $userDefault),
@('1', 'D:\Remembered user app', 'E:\Remembered machine app', 'F:\Chosen exact app', 'F:\Chosen exact app'))) {
Set-SessionProperty $session 'Installed' ''
Set-SessionProperty $session 'ALLUSERS' $case[0]
Set-SessionProperty $session 'CRAFT_USER_DIR' $case[1]
Set-SessionProperty $session 'CRAFT_MACHINE_DIR' $case[2]
Set-SessionProperty $session 'INSTALLFOLDER' $case[3]
foreach ($row in $uiFolderActions) {
if ($session.EvaluateCondition($row[2]) -eq 1 -and $session.DoAction($row[0]) -ne 1) { throw "$($row[0]) failed." }
}
$folder = Get-SessionProperty $session 'INSTALLFOLDER'
if ($folder -ine $case[4]) { throw "Full-UI folder for ALLUSERS='$($case[0])' is '$folder', expected '$($case[4])'." }
}
$execute = @{}
foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] }
foreach ($action in @('CraftRestoreUserFolder','CraftRestoreMachineFolder')) {
if (-not ($execute['AppSearch'] -lt $execute[$action] -and $execute[$action] -lt $execute['CostInitialize'])) {
throw 'Silent updates must restore the folder before directory costing.'
}
}
foreach ($resolver in @('CraftUFromLegacyExe','CraftMFromLegacyExe')) {
if (-not ($execute['AppSearch'] -lt $execute[$resolver] -and $execute[$resolver] -lt $execute['LaunchConditions'])) {
throw 'Both legacy folder resolvers must run after AppSearch and before the launch checks.'
}
}
# AppSearch itself never runs in this safe session, so check the compiled searches and then run the
# property actions that turn the search results into the remembered folder, once per scope.
$appSearch = @{}
foreach ($row in (Read-Rows $database 'SELECT `Property`, `Signature_` FROM `AppSearch`' 2)) { $appSearch[$row[0]] = $row[1] }
$locators = @{}
foreach ($row in (Read-Rows $database 'SELECT `Signature_`, `Root`, `Key`, `Name`, `Type` FROM `RegLocator`' 5)) { $locators[$row[0]] = $row }
$appPaths = '^Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\[^\\]+\.exe$'
# Suffix, how to match the key, key, value name, locator type (2 = raw value, 1 = file; the 64-bit flag is masked off).
$searchContract = @(
@('DIR', 'exact', "Software\$($policy.name)", 'InstallDir', '2'),
@('FOLDER', 'exact', "Software\$($policy.name)", 'InstallFolder', '2'),
@('PATH', 'regex', $appPaths, 'Path', '2'),
@('EXEFOLDER', 'regex', $appPaths, '', '1'),
@('EXE', 'regex', $appPaths, '', '1'))
$resolverCases = @(
@{ name = 'every record, DIR wins'; dir = 'D:\Dir\'; folder = 'D:\Folder\'; path = 'D:\Path\'; exeFolder = 'D:\Exe\'; exe = 'D:\Exe\app.exe'; existing = ''; expected = 'D:\Dir\' },
@{ name = 'FOLDER wins without DIR'; dir = ''; folder = 'D:\Folder\'; path = 'D:\Path\'; exeFolder = 'D:\Exe\'; exe = 'D:\Exe\app.exe'; existing = ''; expected = 'D:\Folder\' },
@{ name = 'PATH wins without DIR, FOLDER'; dir = ''; folder = ''; path = 'D:\Path\'; exeFolder = 'D:\Exe\'; exe = 'D:\Exe\app.exe'; existing = ''; expected = 'D:\Path\' },
@{ name = 'legacy exe folder is last'; dir = ''; folder = ''; path = ''; exeFolder = 'D:\Exe\'; exe = 'D:\Exe\app.exe'; existing = ''; expected = 'D:\Exe\' },
@{ name = 'legacy folder needs the exe'; dir = ''; folder = ''; path = ''; exeFolder = 'D:\Exe\'; exe = ''; existing = ''; expected = '' },
@{ name = 'a preset folder is kept'; dir = 'D:\Dir\'; folder = 'D:\Folder\'; path = 'D:\Path\'; exeFolder = 'D:\Exe\'; exe = 'D:\Exe\app.exe'; existing = 'P:\Preset\'; expected = 'P:\Preset\' })
foreach ($scopeSpec in @(@('U', '1', 'CRAFT_USER_DIR'), @('M', '2', 'CRAFT_MACHINE_DIR'))) {
$letter = $scopeSpec[0]; $target = $scopeSpec[2]
foreach ($item in $searchContract) {
$property = "CRAFT_${letter}_$($item[0])"
$locator = if ($appSearch.ContainsKey($property)) { $locators[$appSearch[$property]] } else { $null }
$keyOk = $null -ne $locator -and $(if ($item[1] -eq 'exact') { $locator[2] -eq $item[2] } else { $locator[2] -match $item[2] })
if (-not $keyOk -or $locator[1] -ne $scopeSpec[1] -or $locator[3] -ne $item[3] -or ([int]$locator[4] -band 3) -ne [int]$item[4]) {
throw "Compiled search for $property is missing or points at the wrong registry location."
}
}
$chain = @(Read-Rows $database 'SELECT `Action`, `Sequence`, `Condition` FROM `InstallExecuteSequence`' 3 |
Where-Object { $_[0] -match "^Craft${letter}From" } | Sort-Object { [int]$_[1] })
$order = @($chain | ForEach-Object { $_[0] }) -join ','
if ($order -ne "Craft${letter}FromDIR,Craft${letter}FromFOLDER,Craft${letter}FromPATH,Craft${letter}FromLegacyExe") {
throw "Remembered-folder resolvers for scope $letter run in the wrong order: $order"
}
foreach ($case in $resolverCases) {
Set-SessionProperty $session $target $case.existing
Set-SessionProperty $session "CRAFT_${letter}_DIR" $case.dir
Set-SessionProperty $session "CRAFT_${letter}_FOLDER" $case.folder
Set-SessionProperty $session "CRAFT_${letter}_PATH" $case.path
Set-SessionProperty $session "CRAFT_${letter}_EXEFOLDER" $case.exeFolder
Set-SessionProperty $session "CRAFT_${letter}_EXE" $case.exe
foreach ($row in $chain) {
if ($session.EvaluateCondition($row[2]) -eq 1 -and $session.DoAction($row[0]) -ne 1) { throw "$($row[0]) failed." }
}
$got = Get-SessionProperty $session $target
if ($got -ne $case.expected) { throw "Resolver chain for scope $letter, case '$($case.name)': got '$got', expected '$($case.expected)'." }
}
}
# Switching the first-page choice must reset BOTH properties, including a back-and-forth change.
$events = @(Read-Rows $database 'SELECT `Event`, `Argument`, `Condition`, `Ordering` FROM `ControlEvent` WHERE `Dialog_` = ''CraftScopeDlg'' AND `Control_` = ''Next'' ORDER BY `Ordering`' 4)
# Model the full-UI upgrade entry with an existing machine-wide copy. Run
# the actual preference action, then the compiled Next events before discovery.
Set-SessionProperty $session 'ALLUSERS' '2'
Set-SessionProperty $session 'CRAFT_SCOPE' 'user'
Set-SessionProperty $session 'CRAFT_USER_DIR' ''
Set-SessionProperty $session 'CRAFT_MACHINE_DIR' 'E:\Existing machine app'
$preference = @(Read-Rows $database 'SELECT `Condition` FROM `InstallUISequence` WHERE `Action` = ''CraftPreferMachineScope''' 1)[0]
if ($session.EvaluateCondition($preference[0]) -ne 1 -or $session.DoAction('CraftPreferMachineScope') -ne 1 -or
(Get-SessionProperty $session 'CRAFT_SCOPE') -ne 'machine') {
throw 'Full-UI machine upgrades must initially select the existing machine context.'
}
foreach ($choice in @('machine','user','machine','user')) {
Set-SessionProperty $session 'CRAFT_SCOPE' $choice
foreach ($event in $events) {
if ($event[0] -notmatch '^\[([^\]]+)\]$') { continue }
$property = $Matches[1]
if ($session.EvaluateCondition($event[2]) -eq 1) {
Set-SessionProperty $session $property $(if ($event[1] -eq '{}') { '' } else { $event[1] })
}
}
$allUsers = Get-SessionProperty $session 'ALLUSERS'
$perUser = Get-SessionProperty $session 'MSIINSTALLPERUSER'
if (($choice -eq 'machine' -and ($allUsers -ne '1' -or $perUser)) -or
($choice -eq 'user' -and ($allUsers -or $perUser -ne '1'))) {
throw 'Scope selector does not reset the requested installation context.'
}
}
$actions = @(Read-Rows $database 'SELECT `Action`, `Source`, `Target`, `Type` FROM `CustomAction`' 4)
$savedOptions = @($actions | Where-Object { $_[0] -match '^CraftLoadOption[0-9]+M$' })
foreach ($option in $savedOptions) {
if ([int]$option[3] -ne 51) { throw 'Saved-option test may run only property-setting actions.' }
$number = [regex]::Match($option[0], '[0-9]+').Value
foreach ($scope in @('2','1')) {
Set-SessionProperty $session 'ALLUSERS' $scope
Set-SessionProperty $session "CRAFT_OPTION_${number}_M" 'saved-machine-choice'
Set-SessionProperty $session "CRAFT_OPTION_${number}_U" 'saved-user-choice'
Set-SessionProperty $session $option[1] ''
$action = "CraftLoadOption$number" + $(if ($scope -eq '1') { 'M' } else { 'U' })
if ($session.DoAction($action) -ne 1) { throw 'Saved-option property action failed.' }
$expected = if ($scope -eq '1') { 'saved-machine-choice' } else { 'saved-user-choice' }
if ((Get-SessionProperty $session $option[1]) -ne $expected) { throw 'Saved installer choices leaked across contexts.' }
}
}
# Follow the compiled control conditions, not the source's intended appearance.
$controls = @(Read-Rows $database 'SELECT `Dialog_`, `Control`, `Type`, `Attributes`, `Text` FROM `Control`' 5)
$conditions = @()
if (@(Read-Rows $database 'SELECT `Name` FROM `_Tables` WHERE `Name` = ''ControlCondition''' 1).Count) {
$conditions = @(Read-Rows $database 'SELECT `Dialog_`, `Control_`, `Action`, `Condition` FROM `ControlCondition`' 4)
}
function Test-ControlVisible($Control) {
$visible = ([int]$Control[3] -band 1) -ne 0
foreach ($condition in $conditions) {
if ($condition[0] -eq $Control[0] -and $condition[1] -eq $Control[1] -and $session.EvaluateCondition($condition[3]) -eq 1) {
if ($condition[2] -eq 'Show') { $visible = $true }
if ($condition[2] -eq 'Hide') { $visible = $false }
}
}
return $visible
}
foreach ($button in @($controls | Where-Object { $_[0] -in 'InstallWelcome','InstallMaintenance','DesktopShortcutDlg' -and ([int]$_[3] -band 8388608) -ne 0 })) {
$plain = @($controls | Where-Object { $_[0] -eq $button[0] -and $_[1] -eq ($button[1] + 'User') })
if ($plain.Count -ne 1 -or ([int]$plain[0][3] -band 8388608)) { throw 'Scope-aware buttons need an unshielded per-user counterpart.' }
foreach ($scope in @('', '2', '1')) {
Set-SessionProperty $session 'ALLUSERS' $scope
if ((Test-ControlVisible $button) -ne ($scope -eq '1') -or (Test-ControlVisible $plain[0]) -ne ($scope -ne '1')) {
throw 'The elevation shield must follow the selected scope.'
}
}
$dialog = $button[0]; $shieldId = $button[1]; $plainId = $plain[0][1]
$shieldEvents = @(Read-Rows $database "SELECT ``Event``, ``Argument``, ``Condition``, ``Ordering`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$shieldId' ORDER BY ``Ordering``" 4 | ForEach-Object { $_ -join '|' })
$plainEvents = @(Read-Rows $database "SELECT ``Event``, ``Argument``, ``Condition``, ``Ordering`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$plainId' ORDER BY ``Ordering``" 4 | ForEach-Object { $_ -join '|' })
if (($shieldEvents -join "`n") -ne ($plainEvents -join "`n")) { throw 'Per-user and machine buttons must perform the same operation.' }
}
foreach ($scope in @('', '2', '1')) {
Set-SessionProperty $session 'ALLUSERS' $scope
foreach ($text in @($controls | Where-Object { $_[4] -like '*for all users*' })) {
if ((Test-ControlVisible $text) -ne ($scope -eq '1')) { throw 'The welcome-page scope text must follow the selected scope.' }
}
}
if ($policy.id -in 'deckcraft','filmcraft','soundcraft') {
foreach ($route in @(@('WelcomeDlg','Next','InstallDirDlg'),@('InstallDirDlg','Back','WelcomeDlg'))) {
$dialog = $route[0]; $control = $route[1]
$routes = @(Read-Rows $database "SELECT ``Argument``, ``Condition`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$control' AND ``Event`` = 'NewDialog' ORDER BY ``Ordering``" 2)
Set-SessionProperty $session 'Installed' ''
$destination = ''
foreach ($event in $routes) { if ($session.EvaluateCondition($event[1]) -eq 1) { $destination = $event[0] } }
if ($destination -ne $route[2]) { throw 'Skipping the licence page must work in both navigation directions.' }
}
}
$registry = @(Read-Rows $database 'SELECT `Root`, `Key`, `Name`, `Value` FROM `Registry`' 4)
if (@($registry | Where-Object { $_[0] -in '1', '2' }).Count) { throw 'Registration must follow the selected context, not force HKCU or HKLM.' }
if (@($registry | Where-Object { $_[0] -eq '0' -and $_[1] -match '^Software\\Classes(\\|$)' }).Count) {
throw 'HKCR keys must be relative to Classes.'
}
foreach ($entry in $policy.registry) {
$found = @($registry | Where-Object { $_[0] -eq $entry[0] -and $_[1] -eq $entry[1] -and $_[2] -eq $entry[2] -and $_[3] -eq $entry[3] })
if ($found.Count -ne 1) { throw "Lost or changed registration: $($entry[1]) / $($entry[2])" }
}
$installDir = @($registry | Where-Object { $_[0] -eq '-1' -and $_[1] -eq "Software\$($policy.name)" -and $_[2] -eq 'InstallDir' -and $_[3] -eq '[INSTALLFOLDER]' })
if ($installDir.Count -ne 1) { throw 'A context-sensitive installation-folder record is required.' }
Write-Output "PASS $($policy.name): $cases scope cases, folder preservation, action sequencing and registry contract. No installation performed."
} finally {
foreach ($com in @($session, $database, $installer)) {
if ($null -ne $com) { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($com) }
}
}