707 lines
25 KiB
PowerShell
707 lines
25 KiB
PowerShell
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$InstallerPath,
|
|
[string]$ValidationRoot = 'C:\HyperTwistInstallerValidation',
|
|
[string]$ReportPath = '',
|
|
[int]$LaunchSmokeSeconds = 20,
|
|
[switch]$KeepInstalled
|
|
)
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
function Write-Utf8JsonFile {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$Path,
|
|
[Parameter(Mandatory = $true)]
|
|
[object]$Value
|
|
)
|
|
|
|
$ParentPath = Split-Path -Parent $Path
|
|
if (-not [string]::IsNullOrWhiteSpace($ParentPath))
|
|
{
|
|
New-Item -ItemType Directory -Force -Path $ParentPath | Out-Null
|
|
}
|
|
|
|
$Json = $Value | ConvertTo-Json -Depth 12
|
|
$Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
|
[System.IO.File]::WriteAllText($Path, $Json, $Utf8NoBom)
|
|
}
|
|
|
|
function Invoke-InstallerProcess {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$ExecutablePath,
|
|
[Parameter(Mandatory = $true)]
|
|
[string[]]$Arguments,
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$Label
|
|
)
|
|
|
|
$Process = Start-Process `
|
|
-FilePath $ExecutablePath `
|
|
-ArgumentList $Arguments `
|
|
-PassThru `
|
|
-Wait
|
|
if ($Process.ExitCode -notin @(0, 3010))
|
|
{
|
|
throw "$Label failed with exit code $($Process.ExitCode)."
|
|
}
|
|
|
|
return [int]$Process.ExitCode
|
|
}
|
|
|
|
function Invoke-InstallerProcessForExitCode {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$ExecutablePath,
|
|
[Parameter(Mandatory = $true)]
|
|
[string[]]$Arguments
|
|
)
|
|
|
|
$Process = Start-Process `
|
|
-FilePath $ExecutablePath `
|
|
-ArgumentList $Arguments `
|
|
-PassThru `
|
|
-Wait
|
|
return [int]$Process.ExitCode
|
|
}
|
|
|
|
function Resolve-PayloadRelativeFilePath {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$RootPath,
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$RelativePath
|
|
)
|
|
|
|
if ([string]::IsNullOrWhiteSpace($RelativePath))
|
|
{
|
|
throw 'A payload-relative path cannot be empty.'
|
|
}
|
|
|
|
$WindowsRelativePath = $RelativePath.Replace('/', '\')
|
|
if ([System.IO.Path]::IsPathRooted($WindowsRelativePath))
|
|
{
|
|
throw "Payload-relative path '$RelativePath' must not be rooted."
|
|
}
|
|
|
|
$ResolvedRootPath = [System.IO.Path]::GetFullPath($RootPath)
|
|
$RootPrefix = $ResolvedRootPath.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar
|
|
$ResolvedFilePath = [System.IO.Path]::GetFullPath(
|
|
(Join-Path $ResolvedRootPath $WindowsRelativePath)
|
|
)
|
|
if (-not $ResolvedFilePath.StartsWith(
|
|
$RootPrefix,
|
|
[System.StringComparison]::OrdinalIgnoreCase))
|
|
{
|
|
throw "Payload-relative path '$RelativePath' escapes root '$RootPath'."
|
|
}
|
|
|
|
return $ResolvedFilePath
|
|
}
|
|
|
|
function Test-PayloadManifest {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$InstallRoot,
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$ManifestPath
|
|
)
|
|
|
|
$Manifest = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json
|
|
$Failures = New-Object System.Collections.ArrayList
|
|
$ExpectedPaths = @{}
|
|
foreach ($Entry in @($Manifest.files))
|
|
{
|
|
$EntryRelativePath = [string]$Entry.relativePath
|
|
$EntryPathKey = $EntryRelativePath.Replace('\', '/').ToLowerInvariant()
|
|
if ($ExpectedPaths.ContainsKey($EntryPathKey))
|
|
{
|
|
[void]$Failures.Add("duplicate:$EntryRelativePath")
|
|
continue
|
|
}
|
|
$ExpectedPaths[$EntryPathKey] = $true
|
|
|
|
try
|
|
{
|
|
$InstalledPath = Resolve-PayloadRelativeFilePath `
|
|
-RootPath $InstallRoot `
|
|
-RelativePath $EntryRelativePath
|
|
}
|
|
catch
|
|
{
|
|
[void]$Failures.Add("invalid-path:$EntryRelativePath")
|
|
continue
|
|
}
|
|
|
|
if (-not (Test-Path -LiteralPath $InstalledPath -PathType Leaf))
|
|
{
|
|
[void]$Failures.Add("missing:$EntryRelativePath")
|
|
continue
|
|
}
|
|
|
|
$InstalledItem = Get-Item -LiteralPath $InstalledPath
|
|
if ($InstalledItem.Length -ne [Int64]$Entry.sizeBytes)
|
|
{
|
|
[void]$Failures.Add("size:$EntryRelativePath")
|
|
continue
|
|
}
|
|
|
|
$InstalledHash = (Get-FileHash -LiteralPath $InstalledPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
|
if ($InstalledHash -ne ([string]$Entry.sha256).ToLowerInvariant())
|
|
{
|
|
[void]$Failures.Add("sha256:$EntryRelativePath")
|
|
}
|
|
}
|
|
|
|
$AllowedInstallerPaths = @{}
|
|
@(
|
|
'Installer/payload-manifest.json',
|
|
'Installer/Test-HyperTwistWindowsReadiness.ps1',
|
|
'Installer/HyperTwist.install-marker',
|
|
'Uninstall HyperTwist.exe'
|
|
) | ForEach-Object { $AllowedInstallerPaths[$_.ToLowerInvariant()] = $true }
|
|
$InstallRootPrefix = [System.IO.Path]::GetFullPath($InstallRoot).TrimEnd('\') + '\'
|
|
$UnexpectedFiles = @(
|
|
Get-ChildItem -LiteralPath $InstallRoot -File -Force -Recurse |
|
|
ForEach-Object {
|
|
$RelativePath = $_.FullName.Substring($InstallRootPrefix.Length).Replace('\', '/')
|
|
$RelativePathKey = $RelativePath.ToLowerInvariant()
|
|
if (-not $ExpectedPaths.ContainsKey($RelativePathKey) `
|
|
-and -not $AllowedInstallerPaths.ContainsKey($RelativePathKey))
|
|
{
|
|
$RelativePath
|
|
}
|
|
}
|
|
)
|
|
$UnexpectedPdbFiles = @(
|
|
Get-ChildItem -LiteralPath $InstallRoot -Filter '*.pdb' -File -Recurse -ErrorAction SilentlyContinue
|
|
)
|
|
return [pscustomobject][ordered]@{
|
|
passed = $Failures.Count -eq 0 `
|
|
-and $UnexpectedPdbFiles.Count -eq 0 `
|
|
-and $UnexpectedFiles.Count -eq 0
|
|
manifestEntryCount = @($Manifest.files).Count
|
|
failureCount = $Failures.Count
|
|
failures = @($Failures)
|
|
unexpectedFiles = $UnexpectedFiles
|
|
unexpectedPdbFiles = @($UnexpectedPdbFiles | Select-Object -ExpandProperty FullName)
|
|
}
|
|
}
|
|
|
|
function Get-OwnedProcessRecords {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$InstallRoot
|
|
)
|
|
|
|
$InstallPrefix = $InstallRoot.TrimEnd('\') + '\'
|
|
return @(
|
|
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
|
|
Where-Object {
|
|
-not [string]::IsNullOrWhiteSpace([string]$_.ExecutablePath) `
|
|
-and ([string]$_.ExecutablePath).StartsWith(
|
|
$InstallPrefix,
|
|
[System.StringComparison]::OrdinalIgnoreCase)
|
|
}
|
|
)
|
|
}
|
|
|
|
function Stop-OwnedProcesses {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$InstallRoot
|
|
)
|
|
|
|
$StoppedProcessIds = New-Object 'System.Collections.Generic.HashSet[int]'
|
|
for ($Attempt = 0; $Attempt -lt 8; $Attempt += 1)
|
|
{
|
|
$OwnedProcesses = @(Get-OwnedProcessRecords -InstallRoot $InstallRoot)
|
|
if ($OwnedProcesses.Count -eq 0)
|
|
{
|
|
break
|
|
}
|
|
|
|
foreach ($OwnedProcess in $OwnedProcesses)
|
|
{
|
|
Stop-Process -Id ([int]$OwnedProcess.ProcessId) -Force -ErrorAction SilentlyContinue
|
|
[void]$StoppedProcessIds.Add([int]$OwnedProcess.ProcessId)
|
|
}
|
|
Start-Sleep -Milliseconds 250
|
|
}
|
|
|
|
return @($StoppedProcessIds)
|
|
}
|
|
|
|
function Invoke-InstalledLaunchSmoke {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$InstallRoot,
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$EvidenceDirectory,
|
|
[int]$SmokeSeconds
|
|
)
|
|
|
|
$ExecutablePath = Join-Path $InstallRoot 'UnrealHyperTwist.exe'
|
|
$RuntimeLogPath = Join-Path $EvidenceDirectory 'installed-runtime.log'
|
|
$DiagnosticsPath = Join-Path $EvidenceDirectory 'installed-runtime.hyperdiagnostics.log'
|
|
$UserDirectory = Join-Path $EvidenceDirectory 'isolated-user'
|
|
New-Item -ItemType Directory -Force -Path $UserDirectory | Out-Null
|
|
|
|
$Arguments = @(
|
|
'-NullRHI',
|
|
'-nosound',
|
|
'-windowed',
|
|
'-ResX=1280',
|
|
'-ResY=720',
|
|
'-log',
|
|
'-FORCELOGFLUSH',
|
|
"-abslog=$RuntimeLogPath",
|
|
"-HyperTwistDiagnosticsLog=`"$DiagnosticsPath`"",
|
|
"-UserDir=`"$UserDirectory`""
|
|
)
|
|
$StartedAtUtc = [DateTime]::UtcNow
|
|
$LaunchProcess = Start-Process `
|
|
-FilePath $ExecutablePath `
|
|
-ArgumentList $Arguments `
|
|
-PassThru
|
|
Start-Sleep -Seconds $SmokeSeconds
|
|
|
|
$OwnedProcesses = @(Get-OwnedProcessRecords -InstallRoot $InstallRoot)
|
|
$OwnedProcessIds = @($OwnedProcesses | Select-Object -ExpandProperty ProcessId)
|
|
$ListeningEndpoints = @(
|
|
if ($OwnedProcessIds.Count -gt 0)
|
|
{
|
|
Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue |
|
|
Where-Object { $OwnedProcessIds -contains [int]$_.OwningProcess } |
|
|
ForEach-Object {
|
|
[pscustomobject][ordered]@{
|
|
processId = [int]$_.OwningProcess
|
|
localAddress = [string]$_.LocalAddress
|
|
localPort = [int]$_.LocalPort
|
|
}
|
|
}
|
|
}
|
|
)
|
|
$FatalPatterns = @(
|
|
'Fatal error:',
|
|
'LowLevelFatalError',
|
|
'Assertion failed:',
|
|
'Critical error:',
|
|
'Unhandled Exception:',
|
|
'StaticShutdownAfterError'
|
|
)
|
|
$RuntimeLines = @(
|
|
if (Test-Path -LiteralPath $RuntimeLogPath)
|
|
{
|
|
Get-Content -LiteralPath $RuntimeLogPath
|
|
}
|
|
)
|
|
$DiagnosticsLines = @(
|
|
if (Test-Path -LiteralPath $DiagnosticsPath)
|
|
{
|
|
Get-Content -LiteralPath $DiagnosticsPath
|
|
}
|
|
)
|
|
$FatalLines = @(
|
|
@($RuntimeLines + $DiagnosticsLines) |
|
|
Where-Object {
|
|
$Line = $_.ToString()
|
|
@($FatalPatterns | Where-Object { $Line -like "*$_*" }).Count -gt 0
|
|
}
|
|
)
|
|
$Initialized = @(
|
|
$DiagnosticsLines |
|
|
Where-Object {
|
|
$_.ToString().IndexOf(
|
|
'[Process] HyperTwist runtime module initialized.',
|
|
[System.StringComparison]::OrdinalIgnoreCase
|
|
) -ge 0
|
|
}
|
|
).Count -gt 0
|
|
|
|
$StoppedProcessIds = @(Stop-OwnedProcesses -InstallRoot $InstallRoot)
|
|
$Passed = $OwnedProcessIds.Count -gt 0 `
|
|
-and (Test-Path -LiteralPath $DiagnosticsPath -PathType Leaf) `
|
|
-and $Initialized `
|
|
-and $FatalLines.Count -eq 0 `
|
|
-and @($ListeningEndpoints | Where-Object { $_.localPort -eq 1985 }).Count -eq 0
|
|
return [pscustomobject][ordered]@{
|
|
passed = $Passed
|
|
startedAtUtc = $StartedAtUtc.ToString('o')
|
|
launcherProcessId = [int]$LaunchProcess.Id
|
|
ownedProcessIds = $OwnedProcessIds
|
|
stoppedProcessIds = $StoppedProcessIds
|
|
runtimeLogPath = $RuntimeLogPath
|
|
diagnosticsPath = $DiagnosticsPath
|
|
diagnosticsInitialized = $Initialized
|
|
fatalLines = $FatalLines
|
|
listeningEndpoints = $ListeningEndpoints
|
|
}
|
|
}
|
|
|
|
function Get-ShortcutEvidence {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$Path
|
|
)
|
|
|
|
if (-not (Test-Path -LiteralPath $Path -PathType Leaf))
|
|
{
|
|
return [pscustomobject][ordered]@{
|
|
path = $Path
|
|
exists = $false
|
|
targetPath = $null
|
|
arguments = $null
|
|
}
|
|
}
|
|
|
|
$Shell = New-Object -ComObject WScript.Shell
|
|
$Shortcut = $null
|
|
try
|
|
{
|
|
$Shortcut = $Shell.CreateShortcut($Path)
|
|
return [pscustomobject][ordered]@{
|
|
path = $Path
|
|
exists = $true
|
|
targetPath = [string]$Shortcut.TargetPath
|
|
arguments = [string]$Shortcut.Arguments
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
if ($null -ne $Shortcut)
|
|
{
|
|
[void][System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($Shortcut)
|
|
}
|
|
[void][System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($Shell)
|
|
}
|
|
}
|
|
|
|
$CurrentIdentity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
|
$CurrentPrincipal = New-Object Security.Principal.WindowsPrincipal($CurrentIdentity)
|
|
if (-not $CurrentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator))
|
|
{
|
|
throw 'Installer lifecycle validation requires an elevated Windows session.'
|
|
}
|
|
if (-not (Test-Path -LiteralPath $InstallerPath -PathType Leaf))
|
|
{
|
|
throw "Installer '$InstallerPath' was not found."
|
|
}
|
|
|
|
$ResolvedInstallerPath = (Resolve-Path -LiteralPath $InstallerPath).Path
|
|
$ResolvedValidationRoot = [System.IO.Path]::GetFullPath($ValidationRoot)
|
|
$RunId = [DateTime]::UtcNow.ToString('yyyyMMddTHHmmssZ')
|
|
$RunDirectory = Join-Path $ResolvedValidationRoot $RunId
|
|
$InstallRoot = Join-Path $RunDirectory 'HyperTwist'
|
|
$EvidenceDirectory = Join-Path $RunDirectory 'evidence'
|
|
New-Item -ItemType Directory -Force -Path $EvidenceDirectory | Out-Null
|
|
if ([string]::IsNullOrWhiteSpace($ReportPath))
|
|
{
|
|
$ReportPath = Join-Path $EvidenceDirectory 'windows-installer-lifecycle-report.json'
|
|
}
|
|
|
|
$RepositoryRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..')).Path
|
|
$ReadinessScriptPath = Join-Path $RepositoryRoot 'scripts\Test-HyperTwistWindowsReadiness.ps1'
|
|
$UninstallRegistryPath = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\HyperTwist'
|
|
$ProductRegistryPath = 'HKLM:\SOFTWARE\HyperTwist\HyperTwist'
|
|
$CommonProgramsPath = [Environment]::GetFolderPath('CommonPrograms')
|
|
$CommonDesktopPath = [Environment]::GetFolderPath('CommonDesktopDirectory')
|
|
$StartMenuDirectory = Join-Path $CommonProgramsPath 'HyperTwist'
|
|
$DesktopShortcutPath = Join-Path $CommonDesktopPath 'HyperTwist.lnk'
|
|
|
|
if (Test-Path -LiteralPath $UninstallRegistryPath)
|
|
{
|
|
throw 'A registered HyperTwist installation already exists; validation will not replace it.'
|
|
}
|
|
|
|
$InstallerItem = Get-Item -LiteralPath $ResolvedInstallerPath
|
|
$Report = [ordered]@{
|
|
reportVersion = 'ht-windows-installer-lifecycle/v1'
|
|
generatedAtUtc = [DateTime]::UtcNow.ToString('o')
|
|
result = 'failed'
|
|
validationClass = 'isolated-current-host'
|
|
cleanMachineAvailable = $false
|
|
cleanMachineBoundary = 'No Windows Sandbox, clean Hyper-V VM, or base VHD was available on the validation host.'
|
|
installerPath = $ResolvedInstallerPath
|
|
installerSizeBytes = [Int64]$InstallerItem.Length
|
|
installerSha256 = (Get-FileHash -LiteralPath $ResolvedInstallerPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
|
installerSignatureStatus = (Get-AuthenticodeSignature -LiteralPath $ResolvedInstallerPath).Status.ToString()
|
|
installRoot = $InstallRoot
|
|
unownedNonEmptyRefusal = $null
|
|
firstInstallExitCode = $null
|
|
upgradeInstallExitCode = $null
|
|
payloadParityAfterFirstInstall = $null
|
|
payloadParityAfterUpgrade = $null
|
|
readiness = $null
|
|
launchSmoke = $null
|
|
uninstallExitCode = $null
|
|
registrationVerified = $false
|
|
shortcutsVerified = $false
|
|
shortcutDetails = $null
|
|
userDataPreserved = $false
|
|
installRootRemoved = $false
|
|
registrationRemoved = $false
|
|
shortcutsRemoved = $false
|
|
cleanupCompleted = $false
|
|
error = $null
|
|
}
|
|
|
|
$UninstallerPath = Join-Path $InstallRoot 'Uninstall HyperTwist.exe'
|
|
$SentinelDirectory = Join-Path $env:LOCALAPPDATA 'UnrealHyperTwist\Saved\InstallerValidation'
|
|
$SentinelPath = Join-Path $SentinelDirectory "$RunId.txt"
|
|
$Installed = $false
|
|
|
|
try
|
|
{
|
|
New-Item -ItemType Directory -Force -Path $InstallRoot | Out-Null
|
|
$UnownedSentinelPath = Join-Path $InstallRoot 'unrelated-owner-sentinel.txt'
|
|
[System.IO.File]::WriteAllText(
|
|
$UnownedSentinelPath,
|
|
"Unowned install-root refusal sentinel $RunId",
|
|
(New-Object System.Text.UTF8Encoding($false))
|
|
)
|
|
$UnownedRefusalExitCode = Invoke-InstallerProcessForExitCode `
|
|
-ExecutablePath $ResolvedInstallerPath `
|
|
-Arguments @('/S', "/D=$InstallRoot")
|
|
if ($UnownedRefusalExitCode -eq 0)
|
|
{
|
|
$Installed = Test-Path -LiteralPath $UninstallerPath -PathType Leaf
|
|
}
|
|
$Report.unownedNonEmptyRefusal = [pscustomobject][ordered]@{
|
|
passed = $UnownedRefusalExitCode -eq 14 `
|
|
-and (Test-Path -LiteralPath $UnownedSentinelPath -PathType Leaf) `
|
|
-and -not (Test-Path -LiteralPath $UninstallRegistryPath) `
|
|
-and -not (Test-Path -LiteralPath $ProductRegistryPath)
|
|
exitCode = $UnownedRefusalExitCode
|
|
expectedExitCode = 14
|
|
sentinelPreserved = Test-Path -LiteralPath $UnownedSentinelPath -PathType Leaf
|
|
registrationAbsent = `
|
|
-not (Test-Path -LiteralPath $UninstallRegistryPath) `
|
|
-and -not (Test-Path -LiteralPath $ProductRegistryPath)
|
|
}
|
|
if (-not $Report.unownedNonEmptyRefusal.passed)
|
|
{
|
|
throw 'The installer did not safely refuse an unowned non-empty installation root.'
|
|
}
|
|
Remove-Item -LiteralPath $InstallRoot -Recurse -Force
|
|
|
|
$Report.firstInstallExitCode = Invoke-InstallerProcess `
|
|
-ExecutablePath $ResolvedInstallerPath `
|
|
-Arguments @('/S', "/D=$InstallRoot") `
|
|
-Label 'Silent installer'
|
|
$Installed = $true
|
|
|
|
$ManifestPath = Join-Path $InstallRoot 'Installer\payload-manifest.json'
|
|
if (-not (Test-Path -LiteralPath $ManifestPath -PathType Leaf))
|
|
{
|
|
throw 'The installed payload manifest is missing.'
|
|
}
|
|
$Report.payloadParityAfterFirstInstall = Test-PayloadManifest `
|
|
-InstallRoot $InstallRoot `
|
|
-ManifestPath $ManifestPath
|
|
if (-not $Report.payloadParityAfterFirstInstall.passed)
|
|
{
|
|
throw 'Installed payload parity failed after first install.'
|
|
}
|
|
|
|
$RegisteredProduct = Get-ItemProperty -LiteralPath $UninstallRegistryPath
|
|
$RegisteredOwner = Get-ItemProperty -LiteralPath $ProductRegistryPath
|
|
$Report.registrationVerified = `
|
|
[System.IO.Path]::GetFullPath([string]$RegisteredProduct.InstallLocation) -eq $InstallRoot `
|
|
-and [System.IO.Path]::GetFullPath([string]$RegisteredOwner.InstallLocation) -eq $InstallRoot
|
|
if (-not $Report.registrationVerified)
|
|
{
|
|
throw 'Add/Remove Programs or product registration does not match the isolated install root.'
|
|
}
|
|
|
|
$ExpectedApplicationPath = Join-Path $InstallRoot 'UnrealHyperTwist.exe'
|
|
$ExpectedReadinessScriptPath = Join-Path $InstallRoot 'Installer\Test-HyperTwistWindowsReadiness.ps1'
|
|
$ExpectedManifestPath = Join-Path $InstallRoot 'Installer\payload-manifest.json'
|
|
$ExpectedUninstallerPath = Join-Path $InstallRoot 'Uninstall HyperTwist.exe'
|
|
$ApplicationShortcut = Get-ShortcutEvidence `
|
|
-Path (Join-Path $StartMenuDirectory 'HyperTwist.lnk')
|
|
$ReadinessShortcut = Get-ShortcutEvidence `
|
|
-Path (Join-Path $StartMenuDirectory 'System Readiness Report.lnk')
|
|
$UninstallShortcut = Get-ShortcutEvidence `
|
|
-Path (Join-Path $StartMenuDirectory 'Uninstall HyperTwist.lnk')
|
|
$DesktopShortcut = Get-ShortcutEvidence -Path $DesktopShortcutPath
|
|
$Report.shortcutDetails = [pscustomobject][ordered]@{
|
|
application = $ApplicationShortcut
|
|
readiness = $ReadinessShortcut
|
|
uninstall = $UninstallShortcut
|
|
desktop = $DesktopShortcut
|
|
}
|
|
$Report.shortcutsVerified = `
|
|
$ApplicationShortcut.exists `
|
|
-and $ApplicationShortcut.targetPath -eq $ExpectedApplicationPath `
|
|
-and $ReadinessShortcut.exists `
|
|
-and $ReadinessShortcut.targetPath.EndsWith(
|
|
'\WindowsPowerShell\v1.0\powershell.exe',
|
|
[System.StringComparison]::OrdinalIgnoreCase
|
|
) `
|
|
-and $ReadinessShortcut.arguments.Contains($ExpectedReadinessScriptPath) `
|
|
-and $ReadinessShortcut.arguments.Contains($ExpectedManifestPath) `
|
|
-and $ReadinessShortcut.arguments.Contains('-WaitForUser') `
|
|
-and $UninstallShortcut.exists `
|
|
-and $UninstallShortcut.targetPath -eq $ExpectedUninstallerPath `
|
|
-and $DesktopShortcut.exists `
|
|
-and $DesktopShortcut.targetPath -eq $ExpectedApplicationPath
|
|
if (-not $Report.shortcutsVerified)
|
|
{
|
|
throw 'One or more installer-managed shortcuts are missing or have incorrect targets or arguments.'
|
|
}
|
|
|
|
$ReadinessReportPath = Join-Path $EvidenceDirectory 'installed-readiness-report.json'
|
|
$Report.readiness = & $ReadinessScriptPath `
|
|
-PayloadRoot $InstallRoot `
|
|
-TargetInstallRoot $InstallRoot `
|
|
-ExpectedPayloadManifestPath $ManifestPath `
|
|
-ReportPath $ReadinessReportPath `
|
|
-VerifyPayloadHashes `
|
|
-PassThru
|
|
if ($Report.readiness.result -eq 'blocked')
|
|
{
|
|
throw 'Installed-host readiness validation is blocked.'
|
|
}
|
|
|
|
New-Item -ItemType Directory -Force -Path $SentinelDirectory | Out-Null
|
|
[System.IO.File]::WriteAllText(
|
|
$SentinelPath,
|
|
"HyperTwist installer validation sentinel $RunId",
|
|
(New-Object System.Text.UTF8Encoding($false))
|
|
)
|
|
|
|
$Report.launchSmoke = Invoke-InstalledLaunchSmoke `
|
|
-InstallRoot $InstallRoot `
|
|
-EvidenceDirectory $EvidenceDirectory `
|
|
-SmokeSeconds $LaunchSmokeSeconds
|
|
if (-not $Report.launchSmoke.passed)
|
|
{
|
|
throw 'Installed executable launch smoke failed.'
|
|
}
|
|
|
|
$Report.upgradeInstallExitCode = Invoke-InstallerProcess `
|
|
-ExecutablePath $ResolvedInstallerPath `
|
|
-Arguments @('/S', "/D=$InstallRoot") `
|
|
-Label 'Silent in-place upgrade'
|
|
$ManifestPath = Join-Path $InstallRoot 'Installer\payload-manifest.json'
|
|
$Report.payloadParityAfterUpgrade = Test-PayloadManifest `
|
|
-InstallRoot $InstallRoot `
|
|
-ManifestPath $ManifestPath
|
|
if (-not $Report.payloadParityAfterUpgrade.passed)
|
|
{
|
|
throw 'Installed payload parity failed after in-place upgrade.'
|
|
}
|
|
|
|
if (-not $KeepInstalled)
|
|
{
|
|
$Report.uninstallExitCode = Invoke-InstallerProcess `
|
|
-ExecutablePath $UninstallerPath `
|
|
-Arguments @('/S') `
|
|
-Label 'Silent uninstaller'
|
|
$Installed = $false
|
|
Start-Sleep -Seconds 3
|
|
|
|
$Report.userDataPreserved = Test-Path -LiteralPath $SentinelPath -PathType Leaf
|
|
$Report.installRootRemoved = -not (Test-Path -LiteralPath $InstallRoot)
|
|
$Report.registrationRemoved = `
|
|
-not (Test-Path -LiteralPath $UninstallRegistryPath) `
|
|
-and -not (Test-Path -LiteralPath $ProductRegistryPath)
|
|
$Report.shortcutsRemoved = `
|
|
-not (Test-Path -LiteralPath $StartMenuDirectory) `
|
|
-and -not (Test-Path -LiteralPath $DesktopShortcutPath)
|
|
|
|
if (-not $Report.userDataPreserved)
|
|
{
|
|
throw 'Default uninstall removed current-user data unexpectedly.'
|
|
}
|
|
if (-not $Report.installRootRemoved)
|
|
{
|
|
throw 'The isolated install root remains after uninstall.'
|
|
}
|
|
if (-not $Report.registrationRemoved)
|
|
{
|
|
throw 'Installer registry state remains after uninstall.'
|
|
}
|
|
if (-not $Report.shortcutsRemoved)
|
|
{
|
|
throw 'Installer-managed shortcuts remain after uninstall.'
|
|
}
|
|
}
|
|
|
|
$Report.cleanupCompleted = -not $KeepInstalled
|
|
$Report.result = 'passed'
|
|
}
|
|
catch
|
|
{
|
|
$Report.error = $_.Exception.Message
|
|
throw
|
|
}
|
|
finally
|
|
{
|
|
[void](Stop-OwnedProcesses -InstallRoot $InstallRoot)
|
|
if ($Installed -and -not $KeepInstalled -and (Test-Path -LiteralPath $UninstallerPath -PathType Leaf))
|
|
{
|
|
try
|
|
{
|
|
[void](Invoke-InstallerProcess `
|
|
-ExecutablePath $UninstallerPath `
|
|
-Arguments @('/S') `
|
|
-Label 'Failure-cleanup uninstaller')
|
|
Start-Sleep -Seconds 3
|
|
$Installed = $false
|
|
}
|
|
catch
|
|
{
|
|
if ([string]::IsNullOrWhiteSpace([string]$Report.error))
|
|
{
|
|
$Report.error = $_.Exception.Message
|
|
}
|
|
else
|
|
{
|
|
$Report.error = "$($Report.error) | Cleanup: $($_.Exception.Message)"
|
|
}
|
|
}
|
|
}
|
|
|
|
if (-not $KeepInstalled `
|
|
-and (Test-Path -LiteralPath $InstallRoot) `
|
|
-and -not (Test-Path -LiteralPath $UninstallRegistryPath) `
|
|
-and -not (Test-Path -LiteralPath $ProductRegistryPath))
|
|
{
|
|
Remove-Item -LiteralPath $InstallRoot -Recurse -Force
|
|
}
|
|
|
|
if (Test-Path -LiteralPath $SentinelPath)
|
|
{
|
|
Remove-Item -LiteralPath $SentinelPath -Force
|
|
}
|
|
if ((Test-Path -LiteralPath $SentinelDirectory) `
|
|
-and @(Get-ChildItem -LiteralPath $SentinelDirectory -Force).Count -eq 0)
|
|
{
|
|
Remove-Item -LiteralPath $SentinelDirectory -Force
|
|
}
|
|
|
|
if (-not $KeepInstalled)
|
|
{
|
|
$Report.installRootRemoved = -not (Test-Path -LiteralPath $InstallRoot)
|
|
$Report.registrationRemoved = `
|
|
-not (Test-Path -LiteralPath $UninstallRegistryPath) `
|
|
-and -not (Test-Path -LiteralPath $ProductRegistryPath)
|
|
$Report.shortcutsRemoved = `
|
|
-not (Test-Path -LiteralPath $StartMenuDirectory) `
|
|
-and -not (Test-Path -LiteralPath $DesktopShortcutPath)
|
|
$Report.cleanupCompleted = `
|
|
-not $Installed `
|
|
-and $Report.installRootRemoved `
|
|
-and $Report.registrationRemoved `
|
|
-and $Report.shortcutsRemoved
|
|
}
|
|
|
|
Write-Utf8JsonFile -Path $ReportPath -Value $Report
|
|
}
|
|
|
|
[Console]::Out.WriteLine('HyperTwist Windows installer lifecycle validation passed.')
|
|
[Console]::Out.WriteLine("Report: $([System.IO.Path]::GetFullPath($ReportPath))")
|