<# .SYNOPSIS Windows-executed test of Install-ScriptWorkerIsolation.ps1 (lmxopcua#883, #890, #901): apply -> re-apply -> verify -> shorter -DenyPaths -> -Verify -> folder swap -> -Verify fails -> -Reapply -> -Verify passes -> -Reapply unchanged -> Invoke-OtOpcUaInstallSwap.ps1 (re-apply + verify built in; refuses a pre-#901 record) -> -Scope InstallRoot/Outside after a swap, the swap's pre-stop gate, -NoStart, a -NoCarry rollback (lmxopcua#905) -> a process still running from InstallRoot after the stop: waited for, timed out clearly, and an unseen handle retried (lmxopcua#908) -> uninstall (from the record, without -DenyPaths) -> uninstall. ACL level only; the restricted-token proof that the worker cannot read the denied folders is InstallerLayoutIsolationTests (ScriptWorker.Tests, OTOPCUA_ISOLATION_INSTALLER_E2E=1). The dry-run harness (Test-ScriptWorkerIsolation.ps1) only asserts plan data; it cannot see host-specific failures such as DirectoryInfo.GetAccessControl missing on PowerShell 7, or StrictMode .Count on a scalar on Windows PowerShell 5.1. Run it once under EACH host: pwsh -NoProfile -File scripts\install\tests\Test-ScriptWorkerIsolation.Windows.ps1 powershell -NoProfile -ExecutionPolicy Bypass -File scripts\install\tests\Test-ScriptWorkerIsolation.Windows.ps1 lmxopcua#905 timing: -TimingFiles N also builds a vd03-like tree (a denied ApiInstall folder holding the install, a candidate and N more files in other app folders, 48 protected-DACL files), installs the isolation on it and times Invoke-OtOpcUaInstallSwap.ps1. -TimingOnly skips the functional part. -ScriptsDir runs the timing against another copy of scripts\install (e.g. the scripts before #905) for a before/after: pwsh -NoProfile -File ...\Test-ScriptWorkerIsolation.Windows.ps1 -TimingOnly -TimingFiles 17000 -ScriptsDir C:\old\install .DESCRIPTION Needs an elevated Windows session. It works in a scratch folder under $env:TEMP with a scratch worker service name and a scratch stand-in host service (a disabled service that is never started), and uses -HostServiceAccount 'NT AUTHORITY\SYSTEM', or with -LocalHostAccount a scratch local user (random password, never shown) that is deleted at the end. It does NOT touch OtOpcUaHost. The firewall rule name is fixed by the installer, so the test refuses to run (exit 2) when that rule already exists, i.e. on a node that has the real isolation installed. Everything it creates is removed in `finally`, including on failure. #> [CmdletBinding()] param([string]$WorkerServiceName = 'OtOpcUaIsoTestWorker', [string]$HostServiceName = 'OtOpcUaIsoTestHost', [switch]$LocalHostAccount, [string]$LocalHostAccountName = 'otopcuaIsoTestAcct', [int]$TimingFiles = 0, [switch]$TimingOnly, [string]$ScriptsDir) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' if ($PSVersionTable.PSEdition -eq 'Core' -and -not $IsWindows) { Write-Host 'SKIP: Windows only'; exit 0 } $principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { Write-Host 'run elevated'; exit 2 } $installer = Join-Path (Split-Path -Parent $PSScriptRoot) 'Install-ScriptWorkerIsolation.ps1' if (-not $ScriptsDir) { $ScriptsDir = Split-Path -Parent $PSScriptRoot } $exe = if ($PSVersionTable.PSEdition -eq 'Core') { (Get-Process -Id $PID).Path } else { 'powershell.exe' } Import-Module (Join-Path (Split-Path -Parent $PSScriptRoot) 'ScriptWorkerIsolation.psm1') -Force $ruleName = 'OtOpcUa-ScriptWorker-BlockOutbound' if (Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue) { Write-Host "refusing: firewall rule $ruleName already exists (real isolation installed on this node)"; exit 2 } foreach ($n in $WorkerServiceName, $HostServiceName) { if (Get-Service -Name $n -ErrorAction SilentlyContinue) { Write-Host "refusing: service $n already exists"; exit 2 } } if ($LocalHostAccount -and (Get-LocalUser -Name $LocalHostAccountName -ErrorAction SilentlyContinue)) { Write-Host "refusing: local user $LocalHostAccountName already exists"; exit 2 } $script:hostAccount = 'NT AUTHORITY\SYSTEM' $script:failures = 0; $script:passes = 0 function Assert([bool]$Condition, [string]$Message) { if ($Condition) { $script:passes++ } else { $script:failures++; Write-Host " FAIL: $Message" -ForegroundColor Red } } function Invoke-Installer([string[]]$Extra) { # $Extra: e.g. @('-DenyPaths', 'a,b') or @('-Uninstall'); -File passes an array parameter as one string, which the installer splits. # Same PowerShell host as this test; the installer's output lines are ' [outcome ] StepId target'. $exe = if ($PSVersionTable.PSEdition -eq 'Core') { (Get-Process -Id $PID).Path } else { 'powershell.exe' } $out = & $exe -NoProfile -ExecutionPolicy Bypass -File $installer -InstallRoot $root -HostServiceAccount $script:hostAccount ` -PeCacheDir $cache -WorkerServiceName $WorkerServiceName -HostServiceName $HostServiceName -ConfigureHostService @Extra 2>&1 | Out-String $steps = @([regex]::Matches($out, '\[(?[a-z]+)\s*\]\s+(?[A-Za-z]+)\s') | ForEach-Object { [pscustomobject]@{ Outcome = $_.Groups['o'].Value; Id = $_.Groups['id'].Value } }) [pscustomobject]@{ Exit = $LASTEXITCODE; Output = $out; Steps = $steps } } function Invoke-Mode([string]$Mode, [string[]]$Extra = @()) { # lmxopcua#901: -Reapply / -Verify with NO settings argument, only the scratch worker name (the record selector). $exe = if ($PSVersionTable.PSEdition -eq 'Core') { (Get-Process -Id $PID).Path } else { 'powershell.exe' } # Continue: an expected failure writes to stderr, which Stop would turn into a terminating error here. Whitespace is # collapsed (and pwsh's '|' margins dropped) because both hosts wrap the error text at the console width. $out = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $installer "-$Mode" -WorkerServiceName $WorkerServiceName @Extra 2>&1 | Out-String } $steps = @([regex]::Matches($out, '\[(?[a-z]+)\s*\]\s+(?[A-Za-z]+)\s+(?\S+)') | ForEach-Object { [pscustomobject]@{ Outcome = $_.Groups['o'].Value; Id = $_.Groups['id'].Value; Target = $_.Groups['t'].Value } }) $code = $LASTEXITCODE $missing = @([regex]::Matches($out, '\[MISSING\]\s+(?[A-Za-z]+)\s+(?\S+)') | ForEach-Object { "$($_.Groups['id'].Value) $($_.Groups['t'].Value)" }) [pscustomobject]@{ Exit = $code; Output = $out; Flat = ($out -replace '[\s|]+', ' '); Steps = $steps; Missing = $missing } } function Show([string]$Label, $R) { # The transcript the issue asks for: one line per run. $what = if ($R.Missing.Count) { "MISSING: $($R.Missing -join ' | ')" } else { ($R.Steps | Group-Object Outcome | ForEach-Object { "$($_.Count) $($_.Name)" }) -join ', ' } Write-Host (" {0,-34} exit {1}; {2}" -f $Label, $R.Exit, $what) } function Get-Explicit([string]$Path, [string]$Sid) { $id = [System.Security.Principal.SecurityIdentifier]::new($Sid) @((Get-Acl -LiteralPath $Path).GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) | Where-Object { $_.IdentityReference -eq $id }) } $scratch = Join-Path ([IO.Path]::GetTempPath()) ("otopcua-iso-test-" + [guid]::NewGuid().ToString('N').Substring(0, 8)) $root = Join-Path $scratch 'install' $cache = Join-Path $scratch 'pe-cache' # lmxopcua#890: the deployment folder + keys + secrets. pki\ inside the install root, as on vd03 (lmxopcua#901: the swap re-creates it). $deny = @($scratch, (Join-Path $root 'pki'), (Join-Path $scratch 'data')) $recordKey = "HKLM:\SYSTEM\CurrentControlSet\Services\$WorkerServiceName\Parameters" $workerExe = Join-Path $root 'script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe' $workerSid = Get-OtOpcUaServiceSid -Name $WorkerServiceName $hostKey = "HKLM:\SYSTEM\CurrentControlSet\Services\$HostServiceName" try { New-Item -ItemType Directory -Path (Split-Path $workerExe) -Force | Out-Null Set-Content -LiteralPath $workerExe -Value 'stub' foreach ($d in $deny) { New-Item -ItemType Directory -Path $d -Force | Out-Null } if ($LocalHostAccount) { $pw = [Security.SecureString]::new() foreach ($ch in ([Convert]::ToBase64String((1..24 | ForEach-Object { [byte](Get-Random -Maximum 256) })) + 'aA1!').ToCharArray()) { $pw.AppendChar($ch) } New-LocalUser -Name $LocalHostAccountName -Password $pw -AccountNeverExpires -Description 'lmxopcua#901 isolation test (scratch)' | Out-Null $script:hostAccount = ".\$LocalHostAccountName" } Set-Content -LiteralPath (Join-Path $root 'appsettings.json') -Value '{}' & sc.exe create $HostServiceName binPath= "$env:SystemRoot\System32\cmd.exe" start= disabled | Out-Null if ($LASTEXITCODE -ne 0) { throw "sc.exe create $HostServiceName failed" } Set-ItemProperty -Path $hostKey -Name Environment -Type MultiString -Value ([string[]]@('Keep__Me=1')) Write-Host "Host: $($PSVersionTable.PSEdition) $($PSVersionTable.PSVersion); scratch $scratch; host account $script:hostAccount" if (-not $TimingOnly) { Write-Host 'Dry run' $dry = Invoke-Installer @('-WhatIf', '-DenyPaths', ($deny -join ',')) Assert ($dry.Exit -eq 0) "-WhatIf exits 0 ($($dry.Output))" Assert (-not (Get-Service $WorkerServiceName -ErrorAction SilentlyContinue) -and -not (Test-Path $cache) -and -not (Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue)) '-WhatIf changed nothing' Write-Host 'Apply' $a = Invoke-Installer @('-DenyPaths', ($deny -join ',')) Assert ($a.Exit -eq 0) "apply exits 0 ($($a.Output))" Assert ($a.Steps.Count -eq 12 -and @($a.Steps | Where-Object Outcome -ne 'changed').Count -eq 0) "apply: 12 steps, all changed ($(($a.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))" Assert ((Get-CimInstance Win32_Service -Filter "Name='$WorkerServiceName'").StartMode -eq 'Disabled') 'anchor service is Disabled' Assert ((& sc.exe showsid $WorkerServiceName | Out-String) -match [regex]::Escape($workerSid)) 'anchor SID matches Get-OtOpcUaServiceSid' Assert ((Get-CimInstance Win32_Service -Filter "Name='$WorkerServiceName'").PathName -eq "`"$workerExe`"") 'anchor binPath is stored quoted (Windows PowerShell 5.1 used to drop the quotes)' $pe = @(Get-Explicit (Join-Path $cache 'pe') $workerSid) Assert ($pe.Count -eq 1 -and $pe[0].AccessControlType -eq 'Allow') 'worker has one Allow ACE on \pe' Assert (@(Get-Explicit $cache $workerSid).Count -eq 0) 'worker has no ACE on the PeCacheDir root' Assert ((Get-Acl -LiteralPath $cache).AreAccessRulesProtected) 'PeCacheDir DACL is protected' $rootDeny = @(Get-Explicit $root $workerSid) Assert ($rootDeny.Count -eq 1 -and $rootDeny[0].AccessControlType -eq 'Deny') 'worker has one Deny ACE on the install root' Assert (@(Get-Explicit (Join-Path $root 'script-worker') $workerSid).Count -eq 1) 'worker has one ACE on script-worker\' foreach ($d in $deny) { $dd = @(Get-Explicit $d $workerSid) Assert ($dd.Count -eq 1 -and $dd[0].AccessControlType -eq 'Deny' -and $dd[0].InheritanceFlags -eq 'ContainerInherit, ObjectInherit') "worker has one inherited Deny ACE on $d" } Assert (((Get-ItemProperty -Path $recordKey -Name DenyPaths).DenyPaths -join '|') -eq ($deny -join '|')) 'the denied folders are recorded under the anchor key' $hostDeny = @((Get-Acl -LiteralPath $scratch).GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) | Where-Object { $_.AccessControlType -eq 'Deny' -and $_.IdentityReference.Value -ne $workerSid }) Assert ($hostDeny.Count -eq 0) 'no deny for anyone but the worker identity (the host account is unaffected)' $fw = Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue Assert ($null -ne $fw -and "$($fw.Direction)" -eq 'Outbound' -and "$($fw.Action)" -eq 'Block' -and (($fw | Get-NetFirewallApplicationFilter).Program -ieq $workerExe)) 'firewall rule is Outbound/Block on the worker exe' $env1 = @((Get-ItemProperty -Path $hostKey -Name Environment).Environment) Assert (($env1 -contains 'Keep__Me=1') -and (@($env1 | Where-Object { $_ -like 'Scripting__Worker__Identity=*' }).Count -eq 1) -and (@($env1 | Where-Object { $_ -like 'Scripting__Worker__PeCacheDir=*' }).Count -eq 1)) 'host env has both Scripting__Worker__* entries and keeps the others' Write-Host 'Re-apply (idempotent)' $b = Invoke-Installer @('-DenyPaths', ($deny -join ',')) Assert ($b.Exit -eq 0) "re-apply exits 0 ($($b.Output))" Assert ($b.Steps.Count -eq 12 -and @($b.Steps | Where-Object Outcome -ne 'unchanged').Count -eq 0) "re-apply: 12 steps, all unchanged ($(($b.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))" Write-Host 'Re-apply with data\ dropped from -DenyPaths: its deny goes, the others stay' $c = Invoke-Installer @('-DenyPaths', (($deny | Select-Object -First 2) -join ',')) Assert ($c.Exit -eq 0) "shorter re-apply exits 0 ($($c.Output))" $changedIds = @($c.Steps | Where-Object Outcome -eq 'changed' | ForEach-Object Id) -join ',' Assert ($changedIds -eq 'SensitiveDirDeny,DenyPathRecord') "only the stale deny and the record change ($changedIds)" Assert (@(Get-Explicit $deny[2] $workerSid).Count -eq 0 -and @(Get-Explicit $deny[1] $workerSid).Count -eq 1) 'data\ un-denied, pki\ still denied' $c2 = Invoke-Installer @('-DenyPaths', ($deny -join ',')) Assert ($c2.Exit -eq 0 -and @($c2.Steps | Where-Object Outcome -eq 'changed').Count -eq 2) 'putting data\ back changes its deny and the record only' Write-Host 'Settings record (lmxopcua#901)' $recVals = Get-ItemProperty -Path $recordKey Assert ($recVals.InstallRoot -eq $root -and $recVals.HostServiceAccount -eq $script:hostAccount -and $recVals.PeCacheDir -eq $cache -and $recVals.HostServiceName -eq $HostServiceName -and $recVals.ConfigureHostService -eq 1) 'install root, host account, PE cache, host service and the flag are recorded' Assert (Test-OtOpcUaRecordKeyAcl -Key $recordKey) "record key DACL is protected SYSTEM + Administrators ($((Get-Acl -Path $recordKey).GetSecurityDescriptorSddlForm('Access')))" $settings = Get-OtOpcUaIsolationSettings -WorkerServiceName $WorkerServiceName Assert ($null -ne $settings -and (@($settings.DenyPaths) -join '|') -eq ($deny -join '|') -and $settings.ConfigureHostService) 'Get-OtOpcUaIsolationSettings reads it back' Write-Host 'Swap -> Verify fails -> Reapply -> Verify passes -> Reapply unchanged (lmxopcua#901)' $v0 = Invoke-Mode 'Verify'; Show 'Verify (installed)' $v0 Assert ($v0.Exit -eq 0 -and $v0.Output -match 'VERIFY OK') "verify passes on a fresh install ($($v0.Output))" # The swap as the deploy packs did it: build the candidate beside the live folder, move live aside, move candidate in, # copy pki\ and appsettings back (robocopy without ACLs, as a new folder). $cand = Join-Path $scratch 'install.new' New-Item -ItemType Directory -Path (Join-Path $cand 'script-worker') -Force | Out-Null Set-Content -LiteralPath (Join-Path $cand 'script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe') -Value 'stub v2' $prev1 = Join-Path $scratch 'install.bak.1' Move-Item -LiteralPath $root -Destination $prev1 Move-Item -LiteralPath $cand -Destination $root & robocopy (Join-Path $prev1 'pki') (Join-Path $root 'pki') /E /COPY:DAT /R:0 /W:0 /NFL /NDL /NJH /NJS /NP | Out-Null Copy-Item -LiteralPath (Join-Path $prev1 'appsettings.json') -Destination $root $v1 = Invoke-Mode 'Verify'; Show 'Verify (after the swap)' $v1 Assert ($v1.Exit -eq 1 -and $v1.Output -match 'VERIFY FAILED') "verify exits 1 after the swap ($($v1.Exit))" $expectMissing = @("WorkerFolderAcl $root\script-worker", "InstallRootDeny $root", "SensitiveDirDeny $root\pki") Assert ((@($v1.Missing | Sort-Object) -join '|') -eq (@($expectMissing | Sort-Object) -join '|')) "exactly the three folder ACEs the swap drops are MISSING ($($v1.Missing -join ' | '))" $r1 = Invoke-Mode 'Reapply'; Show 'Reapply (no arguments)' $r1 Assert ($r1.Exit -eq 0 -and $r1.Output -match 'settings from the record') "reapply exits 0 and says it used the record ($($r1.Output))" $r1Changed = @($r1.Steps | Where-Object Outcome -eq 'changed' | ForEach-Object { "$($_.Id) $($_.Target)" }) Assert ($r1.Steps.Count -eq 12 -and (@($r1Changed | Sort-Object) -join '|') -eq (@($expectMissing | Sort-Object) -join '|')) "reapply changes exactly the missing three ($($r1Changed -join ' | '))" $v2 = Invoke-Mode 'Verify'; Show 'Verify (after Reapply)' $v2 Assert ($v2.Exit -eq 0 -and $v2.Output -match 'VERIFY OK') "verify passes after the reapply ($($v2.Exit))" $r2 = Invoke-Mode 'Reapply'; Show 'Reapply again' $r2 Assert ($r2.Exit -eq 0 -and $r2.Steps.Count -eq 12 -and @($r2.Steps | Where-Object Outcome -ne 'unchanged').Count -eq 0) "second reapply: 12 steps, all unchanged ($(($r2.Steps | ForEach-Object Outcome) -join ','))" Assert ($r2.Output -match 'summary: 12 step\(s\), 0 changed, 12 unchanged') 'the summary line says 0 changed' Write-Host 'Negative control: Verify catches each kind of loss' & icacls $cache /grant '*S-1-5-32-545:(OI)(CI)(RX)' /Q | Out-Null # Users on the PE cache root Remove-NetFirewallRule -Name $ruleName $v3 = Invoke-Mode 'Verify'; Show 'Verify (cache ACL + rule broken)' $v3 Assert ($v3.Exit -eq 1 -and ($v3.Missing -join '|') -match 'PeCacheDir' -and ($v3.Missing -join '|') -match 'FirewallBlockOutbound') "verify flags the PE cache ACL and the missing rule ($($v3.Missing -join ' | '))" $r3 = Invoke-Mode 'Reapply'; $v4 = Invoke-Mode 'Verify'; Show 'Verify (after Reapply)' $v4 Assert ($r3.Exit -eq 0 -and $v4.Exit -eq 0) 'reapply repairs them' Write-Host 'Invoke-OtOpcUaInstallSwap.ps1: swap + Reapply + Verify in one step (no service control in the scratch test)' $swapScript = Join-Path (Split-Path -Parent $PSScriptRoot) 'Invoke-OtOpcUaInstallSwap.ps1' $exe = if ($PSVersionTable.PSEdition -eq 'Core') { (Get-Process -Id $PID).Path } else { 'powershell.exe' } $cand2 = Join-Path $scratch 'install.new2' New-Item -ItemType Directory -Path (Join-Path $cand2 'script-worker') -Force | Out-Null Set-Content -LiteralPath (Join-Path $cand2 'script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe') -Value 'stub v3' $prev2 = Join-Path $scratch 'install.bak.2' $sw = & $exe -NoProfile -ExecutionPolicy Bypass -File $swapScript -InstallRoot $root -Candidate $cand2 -PreviousInstall $prev2 ` -Carry 'appsettings*.json,pki' -NoServiceControl -WorkerServiceName $WorkerServiceName 2>&1 | Out-String $swExit = $LASTEXITCODE Write-Host (" {0,-34} exit {1}; {2}" -f 'Invoke-OtOpcUaInstallSwap', $swExit, $(if ($sw -match 'VERIFY OK') { 'VERIFY OK after its Reapply' } else { 'no VERIFY OK' })) Assert ($swExit -eq 0 -and $sw -match 'VERIFY OK' -and $sw -match 'Swap complete') "swap script exits 0 with the gate passed ($sw)" Assert ((Get-Content -LiteralPath (Join-Path $root 'script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe')) -eq 'stub v3' -and (Test-Path (Join-Path $root 'pki')) -and (Test-Path (Join-Path $root 'appsettings.json'))) 'the candidate is live and pki\ + appsettings were carried' Assert (@(Get-Explicit $root $workerSid).Count -eq 1 -and @(Get-Explicit (Join-Path $root 'script-worker') $workerSid).Count -eq 1 -and @(Get-Explicit (Join-Path $root 'pki') $workerSid).Count -eq 1) 'the three folder ACEs are back on the swapped-in folder' Write-Host 'Swap refuses a pre-#901 record (DenyPaths only) before changing anything' $saved = (Get-ItemProperty -Path $recordKey).InstallRoot Remove-ItemProperty -Path $recordKey -Name InstallRoot $cand3 = Join-Path $scratch 'install.new3' New-Item -ItemType Directory -Path (Join-Path $cand3 'script-worker') -Force | Out-Null $sw2 = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $swapScript -InstallRoot $root -Candidate $cand3 ` -PreviousInstall (Join-Path $scratch 'install.bak.3') -NoServiceControl -WorkerServiceName $WorkerServiceName 2>&1 | Out-String } $sw2Exit = $LASTEXITCODE Write-Host (" {0,-34} exit {1}; {2}" -f 'Invoke-OtOpcUaInstallSwap (pre-#901)', $sw2Exit, $(if (($sw2 -replace '[\s|]+', ' ') -match 'older than lmxopcua#901') { 'refused: settings not recorded' } else { 'NOT refused' })) Assert ($sw2Exit -ne 0 -and ($sw2 -replace '[\s|]+', ' ') -match 'older than lmxopcua#901' -and (Test-Path $cand3) -and -not (Test-Path (Join-Path $scratch 'install.bak.3'))) "refused, nothing moved ($sw2)" $r4 = Invoke-Mode 'Reapply' Show 'Reapply (pre-#901 record)' $r4 Assert ($r4.Exit -ne 0 -and $r4.Flat -match 'full arguments') "-Reapply without a full record refuses and says how to fix it ($($r4.Exit))" Set-ItemProperty -Path $recordKey -Name InstallRoot -Value $saved Write-Host 'Scoped -Reapply / -Verify after a raw swap (lmxopcua#905)' $cand4 = Join-Path $scratch 'install.new4' New-Item -ItemType Directory -Path (Join-Path $cand4 'script-worker') -Force | Out-Null Set-Content -LiteralPath (Join-Path $cand4 'script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe') -Value 'stub v4' $prev4 = Join-Path $scratch 'install.bak.4' Move-Item -LiteralPath $root -Destination $prev4 Move-Item -LiteralPath $cand4 -Destination $root & robocopy (Join-Path $prev4 'pki') (Join-Path $root 'pki') /E /COPY:DAT /R:0 /W:0 /NFL /NDL /NJH /NJS /NP | Out-Null $vo = Invoke-Mode 'Verify' @('-Scope', 'Outside'); Show 'Verify -Scope Outside (after swap)' $vo Assert ($vo.Exit -eq 0 -and $vo.Output -match 'VERIFY OK \(scope Outside\): 9/9') "a swap leaves the 9 Outside steps intact ($($vo.Output))" $vi = Invoke-Mode 'Verify' @('-Scope', 'InstallRoot'); Show 'Verify -Scope InstallRoot (after swap)' $vi Assert ($vi.Exit -eq 1 -and (@($vi.Missing | Sort-Object) -join '|') -eq (@($expectMissing | Sort-Object) -join '|') -and ($vi.Steps.Count + $vi.Missing.Count) -eq 3) ` "the InstallRoot scope checks 3 steps and finds exactly the three dropped ACEs ($($vi.Missing -join ' | '))" $ri = Invoke-Mode 'Reapply' @('-Scope', 'InstallRoot'); Show 'Reapply -Scope InstallRoot' $ri $riChanged = @($ri.Steps | Where-Object Outcome -eq 'changed' | ForEach-Object { "$($_.Id) $($_.Target)" }) Assert ($ri.Exit -eq 0 -and $ri.Steps.Count -eq 3 -and (@($riChanged | Sort-Object) -join '|') -eq (@($expectMissing | Sort-Object) -join '|')) ` "scoped reapply runs 3 steps and changes exactly the three ($($riChanged -join ' | '))" $covLine = [regex]::Match($ri.Flat, 'coverage: \d+ denied folder.s.; \d+ folder.s. walked').Value Assert ($ri.Flat -match 'coverage: 4 denied folder\(s\); 1 folder\(s\) walked') "scoped reapply walks InstallRoot only ($covLine)" $vi2 = Invoke-Mode 'Verify' @('-Scope', 'InstallRoot'); $vAll = Invoke-Mode 'Verify'; Show 'Verify (full, after scoped Reapply)' $vAll Assert ($vi2.Exit -eq 0 -and $vAll.Exit -eq 0 -and $vAll.Output -match 'VERIFY OK: 12/12') 'after the scoped reapply both the scoped and the full verify pass' $scanLine = [regex]::Match($vAll.Flat, 'coverage scan: \d+ folder.s. walked').Value Assert ($vAll.Flat -match 'coverage scan: 1 folder\(s\) walked') "the full verify walks the outermost denied folder once ($scanLine)" Write-Host 'Negative control: each scope sees only its own entries' & icacls $cache /grant '*S-1-5-32-545:(OI)(CI)(RX)' /Q | Out-Null # an Outside entry broken $vi3 = Invoke-Mode 'Verify' @('-Scope', 'InstallRoot'); $vo3 = Invoke-Mode 'Verify' @('-Scope', 'Outside'); Show 'Verify -Scope Outside (cache broken)' $vo3 Assert ($vi3.Exit -eq 0 -and $vo3.Exit -eq 1 -and ($vo3.Missing -join '|') -eq "PeCacheDir $cache") "the cache ACL is Outside's to catch, not InstallRoot's ($($vo3.Missing -join ' | '))" $ro3 = Invoke-Mode 'Reapply' @('-Scope', 'Outside') Assert ($ro3.Exit -eq 0 -and (@($ro3.Steps | Where-Object Outcome -eq 'changed' | ForEach-Object Id) -join ',') -eq 'PeCacheDir' -and $ro3.Steps.Count -eq 9) 'Reapply -Scope Outside repairs it (9 steps, 1 changed)' Write-Host 'Coverage scan: protected items found once, script-worker\ skipped, scope-limited' $prot = Join-Path $root 'pki\own.key'; $protSkip = Join-Path $root 'script-worker\private.key' foreach ($f in $prot, $protSkip) { Set-Content -LiteralPath $f -Value 'k'; & icacls $f /inheritance:r /grant:r '*S-1-5-18:(F)' '*S-1-5-32-544:(F)' /Q | Out-Null } $squash = { param($t) $t -replace '[\s|]+', '' } $vc = Invoke-Mode 'Verify' @('-Scope', 'InstallRoot'); $vco = Invoke-Mode 'Verify' @('-Scope', 'Outside'); $vca = Invoke-Mode 'Verify' $hit = (& $squash $prot) + 'doesnotinherit' Assert ($vc.Exit -eq 0 -and (& $squash $vc.Output).Contains($hit)) 'InstallRoot scope warns the protected pki\ file (a warning, not a failure)' Assert (-not (& $squash $vc.Output).Contains((& $squash $protSkip))) 'script-worker\ is not walked' Assert (-not (& $squash $vco.Output).Contains($hit)) 'the Outside scope skips InstallRoot' Assert (([regex]::Matches((& $squash $vca.Output), [regex]::Escape($hit))).Count -eq 1) 'the full verify reports it exactly once (pki\ is inside two denied folders)' foreach ($f in $prot, $protSkip) { & icacls $f /reset /Q | Out-Null; Remove-Item -LiteralPath $f -Force } Write-Host 'Invoke-OtOpcUaInstallSwap.ps1 refuses a candidate without the worker exe the firewall rule names (lmxopcua#905)' $cand6 = Join-Path $scratch 'install.new6' New-Item -ItemType Directory -Path (Join-Path $cand6 'script-worker') -Force | Out-Null $sw6 = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $swapScript -InstallRoot $root -Candidate $cand6 ` -PreviousInstall (Join-Path $scratch 'install.bak.6') -NoServiceControl -WorkerServiceName $WorkerServiceName 2>&1 | Out-String } $sw6Exit = $LASTEXITCODE Assert ($sw6Exit -ne 0 -and ($sw6 -replace '[\s|]+', '') -match 'candidatehasno' -and (Test-Path $cand6) -and -not (Test-Path (Join-Path $scratch 'install.bak.6')) -and $sw6 -notmatch 'Scope Outside') "refused in preflight, before the pre-stop isolation; nothing moved ($sw6)" Remove-Item -LiteralPath $cand6 -Recurse -Force Write-Host 'Invoke-OtOpcUaInstallSwap.ps1: the pre-stop gate stops before anything moves' $dataDir = Join-Path $scratch 'data' & icacls $dataDir /reset /Q | Out-Null; Remove-Item -LiteralPath $dataDir -Force # an Outside deny folder gone: -Reapply -Scope Outside refuses $cand5 = Join-Path $scratch 'install.new5' New-Item -ItemType Directory -Path (Join-Path $cand5 'script-worker') -Force | Out-Null Set-Content -LiteralPath (Join-Path $cand5 'script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe') -Value 'stub v5' $prev5 = Join-Path $scratch 'install.bak.5' $sw3 = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $swapScript -InstallRoot $root -Candidate $cand5 ` -PreviousInstall $prev5 -Carry 'appsettings*.json,pki' -ServiceName $HostServiceName -WorkerServiceName $WorkerServiceName 2>&1 | Out-String } $sw3Exit = $LASTEXITCODE Write-Host (" {0,-34} exit {1}; {2}" -f 'swap with an Outside gap', $sw3Exit, $(if ((& $squash $sw3) -match 'ScopeOutsidefailed') { 'refused before the stop' } else { 'NOT refused' })) Assert ($sw3Exit -ne 0 -and (& $squash $sw3) -match 'ScopeOutsidefailed.*nothingstoppedorswapped' -and (Test-Path $cand5) -and -not (Test-Path $prev5) -and (Get-Content -LiteralPath $workerExe) -eq 'stub v4' -and $sw3 -notmatch '==> stop') "refused before the stop, nothing moved ($sw3)" New-Item -ItemType Directory -Path $dataDir -Force | Out-Null Write-Host 'Invoke-OtOpcUaInstallSwap.ps1 -NoStart with service control (the stand-in host service is disabled: a start would fail)' $sw4 = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $swapScript -InstallRoot $root -Candidate $cand5 ` -PreviousInstall $prev5 -Carry 'appsettings*.json,pki' -ServiceName $HostServiceName -NoStart -WorkerServiceName $WorkerServiceName 2>&1 | Out-String } $sw4Exit = $LASTEXITCODE $w4 = [regex]::Match($sw4, 'stop -> swapped and verified \(left stopped\): [^\r\n]*').Value Write-Host (" {0,-34} exit {1}; {2}" -f 'swap -NoStart', $sw4Exit, $w4) Assert ($sw4Exit -eq 0 -and $sw4 -match 'Swap complete' -and $w4 -and $sw4 -notmatch '==> start' -and (Get-Service $HostServiceName).Status -eq 'Stopped') "-NoStart: swapped, verified, not started ($sw4)" Assert ($sw4 -match 'pre-stop isolation' -and $sw4 -match 'VERIFY OK \(scope Outside\)' -and $sw4 -match 'VERIFY OK \(scope InstallRoot\)') 'both scoped gates ran (Outside before the stop, InstallRoot after the swap)' Assert ($sw4.IndexOf('VERIFY OK (scope Outside)') -lt $sw4.IndexOf('==> stop') -and $sw4.IndexOf('==> stop') -lt $sw4.IndexOf('-Reapply -Scope InstallRoot')) 'Outside gate before the stop, InstallRoot after it' Assert ((Get-Content -LiteralPath $workerExe) -eq 'stub v5' -and @(Get-Explicit $root $workerSid).Count -eq 1 -and @(Get-Explicit (Join-Path $root 'pki') $workerSid).Count -eq 1) 'v5 live, its ACEs in place' Write-Host 'Invoke-OtOpcUaInstallSwap.ps1 -NoCarry: the rollback swaps the previous install back' $failed5 = Join-Path $scratch 'install.failed.5' $sw5 = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $swapScript -InstallRoot $root -Candidate $prev5 ` -PreviousInstall $failed5 -NoCarry -NoServiceControl -WorkerServiceName $WorkerServiceName 2>&1 | Out-String } $sw5Exit = $LASTEXITCODE Write-Host (" {0,-34} exit {1}; {2}" -f 'swap -NoCarry (rollback)', $sw5Exit, [regex]::Match($sw5, 'swap window[^\r\n]*').Value) Assert ($sw5Exit -eq 0 -and $sw5 -notmatch 'carried' -and (Get-Content -LiteralPath $workerExe) -eq 'stub v4' -and $sw5 -match 'VERIFY OK \(scope InstallRoot\)') "rollback: v4 back, nothing carried, gate passed ($sw5)" $vEnd = Invoke-Mode 'Verify' Assert ($vEnd.Exit -eq 0) 'full verify passes after the rollback' # lmxopcua#908: on QA the host was still exiting when SCM said Stopped, and the first folder move failed "in use". # Stand-in for that host: a copy of powershell.exe under InstallRoot (so its image is under InstallRoot) holding # logs\host908.log open (FileShare.ReadWrite, no Delete: a folder rename fails while it is open; a running image alone # does not block it, checked on windev) until a release file appears. The swap runs as a child whose output is # watched, so the holder is released a fixed time after the swap reaches a given step (its pre-stop isolation gate # takes several seconds, longer than any fixed holder lifetime would be safe against). $winPs = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' $holderLog = Join-Path $root 'logs\host908.log' $release = Join-Path $scratch 'release908' function Start-InstallHolder([string]$Image) { Remove-Item -LiteralPath $release -Force -ErrorAction SilentlyContinue New-Item -ItemType Directory -Path (Join-Path $root 'logs') -Force | Out-Null if ($Image -like "$root\*") { New-Item -ItemType Directory -Path (Split-Path -Parent $Image) -Force | Out-Null; Copy-Item -LiteralPath $winPs -Destination $Image -Force } $cmd = "`$f = [IO.File]::Open('$holderLog', 'OpenOrCreate', 'Write', 'ReadWrite'); `$t = [Diagnostics.Stopwatch]::StartNew(); " + "while (-not (Test-Path -LiteralPath '$release') -and `$t.Elapsed.TotalSeconds -lt 120) { Start-Sleep -Milliseconds 50 }; `$f.Dispose()" $enc = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($cmd)) $p = Start-Process -FilePath $Image -ArgumentList '-NoProfile', '-NonInteractive', '-EncodedCommand', $enc -WindowStyle Hidden -PassThru $deadline = [DateTime]::UtcNow.AddSeconds(15) while ([DateTime]::UtcNow -lt $deadline -and -not $p.HasExited -and -not (Test-Path -LiteralPath $holderLog)) { Start-Sleep -Milliseconds 100 } Start-Sleep -Milliseconds 300 if ($p.HasExited) { throw "holder exited early ($($p.ExitCode))" } $p } function Invoke-SwapReleasing([string[]]$SwapArgs, [string]$Marker, [int]$ReleaseAfterMs, $Holder) { # Runs the swap; ReleaseAfterMs after $Marker appears in its output, releases the holder. Returns output + exit code. $out = Join-Path $scratch 'swap908.out'; $err = Join-Path $scratch 'swap908.err' $argList = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', "`"$swapScript`"") + ($SwapArgs | ForEach-Object { if ($_ -match '\s') { "`"$_`"" } else { $_ } }) $sp = Start-Process -FilePath $exe -ArgumentList $argList -RedirectStandardOutput $out -RedirectStandardError $err -WindowStyle Hidden -PassThru $null = $sp.Handle # Windows PowerShell 5.1: without an open handle ExitCode is $null after the exit $seenAt = $null while (-not $sp.HasExited) { if (-not $seenAt -and (Test-Path $out) -and ((Get-Content -LiteralPath $out -Raw -ErrorAction SilentlyContinue) -like "*$Marker*")) { $seenAt = [DateTime]::UtcNow } if ($seenAt -and ([DateTime]::UtcNow - $seenAt).TotalMilliseconds -ge $ReleaseAfterMs -and -not (Test-Path $release)) { New-Item -ItemType File -Path $release | Out-Null } Start-Sleep -Milliseconds 50 } $sp.WaitForExit() if (-not (Test-Path $release)) { New-Item -ItemType File -Path $release | Out-Null } $Holder.WaitForExit(30000) | Out-Null $text = ((Get-Content -LiteralPath $out -Raw) + (Get-Content -LiteralPath $err -Raw)) -replace "\x1b\[[0-9;]*m", '' # no ANSI colour codes [pscustomobject]@{ Exit = $sp.ExitCode; Output = $text } } function New-StubCandidate([string]$Dir, [string]$Stub) { New-Item -ItemType Directory -Path (Join-Path $Dir 'script-worker') -Force | Out-Null Set-Content -LiteralPath (Join-Path $Dir 'script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe') -Value $Stub } $holderExe = Join-Path $root 'holder908\OtOpcUa.Host908.exe' $common = @('-Carry', 'appsettings*.json,pki', '-NoServiceControl', '-WorkerServiceName', $WorkerServiceName) Write-Host 'lmxopcua#908: a process running from InstallRoot that outlives the budget: fails clearly, nothing moved' $cand8a = Join-Path $scratch 'install.new8a'; $prev8a = Join-Path $scratch 'install.bak.8a' New-StubCandidate $cand8a 'stub v8a' $hold = Start-InstallHolder $holderExe $r8a = Invoke-SwapReleasing (@('-InstallRoot', $root, '-Candidate', $cand8a, '-PreviousInstall', $prev8a, '-ProcessExitTimeoutSeconds', '1') + $common) 'Swap complete' 0 $hold $sq8a = & $squash $r8a.Output Write-Host (" {0,-34} exit {1}; {2}" -f 'swap, holder kept, budget 1 s', $r8a.Exit, $(if ($sq8a -match 'stillinuse1safter') { 'refused: still in use' } else { 'NOT refused' })) Assert ($r8a.Exit -ne 0 -and $sq8a -match 'stillinuse1safter.*OtOpcUa\.Host908\.exe\(PID' -and $sq8a -match 'Nothingwasmoved' -and (Test-Path $cand8a) -and -not (Test-Path $prev8a) -and (Test-Path $holderExe)) "times out naming the holder, nothing moved ($($r8a.Output))" Remove-Item -LiteralPath $holderLog -Force -ErrorAction SilentlyContinue Write-Host 'lmxopcua#908: a process running from InstallRoot until ~2 s after the wait starts: the swap waits for it, then moves' $hold = Start-InstallHolder $holderExe $r8b = Invoke-SwapReleasing (@('-InstallRoot', $root, '-Candidate', $cand8a, '-PreviousInstall', $prev8a, '-MoveRetrySeconds', '0') + $common) '==> wait for the host process' 2000 $hold $released = [regex]::Match($r8b.Output, 'released after ([\d.,]+) s').Groups[1].Value Write-Host (" {0,-34} exit {1}; released after {2} s" -f 'swap, holder +2 s (no move retry)', $r8b.Exit, $released) Assert ($r8b.Exit -eq 0 -and $r8b.Output -match 'Swap complete' -and $released -and [double]($released -replace ',', '.') -ge 1.5 -and (Get-Content -LiteralPath $workerExe) -eq 'stub v8a' -and $r8b.Output -match 'VERIFY OK \(scope InstallRoot\)') "waited for the holder, then swapped and verified ($($r8b.Output))" Write-Host 'lmxopcua#908: a handle the process wait cannot see (holder image outside InstallRoot): the first move is retried' $cand8c = Join-Path $scratch 'install.new8c'; $prev8c = Join-Path $scratch 'install.bak.8c' New-StubCandidate $cand8c 'stub v8c' $hold = Start-InstallHolder $winPs $r8c = Invoke-SwapReleasing (@('-InstallRoot', $root, '-Candidate', $cand8c, '-PreviousInstall', $prev8c, '-MoveRetrySeconds', '20') + $common) "==> move $root ->" 2000 $hold $attempt = [regex]::Match($r8c.Output, 'moved on attempt (\d+)').Groups[1].Value Write-Host (" {0,-34} exit {1}; moved on attempt {2}" -f 'swap, outside holder +2 s', $r8c.Exit, $attempt) Assert ($r8c.Exit -eq 0 -and $attempt -and [int]$attempt -gt 1 -and (Get-Content -LiteralPath $workerExe) -eq 'stub v8c' -and $r8c.Output -match 'VERIFY OK \(scope InstallRoot\)') "the in-use first move was retried and then went through ($($r8c.Output))" $prevItems = @(Get-ChildItem -LiteralPath $prev8c -Recurse -Force | ForEach-Object { $_.FullName.Substring($prev8c.Length + 1) }) Assert (-not ($prevItems -contains 'install') -and ($prevItems -contains 'logs\host908.log') -and (Get-Content -LiteralPath (Join-Path $prev8c 'script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe')) -eq 'stub v8a') ` "the previous install moved whole: no partial tree from a failed attempt, nothing nested into it ($($prevItems -join ', '))" Remove-Item -LiteralPath $release -Force -ErrorAction SilentlyContinue $v8 = Invoke-Mode 'Verify' Assert ($v8.Exit -eq 0) 'full verify passes after the #908 swaps' Write-Host 'Uninstall WITHOUT -DenyPaths (the record drives it), twice' $u1 = Invoke-Installer @('-Uninstall') Assert ($u1.Exit -eq 0) "uninstall exits 0 ($($u1.Output))" Assert ($u1.Steps.Count -eq 10 -and @($u1.Steps | Where-Object Outcome -ne 'changed').Count -eq 0) "uninstall: 10 steps, all changed ($(($u1.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))" Assert (-not (Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue)) 'firewall rule removed' Assert (@(Get-Explicit $root $workerSid).Count -eq 0 -and @(Get-Explicit (Join-Path $root 'script-worker') $workerSid).Count -eq 0 -and @(Get-Explicit (Join-Path $cache 'pe') $workerSid).Count -eq 0 -and @($deny | Where-Object { @(Get-Explicit $_ $workerSid).Count -ne 0 }).Count -eq 0) 'no explicit worker ACEs remain, on the denied folders either' Assert (-not (Test-Path $recordKey)) 'the record is gone' Assert (Test-Path $cache) 'the PE cache is kept without -RemovePeCache' $env2 = @((Get-ItemProperty -Path $hostKey -Name Environment).Environment) Assert (($env2 -join '|') -eq 'Keep__Me=1') "host env restored ($($env2 -join '|'))" Start-Sleep -Milliseconds 500 $u2 = Invoke-Installer @('-Uninstall') Assert ($u2.Exit -eq 0) "second uninstall exits 0 ($($u2.Output))" Assert ($u2.Steps.Count -eq 7 -and @($u2.Steps | Where-Object Outcome -ne 'unchanged').Count -eq 0) "second uninstall: 7 steps, all unchanged ($(($u2.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))" } if ($TimingFiles -gt 0) { # lmxopcua#905: a vd03-like layout. ApiInstall is denied as a whole and holds the install folder, the candidate and # other app folders; pki\ is inside the install; 48 protected-DACL files (vd03 reported 48). Write-Host "Swap timing (lmxopcua#905): scripts from $ScriptsDir" $tInstaller = Join-Path $ScriptsDir 'Install-ScriptWorkerIsolation.ps1' $tSwap = Join-Path $ScriptsDir 'Invoke-OtOpcUaInstallSwap.ps1' $api = Join-Path $scratch 'ApiInstall'; $tRoot = Join-Path $api 'OtOpcUa'; $tCand = Join-Path $api 'OtOpcUa.new' $tData = Join-Path $api 'OtOpcUa-data'; $tKek = Join-Path $scratch 'kek'; $tCache = Join-Path $scratch 'pe-cache-t' function New-DummyApp([string]$Dir, [int]$N, [string]$Stub) { for ($d = 0; $d -lt 60; $d++) { [IO.Directory]::CreateDirectory("$Dir\sub$d") | Out-Null } for ($i = 0; $i -lt $N; $i++) { [IO.File]::WriteAllText("$Dir\sub$($i % 60)\f$i.dll", 'x') } if ($Stub) { [IO.Directory]::CreateDirectory("$Dir\script-worker") | Out-Null; [IO.File]::WriteAllText("$Dir\script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe", $Stub) } } New-DummyApp $tRoot 1400 'live' foreach ($d in "$tRoot\pki\own\private", "$tRoot\logs", $tData, $tKek) { [IO.Directory]::CreateDirectory($d) | Out-Null } [IO.File]::WriteAllText("$tRoot\appsettings.json", '{}') New-DummyApp $tCand 1400 'candidate' $left = [Math]::Max(0, $TimingFiles - 2800); $n = 0 while ($left -gt 0) { $n++; $take = [Math]::Min(1400, $left); New-DummyApp (Join-Path $api "app$n") $take ''; $left -= $take } $protDir = if ($n) { Join-Path $api 'app1\sub0' } else { $tData } for ($p = 0; $p -lt 48; $p++) { $f = "$protDir\prot$p.key"; [IO.File]::WriteAllText($f, 'k'); & icacls $f /inheritance:r /grant:r '*S-1-5-18:(F)' '*S-1-5-32-544:(F)' /Q | Out-Null } $items = @([IO.DirectoryInfo]::new($api).EnumerateFileSystemInfos('*', [IO.SearchOption]::AllDirectories)).Count $tDeny = "$api,$tRoot\pki,$tData,$tKek" $ti = [Diagnostics.Stopwatch]::StartNew() $tiOut = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $tInstaller -InstallRoot $tRoot -HostServiceAccount $script:hostAccount ` -PeCacheDir $tCache -WorkerServiceName $WorkerServiceName -HostServiceName $HostServiceName -ConfigureHostService -DenyPaths $tDeny 2>&1 | Out-String } $tiExit = $LASTEXITCODE; $ti.Stop() Assert ($tiExit -eq 0) "timing: isolation install exits 0 ($tiOut)" $ts = [Diagnostics.Stopwatch]::StartNew() $tsOut = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $tSwap -InstallRoot $tRoot -Candidate $tCand ` -PreviousInstall (Join-Path $api 'OtOpcUa.bak.t') -NoServiceControl -WorkerServiceName $WorkerServiceName 2>&1 | Out-String } $tsExit = $LASTEXITCODE; $ts.Stop() Assert ($tsExit -eq 0 -and $tsOut -match 'Swap complete' -and [IO.File]::ReadAllText("$tRoot\script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe") -eq 'candidate') "timing: swap exits 0 ($tsOut)" $wm = [regex]::Match($tsOut, 'swap window[^:]*: (?[0-9.,]+) s(?[^\r\n]*)') # Before #905 the whole swap script ran inside the stopped window (nothing ran before the stop). $window = if ($wm.Success) { [double]::Parse($wm.Groups['s'].Value, [Globalization.CultureInfo]::CurrentCulture) } else { $ts.Elapsed.TotalSeconds } $pre = [regex]::Match($tsOut, 'pre-stop isolation (?[0-9.,]+) s').Groups['s'].Value Write-Host (" TIMING {0} {1}: {2} items under the denied folder; install {3:N1} s; swap script {4:N1} s; pre-stop {5}; STOPPED WINDOW {6:N1} s {7}" -f ` $PSVersionTable.PSEdition, $PSVersionTable.PSVersion, $items, $ti.Elapsed.TotalSeconds, $ts.Elapsed.TotalSeconds, $(if ($pre) { "$pre s" } else { 'none (all in the window)' }), $window, $wm.Groups['rest'].Value) $tv = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $tInstaller -Verify -WorkerServiceName $WorkerServiceName 2>&1 | Out-String } Assert ($LASTEXITCODE -eq 0) 'timing: full verify passes after the swap' $tu = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $tInstaller -Uninstall -WorkerServiceName $WorkerServiceName 2>&1 | Out-String } Assert ($LASTEXITCODE -eq 0 -and -not (Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue)) "timing: uninstall from the record ($tu)" } } catch { $script:failures++; Write-Host " FAIL: unexpected error: $($_ | Out-String)" -ForegroundColor Red } finally { # Best-effort cleanup that does not depend on the code under test. Remove-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue foreach ($n in $WorkerServiceName, $HostServiceName) { if (Get-Service -Name $n -ErrorAction SilentlyContinue) { & sc.exe delete $n | Out-Null } } if ($LocalHostAccount -and (Get-LocalUser -Name $LocalHostAccountName -ErrorAction SilentlyContinue)) { Remove-LocalUser -Name $LocalHostAccountName } if (Test-Path -LiteralPath $scratch) { & icacls $scratch /reset /T /C /Q 2>&1 | Out-Null Remove-Item -LiteralPath $scratch -Recurse -Force -ErrorAction SilentlyContinue } } Write-Host '' Write-Host "$($script:passes) passed, $($script:failures) failed" if ($script:failures -gt 0) { exit 1 }