<# .SYNOPSIS Dry-run harness for Install-ScriptWorkerIsolation.ps1 (lmxopcua#780). Asserts the planned changes — ids, order, targets, SDDL and ACEs — WITHOUT touching the machine, on any OS with PowerShell 7 (`pwsh`). The repo has no Pester; this is a plain script that exits non-zero on the first failed group. .EXAMPLE pwsh -NoProfile -File scripts/install/tests/Test-ScriptWorkerIsolation.ps1 #> Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $here = Split-Path -Parent $MyInvocation.MyCommand.Path $installDir = Split-Path -Parent $here Import-Module (Join-Path $installDir 'ScriptWorkerIsolation.psm1') -Force $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 Join-Targets($Steps) { (@($Steps | ForEach-Object { $_.Target }) -join '|') } # StrictMode-safe on an empty list function Assert-Throws([scriptblock]$Block, [string]$Like, [string]$Message) { try { & $Block; Assert $false "$Message (did not throw)" } catch { Assert ($_.Exception.Message -like $Like) "$Message (threw '$($_.Exception.Message)')" } } $hostSid = 'S-1-5-21-1111111111-2222222222-3333333333-1001' $workerSid = 'S-1-5-80-1846135827-3903057317-1998105452-3938448374-3023753135' # NT SERVICE\OtOpcUaScriptWorker $root = 'C:\Program Files\OtOpcUa' $cache = 'C:\ProgramData\OtOpcUa\script-pe-cache' Write-Host 'Service SID algorithm' Assert ((Get-OtOpcUaServiceSid -Name 'TrustedInstaller') -eq 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464') ` 'NT SERVICE\TrustedInstaller matches the well-known SID' Assert ((Get-OtOpcUaServiceSid -Name 'OtOpcUaScriptWorker') -eq $workerSid) 'OtOpcUaScriptWorker SID (pinned; ScriptWorkerIdentityTests pins the same value)' Assert ((Get-OtOpcUaServiceSid -Name 'otopcuascriptworker') -eq $workerSid) 'the name is case-insensitive' Assert-Throws { Get-OtOpcUaServiceSid -Name 'bad name' } '*' 'a name with a space is refused' Write-Host 'Install plan' $plan = @(Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir $cache -ConfigureHostService) $ids = $plan.Id -join ',' Assert ($ids -eq 'AnchorService,DenyPathRecord,SettingsRecord,PeCacheDir,PeEntriesDir,WorkerFolderAcl,InstallRootDeny,FirewallBlockOutbound,HostServiceEnv') "install step order ($ids)" $byId = @{}; foreach ($s in $plan) { $byId[$s.Id] = $s } $anchor = $byId['AnchorService'].Data Assert ($anchor.Identity -eq 'NT SERVICE\OtOpcUaScriptWorker') 'anchor service runs as its own virtual account' Assert ($anchor.BinPath -eq 'C:\Program Files\OtOpcUa\script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe') "anchor binPath ($($anchor.BinPath))" Assert ($byId['AnchorService'].Change -like '*disabled*never started*') 'anchor is described as disabled and never started' $sddl = $byId['PeCacheDir'].Data.Sddl Assert ($sddl -eq "O:BAG:SYD:PAI(A;OICI;0x1f01ff;;;SY)(A;OICI;0x1f01ff;;;BA)(A;OICI;0x1f01ff;;;$hostSid)") "PeCacheDir SDDL ($sddl)" Assert ($sddl.StartsWith('O:BAG:SYD:PAI')) 'PeCacheDir DACL is protected (no inherited Users/Everyone)' foreach ($forbidden in 'BU', 'WD', 'AU', 'S-1-5-32-545', 'S-1-1-0', 'S-1-5-11', $workerSid) { Assert (-not $sddl.Contains(";;;$forbidden)")) "PeCacheDir root grants nothing to $forbidden" } $sysSddl = Get-OtOpcUaPeCacheSddl -HostSid 'S-1-5-18' Assert ($sysSddl -eq 'O:BAG:SYD:PAI(A;OICI;0x1f01ff;;;SY)(A;OICI;0x1f01ff;;;BA)') "a LocalSystem host gets no duplicate ACE ($sysSddl)" $pe = $byId['PeEntriesDir'].Data Assert ($pe.Path -eq "$cache\pe" -or $pe.Path -eq "$cache/pe") "entries dir is \pe ($($pe.Path))" Assert ($pe.Ace -eq "(A;OICI;0x1301bf;;;$workerSid)") "worker gets Modify on pe\ ($($pe.Ace))" Assert ($pe.Type -eq 'Allow' -and $pe.Mask -eq 0x1301BF -and $pe.Sid -eq $workerSid) 'pe\ ACE data matches its SDDL' $wf = $byId['WorkerFolderAcl'].Data Assert ($wf.Ace -eq "(A;OICI;0x1200a9;;;$workerSid)") "worker gets read+execute on script-worker\ ($($wf.Ace))" Assert ($wf.Path -like '*\OtOpcUa\script-worker' -or $wf.Path -like '*/script-worker') "worker folder path ($($wf.Path))" $deny = $byId['InstallRootDeny'].Data Assert ($deny.Ace -eq "(D;OICI;0xd0157;;;$workerSid)") "install root DENIES the worker read and write, inherited ($($deny.Ace))" Assert ($deny.Type -eq 'Deny' -and $deny.Path -eq $root -and $deny.Mask -eq 0xD0157) 'install-root ACE is a deny on the root' # lmxopcua#890: the bits that matter, and the ones that must stay off so the worker can still reach script-worker\. foreach ($bit in @{ ReadData = 0x1; WriteData = 0x2; AppendData = 0x4; WriteAttributes = 0x100; Delete = 0x10000; DeleteChild = 0x40; WriteDac = 0x40000 }.GetEnumerator()) { Assert (($deny.Mask -band $bit.Value) -ne 0) "the deny includes $($bit.Key)" } foreach ($bit in @{ TraverseExecute = 0x20; ReadAttributes = 0x80; ReadPermissions = 0x20000; Synchronize = 0x100000 }.GetEnumerator()) { Assert (($deny.Mask -band $bit.Value) -eq 0) "the deny leaves $($bit.Key) alone (the loader walks through denied folders)" } $rec = $byId['DenyPathRecord'].Data Assert ($rec.Key -eq 'HKLM:\SYSTEM\CurrentControlSet\Services\OtOpcUaScriptWorker\Parameters' -and $rec.Name -eq 'DenyPaths' -and @($rec.Paths).Count -eq 0) ` "without -DenyPaths the record is empty, under the anchor service's key ($($rec.Key))" # lmxopcua#901: the effective arguments are recorded beside DenyPaths, in a key locked to SYSTEM + Administrators. $set = $byId['SettingsRecord'].Data Assert ($set.Key -eq $rec.Key) "settings live under the same key as DenyPaths ($($set.Key))" Assert ($set.Settings.InstallRoot -eq $root -and $set.Settings.PeCacheDir -eq $cache -and $set.Settings.HostServiceName -eq 'OtOpcUaHost' -and $set.Settings.ConfigureHostService -eq 1 -and $set.Settings.HostServiceAccount -eq $hostSid) "recorded settings ($(($set.Settings.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join '; '))" Assert ((@($set.Settings.Keys) -join ',') -eq 'InstallRoot,HostServiceAccount,PeCacheDir,HostServiceName,ConfigureHostService') 'exactly these five values (no secrets)' Assert ($set.Sddl -eq 'D:PAI(A;CI;KA;;;SY)(A;CI;KA;;;BA)') "record key DACL is protected, SYSTEM + Administrators only ($($set.Sddl))" foreach ($forbidden in 'BU', 'WD', 'AU', 'RC', $workerSid) { Assert (-not $set.Sddl.Contains(";;;$forbidden)")) "record key grants nothing to $forbidden" } $named = @(Get-ScriptWorkerIsolationPlan -InstallRoot "$root\" -HostSid 'S-1-5-18' -HostServiceAccount 'LocalSystem' -PeCacheDir $cache) $namedSet = ($named | Where-Object Id -eq 'SettingsRecord').Data.Settings Assert ($namedSet.HostServiceAccount -eq 'LocalSystem' -and $namedSet.ConfigureHostService -eq 0 -and $namedSet.InstallRoot -eq $root) ` 'the account is recorded as given, the flag as 0, the root without a trailing backslash' $fw = $byId['FirewallBlockOutbound'].Data Assert ($fw.Program -eq 'C:\Program Files\OtOpcUa\script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe') "firewall rule program ($($fw.Program))" Assert ($byId['FirewallBlockOutbound'].Change -like '*blocking ALL outbound*') 'firewall rule blocks outbound' $envSet = $byId['HostServiceEnv'].Data.Set Assert ($envSet['Scripting__Worker__Identity'] -eq 'NT SERVICE\OtOpcUaScriptWorker') 'env: Scripting__Worker__Identity' Assert ($envSet['Scripting__Worker__PeCacheDir'] -eq $cache) 'env: Scripting__Worker__PeCacheDir' Assert (-not ($envSet.Keys -contains 'Scripting__Runner')) 'the runner flip is NOT part of the installer' $noEnv = @(Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir $cache) Assert (-not ($noEnv.Id -contains 'HostServiceEnv')) 'without -ConfigureHostService the host service is untouched' Write-Host 'Path rules (positive and negative controls)' Assert-Throws { Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir "$root\cache" } '*inside InstallRoot*' 'a cache inside the install root is refused' Assert-Throws { Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir $root } '*inside InstallRoot*' 'the install root itself is refused' Assert-Throws { Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir 'relative\cache' } '*absolute*' 'a relative cache is refused' $sibling = @(Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir 'C:\Program Files\OtOpcUa-cache') Assert ($sibling.Count -eq 8) 'a sibling folder sharing the prefix is NOT inside the root (positive control)' Write-Host 'Sensitive siblings: -DenyPaths (lmxopcua#890, the wonder layout)' $qaRoot = 'E:\ApiInstall\OtOpcUa' $qaApp = "$qaRoot\app" $qaDeny = @($qaRoot, "$qaRoot\pki", "$qaRoot\data") $qa = @(Get-ScriptWorkerIsolationPlan -InstallRoot $qaApp -HostSid 'S-1-5-18' -PeCacheDir $cache -DenyPaths $qaDeny -ConfigureHostService) $qaIds = $qa.Id -join ',' Assert ($qaIds -eq 'AnchorService,DenyPathRecord,SettingsRecord,PeCacheDir,PeEntriesDir,WorkerFolderAcl,InstallRootDeny,SensitiveDirDeny,SensitiveDirDeny,SensitiveDirDeny,FirewallBlockOutbound,HostServiceEnv') ` "QA plan order ($qaIds)" $sens = @($qa | Where-Object Id -eq 'SensitiveDirDeny') Assert ((Join-Targets $sens) -eq ($qaDeny -join '|')) "one deny per folder, in the given order ($(Join-Targets $sens))" foreach ($s in $sens) { Assert ($s.Data.Ace -eq "(D;OICI;0xd0157;;;$workerSid)" -and $s.Data.Type -eq 'Deny' -and $s.Data.Sid -eq $workerSid -and $s.Data.Mask -eq 0xD0157) ` "$($s.Target): the worker identity is denied read and write, inherited ($($s.Data.Ace))" } Assert ((@(($qa | Where-Object Id -eq 'DenyPathRecord').Data.Paths) -join '|') -eq ($qaDeny -join '|')) 'the record lists exactly the denied folders' # The host account is never denied anything: every deny ACE names the worker identity alone. $allDeny = @($qa | Where-Object { $_.Data.ContainsKey('Type') -and $_.Data.Type -eq 'Deny' }) Assert ($allDeny.Count -eq 4 -and @($allDeny | Where-Object { $_.Data.Sid -ne $workerSid }).Count -eq 0) "every deny ACE is the worker identity's ($($allDeny.Count) denies)" $asUser = @(Get-ScriptWorkerIsolationPlan -InstallRoot $qaApp -HostSid $hostSid -PeCacheDir $cache -DenyPaths $qaDeny) Assert (@($asUser | Where-Object { $_.Data.ContainsKey('Ace') -and $_.Data.Ace -like "(D;*;;;$hostSid)" }).Count -eq 0 -and @($asUser | Where-Object { $_.Data.ContainsKey('Ace') -and $_.Data.Ace -like '(D;*' }).Count -eq 4) 'a domain host account is never the subject of a deny' # script-worker\ keeps its allow, and the PE cache is untouched by the denies. Assert (($qa | Where-Object Id -eq 'WorkerFolderAcl').Data.Path -eq "$qaApp\script-worker") 'script-worker\ allow still planned under app\' Assert (@($sens | Where-Object { $_.Target -like "$cache*" }).Count -eq 0) 'no deny on the PE cache' $norm = @(Resolve-OtOpcUaDenyPaths -Paths @("$qaRoot\pki\,$qaRoot\PKI;$qaRoot\data", '', $qaApp, "$qaRoot\") -InstallRoot $qaApp -PeCacheDir $cache) Assert (($norm -join '|') -eq "$qaRoot\pki|$qaRoot\data|$qaRoot") "comma/semicolon split, trailing '\' trimmed, case-insensitive de-dup, InstallRoot dropped ($($norm -join '|'))" $viaString = @(Get-ScriptWorkerIsolationPlan -InstallRoot $qaApp -HostSid 'S-1-5-18' -PeCacheDir $cache -DenyPaths ($qaDeny -join ',')) Assert (@($viaString | Where-Object Id -eq 'SensitiveDirDeny').Count -eq 3) 'a single comma-separated string (powershell -File) gives the same three denies' Assert-Throws { Resolve-OtOpcUaDenyPaths -Paths 'E:\' -InstallRoot $qaApp -PeCacheDir $cache } '*drive or share root*' 'a drive root is refused' Assert-Throws { Resolve-OtOpcUaDenyPaths -Paths '\\srv\share' -InstallRoot $qaApp -PeCacheDir $cache } '*drive or share root*' 'a share root is refused' Assert-Throws { Resolve-OtOpcUaDenyPaths -Paths "$qaApp\script-worker" -InstallRoot $qaApp -PeCacheDir $cache } '*worker''s own binaries*' 'script-worker\ itself is refused' Assert-Throws { Resolve-OtOpcUaDenyPaths -Paths "$qaApp\SCRIPT-WORKER\runtimes" -InstallRoot $qaApp -PeCacheDir $cache } '*worker''s own binaries*' 'a folder inside script-worker\ is refused' Assert-Throws { Resolve-OtOpcUaDenyPaths -Paths "$cache\pe" -InstallRoot $qaApp -PeCacheDir $cache } '*PeCacheDir*' 'the PE cache (pe\) is refused' Assert-Throws { Resolve-OtOpcUaDenyPaths -Paths 'pki' -InstallRoot $qaApp -PeCacheDir $cache } '*absolute*' 'a relative path is refused' $okAncestor = @(Resolve-OtOpcUaDenyPaths -Paths @('E:\ApiInstall', "$qaApp-old\script-worker") -InstallRoot $qaApp -PeCacheDir $cache) Assert ($okAncestor.Count -eq 2) 'an ancestor of script-worker\ and a prefix-sharing sibling are accepted (positive control)' Write-Host 'Re-run with a shorter list: the dropped folder loses its deny (lmxopcua#890)' $rerun = @(Get-ScriptWorkerIsolationPlan -InstallRoot $qaApp -HostSid 'S-1-5-18' -PeCacheDir $cache -DenyPaths @($qaRoot, "$qaRoot\pki") ` -RecordedDenyPaths @($qaRoot, "$qaRoot\pki", "$qaRoot\data\")) $staleSteps = @($rerun | Where-Object { $_.Id -eq 'SensitiveDirDeny' -and $_.Data.ContainsKey('Remove') }) Assert ((Join-Targets $staleSteps) -eq "$qaRoot\data") "only the dropped folder is un-denied ($(Join-Targets $staleSteps))" Assert ([array]::IndexOf(@($rerun.Id), 'SensitiveDirDeny') -lt [array]::IndexOf(@($rerun.Id), 'DenyPathRecord')) 'the stale removal runs before the record is rewritten' Assert (@($rerun | Where-Object { $_.Id -eq 'SensitiveDirDeny' -and -not $_.Data.ContainsKey('Remove') }).Count -eq 2) 'the two kept folders keep their deny' $same = @(Get-ScriptWorkerIsolationPlan -InstallRoot $qaApp -HostSid 'S-1-5-18' -PeCacheDir $cache -DenyPaths $qaDeny -RecordedDenyPaths $qaDeny) Assert (@($same | Where-Object { $_.Data.ContainsKey('Remove') }).Count -eq 0) 'an unchanged list plans no removal (idempotent re-run)' Write-Host 'Uninstall plan' $un = @(Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir $cache -ConfigureHostService -Uninstall) $unIds = $un.Id -join ',' Assert ($unIds -eq 'HostServiceEnv,FirewallBlockOutbound,InstallRootDeny,WorkerFolderAcl,PeEntriesDir,DenyPathRecord,AnchorService') "rollback order ($unIds)" Assert (@($un | Where-Object { -not ($_.Data.ContainsKey('Remove')) }).Count -eq 0) 'every rollback step removes' Assert ((@($un.Id | Sort-Object) -join ',') -eq (@($plan.Id | Where-Object { $_ -notin 'PeCacheDir', 'SettingsRecord' } | Sort-Object) -join ',')) ` 'rollback reverses every install step but the cache (kept by default); the settings go with the record key (DenyPathRecord)' Assert (($un | Where-Object Id -eq 'HostServiceEnv').Data.Remove -contains 'Scripting__Worker__Identity') 'rollback removes the identity env entry' foreach ($s in $un | Where-Object { $_.Id -in 'InstallRootDeny', 'WorkerFolderAcl', 'PeEntriesDir' }) { Assert ($s.Data.Sid -eq $workerSid) "rollback $($s.Id) targets only the worker identity's ACEs" } # lmxopcua#890: -Uninstall removes the deny from every recorded folder, plus any passed again, once each. $unQa = @(Get-ScriptWorkerIsolationPlan -InstallRoot $qaApp -HostSid 'S-1-5-18' -PeCacheDir $cache -Uninstall ` -DenyPaths @("$qaRoot\PKI") -RecordedDenyPaths $qaDeny) $unSens = @($unQa | Where-Object Id -eq 'SensitiveDirDeny') Assert ($unSens.Count -eq 3 -and @($unSens | Where-Object { -not $_.Data.ContainsKey('Remove') -or $_.Data.Sid -ne $workerSid }).Count -eq 0) ` "rollback un-denies each recorded folder once, worker ACEs only ($(Join-Targets $unSens))" Assert ((@(@($unSens | ForEach-Object { $_.Target }) | Sort-Object) -join '|') -eq (@($qaDeny | Sort-Object) -join '|')) 'rollback covers exactly the recorded folders' Assert ((@($unQa.Id) -join ',') -eq 'FirewallBlockOutbound,SensitiveDirDeny,SensitiveDirDeny,SensitiveDirDeny,InstallRootDeny,WorkerFolderAcl,PeEntriesDir,DenyPathRecord,AnchorService') ` "QA rollback order ($(@($unQa.Id) -join ','))" Assert (($unQa | Where-Object Id -eq 'DenyPathRecord').Data.ContainsKey('Remove')) 'rollback deletes the record' $unAll = @(Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir $cache -Uninstall -RemovePeCache) Assert ($unAll.Id -contains 'PeCacheDir' -and -not ($unAll.Id -contains 'PeEntriesDir')) '-RemovePeCache deletes the cache instead of editing it' Write-Host 'Entry script -PlanOnly (end to end, no machine access)' $entry = Join-Path $installDir 'Install-ScriptWorkerIsolation.ps1' $viaScript = @(& $entry -InstallRoot $root -HostServiceAccount $hostSid -PeCacheDir $cache -ConfigureHostService -PlanOnly 6>$null) Assert (($viaScript.Id -join ',') -eq $ids) "-PlanOnly returns the module's plan ($($viaScript.Id -join ','))" Assert-Throws { & $entry -InstallRoot $root -HostServiceAccount 'OTOPCUA\svc' -PeCacheDir $cache -PlanOnly 6>$null } '*needs -HostServiceAccount as a SID*' ` '-PlanOnly refuses an account name (no lookup without the OS)' $defaultCache = @(& $entry -InstallRoot $root -HostServiceAccount $hostSid -PlanOnly 6>$null) Assert ((($defaultCache | Where-Object Id -eq 'PeCacheDir').Data.Path) -like '*OtOpcUa*script-pe-cache') 'default PeCacheDir is under ProgramData\OtOpcUa' $qaEntry = @(& $entry -InstallRoot $qaApp -HostServiceAccount 'S-1-5-18' -PeCacheDir $cache -DenyPaths ($qaDeny -join ',') -ConfigureHostService -PlanOnly 6>$null) Assert ((@($qaEntry.Id) -join ',') -eq $qaIds) "-PlanOnly -DenyPaths 'a,b,c' (one string, as powershell -File passes it) plans the same steps" Write-Host 'Entry script -Reapply / -Verify argument rules (lmxopcua#901; refused before any machine access)' Assert-Throws { & $entry -Reapply -InstallRoot $root 6>$null } '*-Reapply takes its settings from the record*' '-Reapply refuses explicit settings' Assert-Throws { & $entry -Reapply -DenyPaths 'C:\x' 6>$null } '*-Reapply takes its settings from the record*' '-Reapply refuses -DenyPaths' Assert-Throws { & $entry -Reapply -Verify 6>$null } '*exclusive*' '-Reapply and -Verify are exclusive' Assert-Throws { & $entry -Verify -Uninstall 6>$null } '*exclusive*' '-Verify and -Uninstall are exclusive' Assert-Throws { & $entry -InstallRoot $root 6>$null } '*required*' 'a plain install still needs -HostServiceAccount' Write-Host 'Swap scopes: Outside before the stop, InstallRoot in the stopped window (lmxopcua#905)' # vd03: the install folder itself is the root, pki\ inside it, the whole E:\ApiInstall denied, data beside it. $vdRoot = 'E:\ApiInstall\OtOpcUa' $vdDeny = @('E:\ApiInstall', "$vdRoot\pki", 'E:\ApiInstall\OtOpcUa-data', 'C:\ProgramData\ZB\kek', 'E:\psrem-stage') $vd = @(Get-ScriptWorkerIsolationPlan -InstallRoot $vdRoot -HostSid 'S-1-5-18' -PeCacheDir $cache -DenyPaths $vdDeny) $vdIn = @(Select-ScriptWorkerIsolationPlanScope -Plan $vd -InstallRoot $vdRoot -Scope InstallRoot) $vdOut = @(Select-ScriptWorkerIsolationPlanScope -Plan $vd -InstallRoot $vdRoot -Scope Outside) $vdAll = @(Select-ScriptWorkerIsolationPlanScope -Plan $vd -InstallRoot $vdRoot -Scope All) Assert ((@($vdIn | ForEach-Object { "$($_.Id) $($_.Target)" }) -join '|') -eq "WorkerFolderAcl $vdRoot\script-worker|InstallRootDeny $vdRoot|SensitiveDirDeny $vdRoot\pki") ` "vd03 InstallRoot scope = exactly the three ACEs a swap drops, in plan order ($(@($vdIn | ForEach-Object { "$($_.Id) $($_.Target)" }) -join '|'))" Assert ((@($vdOut | ForEach-Object { "$($_.Id) $($_.Target)" }) -join '|') -eq ("AnchorService OtOpcUaScriptWorker|DenyPathRecord $($rec.Key)|SettingsRecord $($rec.Key)|PeCacheDir $cache|PeEntriesDir $cache\pe|" + "SensitiveDirDeny E:\ApiInstall|SensitiveDirDeny E:\ApiInstall\OtOpcUa-data|SensitiveDirDeny C:\ProgramData\ZB\kek|SensitiveDirDeny E:\psrem-stage|" + "FirewallBlockOutbound OtOpcUa-ScriptWorker-BlockOutbound")) ` "vd03 Outside scope = everything else ($(@($vdOut | ForEach-Object { "$($_.Id) $($_.Target)" }) -join '|'))" Assert (($vdIn.Count + $vdOut.Count) -eq $vd.Count -and @($vdIn | Where-Object { $vdOut -contains $_ }).Count -eq 0) 'the two scopes partition the plan (no step lost, none twice)' Assert ($vdAll.Count -eq $vd.Count) '-Scope All is the whole plan' Assert (@($vdOut | Where-Object { $_.Target -eq 'E:\ApiInstall\OtOpcUa-data' }).Count -eq 1) 'a prefix-sharing sibling (OtOpcUa-data) is Outside (negative control for the prefix match)' $qaIn = @(Select-ScriptWorkerIsolationPlanScope -Plan $qa -InstallRoot $qaApp -Scope InstallRoot) Assert ((@($qaIn.Id) -join ',') -eq 'WorkerFolderAcl,InstallRootDeny') "QA (pki\ beside app\): the InstallRoot scope has no SensitiveDirDeny ($(@($qaIn.Id) -join ','))" Assert (@(Select-ScriptWorkerIsolationPlanScope -Plan $qa -InstallRoot $qaApp -Scope Outside | Where-Object Id -eq 'HostServiceEnv').Count -eq 1) 'the host env is set before the stop (it takes effect on the next start, the new build)' $staleIn = @(Get-ScriptWorkerIsolationPlan -InstallRoot $vdRoot -HostSid 'S-1-5-18' -PeCacheDir $cache -DenyPaths @('E:\ApiInstall') -RecordedDenyPaths @('E:\ApiInstall', "$vdRoot\pki")) $staleInScoped = @(Select-ScriptWorkerIsolationPlanScope -Plan $staleIn -InstallRoot $vdRoot -Scope InstallRoot | Where-Object { $_.Data.ContainsKey('Remove') }) Assert ((Join-Targets $staleInScoped) -eq "$vdRoot\pki") 'a stale deny inside InstallRoot is removed in the InstallRoot scope' Assert (-not (Test-OtOpcUaStepInInstallRoot -Step ($vd | Where-Object Id -eq 'FirewallBlockOutbound') -InstallRoot $vdRoot)) 'the firewall rule is Outside (its exe path does not change; its query costs seconds)' Assert (Test-OtOpcUaStepInInstallRoot -Step ($vd | Where-Object Id -eq 'WorkerFolderAcl') -InstallRoot "$vdRoot\") 'a trailing backslash on InstallRoot still matches (positive control)' Write-Host 'Coverage scan roots: each denied folder walked once (lmxopcua#905)' $cw = Get-OtOpcUaCoverageWalkRoots -InstallRoot $vdRoot -DeniedPaths $vdDeny -Scope All Assert (($cw.Roots -join '|') -eq 'E:\ApiInstall|C:\ProgramData\ZB\kek|E:\psrem-stage' -and @($cw.Skip).Count -eq 0) "vd03 All: nested InstallRoot, pki\ and OtOpcUa-data are walked inside E:\ApiInstall, once ($($cw.Roots -join '|'))" $cwIn = Get-OtOpcUaCoverageWalkRoots -InstallRoot $vdRoot -DeniedPaths $vdDeny -Scope InstallRoot Assert (($cwIn.Roots -join '|') -eq $vdRoot -and @($cwIn.Skip).Count -eq 0) "vd03 InstallRoot: only the swapped-in folder ($($cwIn.Roots -join '|'))" $cwOut = Get-OtOpcUaCoverageWalkRoots -InstallRoot $vdRoot -DeniedPaths $vdDeny -Scope Outside Assert (($cwOut.Roots -join '|') -eq 'E:\ApiInstall|C:\ProgramData\ZB\kek|E:\psrem-stage' -and (@($cwOut.Skip) -join '|') -eq $vdRoot) "vd03 Outside: the denied folders, InstallRoot skipped ($($cwOut.Roots -join '|'); skip $(@($cwOut.Skip) -join '|'))" $cwQa = Get-OtOpcUaCoverageWalkRoots -InstallRoot $qaApp -DeniedPaths $qaDeny -Scope All Assert (($cwQa.Roots -join '|') -eq $qaRoot) "QA All: app\, pki\, data\ all inside E:\ApiInstall\OtOpcUa ($($cwQa.Roots -join '|'))" $cwCase = Get-OtOpcUaCoverageWalkRoots -InstallRoot 'C:\x\app' -DeniedPaths @('D:\Keys\', 'd:\keys', 'D:\KeysOld') -Scope Outside Assert (($cwCase.Roots -join '|') -eq 'D:\Keys|D:\KeysOld') "case and trailing '\' collapse; a prefix-sharing sibling is its own root ($($cwCase.Roots -join '|'))" Write-Host 'Entry script -Scope (lmxopcua#905)' $inEntry = @(& $entry -InstallRoot $vdRoot -HostServiceAccount 'S-1-5-18' -PeCacheDir $cache -DenyPaths ($vdDeny -join ',') -PlanOnly -Scope InstallRoot 6>$null) Assert ((@($inEntry.Id) -join ',') -eq 'WorkerFolderAcl,InstallRootDeny,SensitiveDirDeny') "-PlanOnly -Scope InstallRoot ($(@($inEntry.Id) -join ','))" $outEntry = @(& $entry -InstallRoot $vdRoot -HostServiceAccount 'S-1-5-18' -PeCacheDir $cache -DenyPaths ($vdDeny -join ',') -PlanOnly -Scope Outside 6>$null) Assert ($outEntry.Count -eq 10 -and -not ($outEntry.Id -contains 'WorkerFolderAcl')) "-PlanOnly -Scope Outside ($(@($outEntry.Id) -join ','))" Assert-Throws { & $entry -InstallRoot $root -HostServiceAccount $hostSid -Scope InstallRoot 6>$null } '*-Scope works with*' 'a plain install refuses -Scope' Assert-Throws { & $entry -Uninstall -InstallRoot $root -HostServiceAccount $hostSid -Scope Outside 6>$null } '*-Scope works with*' '-Uninstall refuses -Scope' Assert-Throws { & $entry -Verify -Scope Elsewhere 6>$null } '*Elsewhere*' 'an unknown scope is refused' Write-Host 'Swap script order (lmxopcua#905; parsed, not run)' $swapAst = [System.Management.Automation.Language.Parser]::ParseFile((Join-Path $installDir 'Invoke-OtOpcUaInstallSwap.ps1'), [ref]$null, [ref]$null) $cmds = @($swapAst.FindAll({ param($n) $n -is [System.Management.Automation.Language.CommandAst] }, $true) | Where-Object { $_.GetCommandName() }) function First-Offset([string]$Name, [string]$Contains = '') { $hit = @($cmds | Where-Object { $_.GetCommandName() -eq $Name -and $_.Extent.Text -like "*$Contains*" } | Sort-Object { $_.Extent.StartOffset }) if ($hit.Count) { $hit[0].Extent.StartOffset } else { -1 } } $swapText = $swapAst.Extent.Text function Text-Offset([string]$Text) { $swapText.IndexOf($Text, [StringComparison]::Ordinal) } $iso = @($cmds | Where-Object { $_.GetCommandName() -eq 'Invoke-Isolation' }) Assert ($iso.Count -eq 4 -and @($iso | Where-Object { $_.CommandElements.Count -ne 3 }).Count -eq 0) "every Invoke-Isolation call names a scope ($(@($iso | ForEach-Object { $_.Extent.Text }) -join ' | '))" $order = @( (First-Offset 'Invoke-Isolation' "'Reapply' 'Outside'"), (First-Offset 'Invoke-Isolation' "'Verify' 'Outside'"), (First-Offset 'Stop-Service'), (Text-Offset '[System.IO.Directory]::Move($Candidate, $InstallRoot)'), (First-Offset 'Invoke-Isolation' "'Reapply' 'InstallRoot'"), (First-Offset 'Invoke-Isolation' "'Verify' 'InstallRoot'"), (First-Offset 'Start-Service')) Assert (@($order | Where-Object { $_ -lt 0 }).Count -eq 0 -and (($order -join ',') -eq (@($order | Sort-Object) -join ','))) ` "Reapply/Verify Outside -> stop -> swap -> Reapply/Verify InstallRoot -> start ($($order -join ','))" $swapExit = First-Offset 'Wait-OtOpcUaInstallReleased' $swapRetry = First-Offset 'Invoke-OtOpcUaMoveWithRetry' '::Move($InstallRoot, $PreviousInstall)' $swapPid = First-Offset 'Get-CimInstance' 'Win32_Service' Assert ($swapPid -ge 0 -and $swapPid -lt (First-Offset 'Stop-Service') -and (First-Offset 'Stop-Service') -lt $swapExit -and $swapExit -lt $swapRetry -and $swapRetry -lt (Text-Offset '[System.IO.Directory]::Move($Candidate')) ` "lmxopcua#908: host PID captured -> stop -> wait for the processes to exit -> first move (with retry) -> candidate move ($swapPid,$(First-Offset 'Stop-Service'),$swapExit,$swapRetry,$(Text-Offset '[System.IO.Directory]::Move($Candidate'))" function Test-InsideCommand($Ast, [string]$Name) { for ($p = $Ast.Parent; $p; $p = $p.Parent) { if ($p -is [System.Management.Automation.Language.CommandAst] -and $p.GetCommandName() -eq $Name) { return $true } } $false } $firstMoves = @($swapAst.FindAll({ param($n) $n -is [System.Management.Automation.Language.InvokeMemberExpressionAst] -and $n.Extent.Text -like '*::Move($InstallRoot, $PreviousInstall)' }, $true)) Assert ($firstMoves.Count -eq 1 -and @($firstMoves | Where-Object { -not (Test-InsideCommand $_ 'Invoke-OtOpcUaMoveWithRetry') }).Count -eq 0) ` "the first move (InstallRoot -> PreviousInstall) runs only inside Invoke-OtOpcUaMoveWithRetry ($($firstMoves.Count))" $moveItems = @($cmds | Where-Object { $_.GetCommandName() -eq 'Move-Item' }) Assert ($moveItems.Count -eq 0) "no Move-Item: PowerShell 7's falls back to a partial item-by-item move when the rename fails (lmxopcua#908) ($(@($moveItems | ForEach-Object { $_.Extent.Text }) -join ' | '))" Write-Host 'Host process exit wait (lmxopcua#908; fake process lists)' $qaApp = 'E:\ApiInstall\OtOpcUa\app' $t0 = [datetime]::new(2026, 11, 13, 12, 0, 0, [DateTimeKind]::Utc) $hostP = [pscustomobject]@{ Id = 4100; Name = 'OtOpcUa.Host.exe'; Path = "$qaApp\OtOpcUa.Host.exe"; StartTime = $t0 } $workerP = [pscustomobject]@{ Id = 4200; Name = 'ZB.MOM.WW.OtOpcUa.ScriptWorker.exe'; Path = "$qaApp\script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe"; StartTime = $t0 } $hostNoPath = [pscustomobject]@{ Id = 4100; Name = 'OtOpcUa.Host.exe'; Path = ''; StartTime = $t0 } $others = @( [pscustomobject]@{ Id = 10; Name = 'svchost.exe'; Path = 'C:\Windows\System32\svchost.exe'; StartTime = $t0 }, [pscustomobject]@{ Id = 11; Name = 'OtOpcUa.Host.exe'; Path = "$qaApp.new-76e73e7d\OtOpcUa.Host.exe"; StartTime = $t0 }, # the candidate: a prefix sibling [pscustomobject]@{ Id = 12; Name = 'pwsh.exe'; Path = $null; StartTime = $null }) $h = @(Find-OtOpcUaInstallHolder -Snapshot (@($hostP, $workerP) + $others) -InstallRoot "$qaApp\" -ProcessId 4100 -ProcessStartTime $t0) Assert ((@($h | ForEach-Object { $_.Id }) -join ',') -eq '4100,4200') "host + worker under app\ hold it; the candidate folder and others do not ($(@($h | ForEach-Object { $_.Id }) -join ','))" $h = @(Find-OtOpcUaInstallHolder -Snapshot (@($hostNoPath) + $others) -InstallRoot $qaApp -ProcessId 4100 -ProcessStartTime $t0) Assert ((@($h | ForEach-Object { $_.Id }) -join ',') -eq '4100') "the host counts by PID even when its image path is unreadable ($(@($h | ForEach-Object { $_.Id }) -join ','))" $reused = [pscustomobject]@{ Id = 4100; Name = 'notepad.exe'; Path = 'C:\Windows\notepad.exe'; StartTime = $t0.AddSeconds(30) } $h = @(Find-OtOpcUaInstallHolder -Snapshot (@($reused) + $others) -InstallRoot $qaApp -ProcessId 4100 -ProcessStartTime $t0) Assert ($h.Count -eq 0) 'a different process that reused the PID does not count' $h = @(Find-OtOpcUaInstallHolder -Snapshot $others -InstallRoot $qaApp) Assert ($h.Count -eq 0) 'nothing under app\ and no PID: released' # The QA case: SCM said Stopped, the host + worker are gone ~1 s later. The wait must see them, then return. $script:calls = 0 $fake = { $script:calls++; if ($script:calls -le 3) { @($hostP, $workerP) + $others } else { $others } } $waited = Wait-OtOpcUaInstallReleased -InstallRoot $qaApp -ProcessId 4100 -ProcessStartTime $t0 -TimeoutSeconds 10 -PollMilliseconds 20 -GetSnapshot $fake Assert ($script:calls -eq 4) "waits while the host and worker are still exiting, returns once they are gone (snapshots taken: $($script:calls), $([math]::Round($waited, 2)) s)" $script:calls = 0 $null = Wait-OtOpcUaInstallReleased -InstallRoot $qaApp -ProcessId 4100 -TimeoutSeconds 10 -PollMilliseconds 20 -GetSnapshot { $script:calls++; $others } Assert ($script:calls -eq 1) "already released: one snapshot, no wait ($($script:calls))" $sw908 = [System.Diagnostics.Stopwatch]::StartNew() Assert-Throws { Wait-OtOpcUaInstallReleased -InstallRoot $qaApp -ProcessId 4100 -TimeoutSeconds 1 -PollMilliseconds 50 -GetSnapshot { @($workerP) + $others } } ` '*still in use 1 s after*ZB.MOM.WW.OtOpcUa.ScriptWorker.exe (PID 4200*Nothing was moved*' 'a process that never exits fails clearly, naming it' Assert ($sw908.Elapsed.TotalSeconds -lt 5) "the wait is bounded ($([math]::Round($sw908.Elapsed.TotalSeconds, 1)) s for a 1 s budget)" Write-Host 'First-move retry on a sharing violation (lmxopcua#908)' $inUse = [System.IO.IOException]::new('The process cannot access the file because it is being used by another process.', -2147024864) # 0x80070020 Assert (Test-OtOpcUaSharingViolation -Exception $inUse) 'ERROR_SHARING_VIOLATION is recognised' Assert (Test-OtOpcUaSharingViolation -Exception ([System.Management.Automation.RuntimeException]::new('wrapped', $inUse))) 'also when wrapped' Assert (-not (Test-OtOpcUaSharingViolation -Exception ([System.IO.IOException]::new('Cannot create a file when that file already exists.', -2147024713)))) 'ERROR_ALREADY_EXISTS is not' Assert (Test-OtOpcUaSharingViolation -Exception ([System.Management.Automation.MethodInvocationException]::new('Exception calling "Move" with "2" argument(s)', [System.IO.IOException]::new("Access to the path 'E:\ApiInstall\OtOpcUa\app' is denied.", -2147024891)))) ` 'the [IO.Directory]::Move shape (MethodInvocationException over IOException 0x80070005, both hosts, windev)' Assert (Test-OtOpcUaSharingViolation -Exception ([System.IO.IOException]::new("Access to the path 'E:\ApiInstall\OtOpcUa\app' is denied.", -2146232800))) ` 'the Windows PowerShell 5.1 shape of the same failure (IOException COR_E_IO "Access to the path ... is denied", windev)' Assert (-not (Test-OtOpcUaSharingViolation -Exception ([System.UnauthorizedAccessException]::new("Access to the path 'x' is denied.")))) 'an UnauthorizedAccessException is not' $script:calls = 0 $n = try { Invoke-OtOpcUaMoveWithRetry -RetrySeconds 5 -DelayMilliseconds 10 -Move { $script:calls++; if ($script:calls -le 2) { throw $inUse } } 3>$null } catch { "threw: $($_.Exception.Message)" } Assert ($n -eq 3 -and $script:calls -eq 3) "in use twice, then moved on attempt 3 ($n)" $script:calls = 0 Assert-Throws { Invoke-OtOpcUaMoveWithRetry -RetrySeconds 5 -DelayMilliseconds 10 -Move { $script:calls++; throw [System.UnauthorizedAccessException]::new('Access to the path is denied.') } 3>$null } ` '*denied*' 'any other error is not retried' Assert ($script:calls -eq 1) " (one attempt: $($script:calls))" $script:calls = 0 Assert-Throws { Invoke-OtOpcUaMoveWithRetry -RetrySeconds 0 -DelayMilliseconds 10 -Move { $script:calls++; throw $inUse } 3>$null } '*being used by another process*' '-MoveRetrySeconds 0: no retry' Assert ($script:calls -eq 1) " (one attempt: $($script:calls))" if ($IsWindows) { Write-Host 'SDDL parses on Windows' $ds = [System.Security.AccessControl.DirectorySecurity]::new() $ds.SetSecurityDescriptorSddlForm($sddl) Assert ($ds.AreAccessRulesProtected) 'parsed PeCacheDir descriptor is protected' foreach ($ace in $pe.Ace, $wf.Ace, $deny.Ace, $sens[0].Data.Ace) { $one = [System.Security.AccessControl.DirectorySecurity]::new() $one.SetSecurityDescriptorSddlForm("D:$ace") Assert (@($one.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier])).Count -eq 1) "ACE $ace parses" } } Write-Host '' Write-Host "$($script:passes) passed, $($script:failures) failed" if ($script:failures -gt 0) { exit 1 }