<# ScriptWorker isolation (lmxopcua#780, design §6 + owner decisions §14 #5/#6, §7 PeCacheDir ACL). Pure planning functions (no OS calls — the dry-run harness runs them on any OS) plus the Windows apply step. Install-ScriptWorkerIsolation.ps1 is the entry point; see its help. The worker identity is the virtual account NT SERVICE\. Its SID is computed from the name (Get-OtOpcUaServiceSid, the algorithm behind `sc.exe showsid`), and the host starts each worker with a restricted token whose restricting SIDs are that identity + Everyone + BUILTIN\Users + RESTRICTED (ScriptWorkerIdentity.cs, RestrictedWorkerLauncher.cs). Every access check the worker makes must therefore pass for the host account AND for that set — which is why the ACLs below grant or deny the worker identity specifically. #> Set-StrictMode -Version Latest # Access masks (FileSystemRights) used in the planned ACEs. $script:FullControl = 0x1F01FF $script:Modify = 0x1301BF # Modify | Synchronize $script:ReadAndExecute = 0x1200A9 # ReadAndExecute | Synchronize $script:ReadData = 0x000001 # ReadData (files) / ListDirectory (folders) # lmxopcua#890: the deny the worker identity gets on the install folder and on every -DenyPaths folder. Read AND write: # ReadData/ListDirectory 0x1, WriteData/AddFile 0x2, AppendData/AddSubdirectory 0x4, WriteExtendedAttributes 0x10, # DeleteSubdirectoriesAndFiles 0x40, WriteAttributes 0x100, Delete 0x10000, ChangePermissions 0x40000, # TakeOwnership 0x80000. # Not denied, so the loader can still walk to script-worker\ and the worker can still run: Traverse/Execute 0x20, # ReadAttributes 0x80, ReadPermissions 0x20000, Synchronize 0x100000 (and ReadExtendedAttributes 0x8, which leaks nothing). # The write half matters because a data drive commonly grants BUILTIN\Users (one of the worker's restricting SIDs) # CreateFiles/AppendData on its subfolders: without it a worker could drop a certificate into pki\trusted\certs. $script:DenyMask = 0x0D0157 # lmxopcua#890: where the installer records the -DenyPaths it applied, so a later re-run removes the ones dropped from # the list and -Uninstall removes exactly what was added without the operator repeating them. The value lives under the # anchor service's key and goes with it. $script:DenyRecordValue = 'DenyPaths' # lmxopcua#901: the effective install arguments, recorded beside DenyPaths so `-Reapply` (after an upgrade swapped the # install folder) and `-Verify` (post-deploy gate) need no arguments. Paths, an account name and two flags; no secrets. # The key's DACL is set protected, SYSTEM + Administrators only (the services hive is otherwise readable by Users, one of # the worker token's restricting SIDs, and the record lists exactly which folders are denied). $script:SettingsValueNames = @('InstallRoot', 'HostServiceAccount', 'PeCacheDir', 'HostServiceName', 'ConfigureHostService') $script:RecordKeySddl = 'D:PAI(A;CI;KA;;;SY)(A;CI;KA;;;BA)' $script:FirewallRuleName = 'OtOpcUa-ScriptWorker-BlockOutbound' $script:WorkerExeName = 'ZB.MOM.WW.OtOpcUa.ScriptWorker.exe' $script:EnvIdentity = 'Scripting__Worker__Identity' $script:EnvPeCacheDir = 'Scripting__Worker__PeCacheDir' function Get-OtOpcUaServiceSid { <# .SYNOPSIS The SID of the virtual account NT SERVICE\: S-1-5-80- + SHA-1 of the upper-cased name (UTF-16LE) read as five little-endian UInt32 values. Identical to `sc.exe showsid ` and to ScriptWorkerIdentity.ServiceSid. #> [CmdletBinding()] param([Parameter(Mandatory)] [ValidatePattern('^[A-Za-z0-9_.\-]{1,80}$')] [string]$Name) $bytes = [System.Text.Encoding]::Unicode.GetBytes($Name.ToUpperInvariant()) $sha1 = [System.Security.Cryptography.SHA1]::Create() try { $hash = $sha1.ComputeHash($bytes) } finally { $sha1.Dispose() } $parts = for ($i = 0; $i -lt 5; $i++) { [BitConverter]::ToUInt32($hash, $i * 4) } 'S-1-5-80-' + ($parts -join '-') } function Format-OtOpcUaAce { # One SDDL ACE string: (A|D;flags;0xMASK;;;SID). param([ValidateSet('A', 'D')] [string]$Type, [string]$Flags, [int]$Mask, [string]$Sid) '({0};{1};0x{2:x};;;{3})' -f $Type, $Flags, $Mask, $Sid } function Get-OtOpcUaPeCacheSddl { <# .SYNOPSIS The PeCacheDir root's security descriptor: owner Administrators, PROTECTED (no inherited Users/Everyone/ Authenticated Users), SYSTEM + Administrators + the host account full control (container + object inherit). The worker identity gets NOTHING here — the HMAC key (pe-cache.key) and the launch markers (workers\) stay host-only; the worker's one writable place is the pe\ subfolder (Get-OtOpcUaPeEntriesAce). #> param([Parameter(Mandatory)] [string]$HostSid) $aces = @( (Format-OtOpcUaAce -Type A -Flags 'OICI' -Mask $script:FullControl -Sid 'SY') (Format-OtOpcUaAce -Type A -Flags 'OICI' -Mask $script:FullControl -Sid 'BA') ) if ($HostSid -ne 'S-1-5-18') { $aces += Format-OtOpcUaAce -Type A -Flags 'OICI' -Mask $script:FullControl -Sid $HostSid } 'O:BAG:SYD:PAI' + ($aces -join '') } function Get-OtOpcUaPeEntriesAce { # pe\ : the worker compiles in-process (owner decision §14 #7) and writes HMAC'd entries here — Modify, inherited. param([Parameter(Mandatory)] [string]$WorkerSid) Format-OtOpcUaAce -Type A -Flags 'OICI' -Mask $script:Modify -Sid $WorkerSid } function Get-OtOpcUaScriptWorkerFolderAce { # script-worker\ : the worker's own binaries — read and execute, inherited. param([Parameter(Mandatory)] [string]$WorkerSid) Format-OtOpcUaAce -Type A -Flags 'OICI' -Mask $script:ReadAndExecute -Sid $WorkerSid } function Get-OtOpcUaInstallRootDenyAce { <# .SYNOPSIS InstallRoot\ and every -DenyPaths folder: DENY the worker identity read and write ($script:DenyMask), inherited — so appsettings*.json, the logs, the host binaries, pki\ (private keys, trust lists) and data\ (secrets.db, LocalDb) are unreadable and unwritable to the worker even where BUILTIN\Users (one of its restricting SIDs) can read or create files (lmxopcua#890). script-worker\'s explicit allow is the nearer ACE and wins below it. #> param([Parameter(Mandatory)] [string]$WorkerSid) Format-OtOpcUaAce -Type D -Flags 'OICI' -Mask $script:DenyMask -Sid $WorkerSid } function Test-OtOpcUaPathWithin { # True when $Path equals $Ancestor or is below it (Windows paths, case-insensitive, no OS call). param([string]$Path, [string]$Ancestor) $p = $Path.TrimEnd('\'); $a = $Ancestor.TrimEnd('\') $p -ieq $a -or $p.StartsWith($a + '\', [StringComparison]::OrdinalIgnoreCase) } function Resolve-OtOpcUaDenyPaths { <# .SYNOPSIS Normalises -DenyPaths (lmxopcua#890): splits 'a,b' / 'a;b' (powershell -File passes an array as one string), trims trailing '\', drops blanks, de-duplicates case-insensitively, drops InstallRoot itself (InstallRootDeny covers it) and refuses what would break the worker or deny far too much. #> param([string[]]$Paths, [Parameter(Mandatory)] [string]$InstallRoot, [Parameter(Mandatory)] [string]$PeCacheDir) $root = $InstallRoot.TrimEnd('\') $workerFolder = "$root\script-worker" $cache = $PeCacheDir.TrimEnd('\') $out = [System.Collections.Generic.List[string]]::new() foreach ($raw in @($Paths | Where-Object { $_ } | ForEach-Object { $_ -split '[,;]' })) { $p = $raw.Trim().TrimEnd('\') if (-not $p) { continue } if ($p -match '^[A-Za-z]:$' -or $p -match '^\\\\[^\\]+(\\[^\\]+)?$') { throw "DenyPaths entry '$raw' is a drive or share root; deny the deployment folder instead" } if ($p -notmatch '^([A-Za-z]:\\|\\\\[^\\]+\\)') { throw "DenyPaths entry '$raw' must be an absolute Windows path" } if (Test-OtOpcUaPathWithin -Path $p -Ancestor $workerFolder) { throw "DenyPaths entry '$raw' is (inside) $workerFolder, the worker's own binaries" } if (Test-OtOpcUaPathWithin -Path $p -Ancestor $cache) { throw "DenyPaths entry '$raw' is (inside) PeCacheDir '$PeCacheDir'; the worker writes its compiled entries there" } if ($p -ieq $root) { continue } if (-not ($out | Where-Object { $_ -ieq $p })) { $out.Add($p) } } $out.ToArray() } function New-OtOpcUaPlanStep { param([string]$Id, [string]$Target, [string]$Change, [hashtable]$Data = @{}) [pscustomobject]@{ Id = $Id; Target = $Target; Change = $Change; Data = $Data } } function Get-ScriptWorkerIsolationPlan { <# .SYNOPSIS Every change Install-ScriptWorkerIsolation.ps1 makes, in order, as data. Pure: no OS call, so the dry-run harness (scripts/install/tests/Test-ScriptWorkerIsolation.ps1) asserts it on any OS and -WhatIf prints it verbatim. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$InstallRoot, [Parameter(Mandatory)] [string]$HostSid, [Parameter(Mandatory)] [string]$PeCacheDir, [string]$WorkerServiceName = 'OtOpcUaScriptWorker', [string]$HostServiceName = 'OtOpcUaHost', # lmxopcua#890: more folders to deny the worker identity (the deployment folder around InstallRoot, pki\, data\, # logs\, a Secrets or LocalDb folder elsewhere). Each gets the same inherited deny as InstallRoot. [string[]]$DenyPaths = @(), # The -DenyPaths a previous run recorded (read by the entry script from the anchor service's key; empty under # -PlanOnly). Install removes the deny from recorded paths no longer listed; -Uninstall removes it from all. [string[]]$RecordedDenyPaths = @(), # lmxopcua#901: the -HostServiceAccount as the operator gave it (recorded for -Reapply). Defaults to the SID. [string]$HostServiceAccount, [switch]$ConfigureHostService, [switch]$Uninstall, [switch]$RemovePeCache ) # Windows paths, planned on any OS (the harness runs on macOS/Linux too): drive-rooted or UNC only. foreach ($p in @{ InstallRoot = $InstallRoot; PeCacheDir = $PeCacheDir }.GetEnumerator()) { if ($p.Value -notmatch '^([A-Za-z]:\\|\\\\[^\\]+\\)') { throw "$($p.Key) '$($p.Value)' must be an absolute Windows path" } } $root = $InstallRoot.TrimEnd('\') $cache = $PeCacheDir.TrimEnd('\') if ($cache -ieq $root -or $cache.StartsWith($root + '\', [StringComparison]::OrdinalIgnoreCase)) { throw "PeCacheDir '$PeCacheDir' is inside InstallRoot '$InstallRoot'; the host refuses a cache inside its content root" } $workerSid = Get-OtOpcUaServiceSid -Name $WorkerServiceName $identity = "NT SERVICE\$WorkerServiceName" $workerFolder = "$root\script-worker" $workerExe = "$workerFolder\$($script:WorkerExeName)" $entries = "$cache\pe" $deny = @(Resolve-OtOpcUaDenyPaths -Paths $DenyPaths -InstallRoot $root -PeCacheDir $cache) # Recorded paths are not re-validated (they were when recorded); only normalised, so a removal always matches. $recorded = @($RecordedDenyPaths | Where-Object { $_ } | ForEach-Object { $_.Trim().TrimEnd('\') } | Where-Object { $_ -and $_ -ine $root } | Sort-Object -Unique) $denyAce = Get-OtOpcUaInstallRootDenyAce -WorkerSid $workerSid $recordKey = "HKLM:\SYSTEM\CurrentControlSet\Services\$WorkerServiceName\Parameters" $steps = [System.Collections.Generic.List[object]]::new() if (-not $Uninstall) { $steps.Add((New-OtOpcUaPlanStep 'AnchorService' $WorkerServiceName ` "register the disabled anchor service '$WorkerServiceName' so the virtual account '$identity' ($workerSid) resolves by name (never started)" ` @{ Name = $WorkerServiceName; Identity = $identity; BinPath = $workerExe })) foreach ($stale in @($recorded | Where-Object { $p = $_; -not ($deny | Where-Object { $_ -ieq $p }) })) { $steps.Add((New-OtOpcUaPlanStep 'SensitiveDirDeny' $stale ` "remove every explicit ACE for the worker identity from '$stale' (recorded by an earlier run, no longer in -DenyPaths)" ` @{ Path = $stale; Sid = $workerSid; Remove = $true })) } $steps.Add((New-OtOpcUaPlanStep 'DenyPathRecord' $recordKey ` "record the denied folders ($(if ($deny.Count) { $deny -join '; ' } else { 'none beyond the install folder' })) so a re-run or -Uninstall removes exactly these" ` @{ Key = $recordKey; Name = $script:DenyRecordValue; Paths = $deny })) $settings = [ordered]@{ InstallRoot = $root; HostServiceAccount = $(if ($HostServiceAccount) { $HostServiceAccount } else { $HostSid }) PeCacheDir = $cache; HostServiceName = $HostServiceName; ConfigureHostService = [int][bool]$ConfigureHostService } $steps.Add((New-OtOpcUaPlanStep 'SettingsRecord' $recordKey ` "record the effective settings (InstallRoot, HostServiceAccount, PeCacheDir, HostServiceName, ConfigureHostService) and lock the key to SYSTEM + Administrators, so -Reapply and -Verify need no arguments (lmxopcua#901)" ` @{ Key = $recordKey; Settings = $settings; Sddl = $script:RecordKeySddl })) $steps.Add((New-OtOpcUaPlanStep 'PeCacheDir' $cache ` "create the PE cache directory and set its ACL (protected; SYSTEM, Administrators, host account full control; no Users; no worker access to the key or markers)" ` @{ Path = $cache; Sddl = (Get-OtOpcUaPeCacheSddl -HostSid $HostSid) })) $steps.Add((New-OtOpcUaPlanStep 'PeEntriesDir' $entries ` "create the PE entries directory and grant the worker identity Modify (it compiles and writes HMAC'd entries)" ` @{ Path = $entries; Ace = (Get-OtOpcUaPeEntriesAce -WorkerSid $workerSid); Sid = $workerSid; Mask = $script:Modify; Type = 'Allow' })) $steps.Add((New-OtOpcUaPlanStep 'WorkerFolderAcl' $workerFolder ` "grant the worker identity read+execute on its own binaries" ` @{ Path = $workerFolder; Ace = (Get-OtOpcUaScriptWorkerFolderAce -WorkerSid $workerSid); Sid = $workerSid; Mask = $script:ReadAndExecute; Type = 'Allow' })) $steps.Add((New-OtOpcUaPlanStep 'InstallRootDeny' $root ` "deny the worker identity read and write on the install folder (appsettings, logs, host binaries), inherited; script-worker\ stays readable" ` @{ Path = $root; Ace = $denyAce; Sid = $workerSid; Mask = $script:DenyMask; Type = 'Deny' })) foreach ($p in $deny) { $steps.Add((New-OtOpcUaPlanStep 'SensitiveDirDeny' $p ` "deny the worker identity read and write on '$p' (certificates, secrets, stores, logs, backups), inherited" ` @{ Path = $p; Ace = $denyAce; Sid = $workerSid; Mask = $script:DenyMask; Type = 'Deny' })) } $steps.Add((New-OtOpcUaPlanStep 'FirewallBlockOutbound' $script:FirewallRuleName ` "add a Windows Firewall rule blocking ALL outbound traffic from '$workerExe' (every profile)" ` @{ Name = $script:FirewallRuleName; Program = $workerExe })) if ($ConfigureHostService) { $steps.Add((New-OtOpcUaPlanStep 'HostServiceEnv' $HostServiceName ` "set $($script:EnvIdentity)=$identity and $($script:EnvPeCacheDir)=$cache in the '$HostServiceName' service environment (other entries kept; takes effect on the next service start)" ` @{ Service = $HostServiceName; Set = [ordered]@{ $script:EnvIdentity = $identity; $script:EnvPeCacheDir = $cache } })) } } else { if ($ConfigureHostService) { $steps.Add((New-OtOpcUaPlanStep 'HostServiceEnv' $HostServiceName ` "remove $($script:EnvIdentity) and $($script:EnvPeCacheDir) from the '$HostServiceName' service environment (workers revert to the host's token on the next start)" ` @{ Service = $HostServiceName; Remove = @($script:EnvIdentity, $script:EnvPeCacheDir) })) } $steps.Add((New-OtOpcUaPlanStep 'FirewallBlockOutbound' $script:FirewallRuleName ` "remove the Windows Firewall rule '$($script:FirewallRuleName)'" @{ Name = $script:FirewallRuleName; Remove = $true })) foreach ($p in @(@($deny) + @($recorded) | Sort-Object -Unique)) { $steps.Add((New-OtOpcUaPlanStep 'SensitiveDirDeny' $p ` "remove every explicit ACE for the worker identity from '$p'" @{ Path = $p; Sid = $workerSid; Remove = $true })) } $steps.Add((New-OtOpcUaPlanStep 'InstallRootDeny' $root ` "remove every explicit ACE for the worker identity from the install folder" @{ Path = $root; Sid = $workerSid; Remove = $true })) $steps.Add((New-OtOpcUaPlanStep 'WorkerFolderAcl' $workerFolder ` "remove every explicit ACE for the worker identity from script-worker\" @{ Path = $workerFolder; Sid = $workerSid; Remove = $true })) if ($RemovePeCache) { $steps.Add((New-OtOpcUaPlanStep 'PeCacheDir' $cache ` "delete the PE cache directory (HMAC key, entries, launch markers; the host recreates a fresh cache if still configured)" ` @{ Path = $cache; Remove = $true })) } else { $steps.Add((New-OtOpcUaPlanStep 'PeEntriesDir' $entries ` "remove every explicit ACE for the worker identity from the PE entries directory (the cache itself is kept; -RemovePeCache deletes it)" ` @{ Path = $entries; Sid = $workerSid; Remove = $true })) } $steps.Add((New-OtOpcUaPlanStep 'DenyPathRecord' $recordKey ` "delete the record of denied folders" @{ Key = $recordKey; Name = $script:DenyRecordValue; Remove = $true })) $steps.Add((New-OtOpcUaPlanStep 'AnchorService' $WorkerServiceName ` "delete the anchor service '$WorkerServiceName'" @{ Name = $WorkerServiceName; Remove = $true })) } $steps } # --------------------------------------------------------------------------------------------------------------------- # Apply (Windows only). Each function is idempotent: it reads the current state, changes only what differs, and # returns 'changed' or 'unchanged'. Callers gate every call with ShouldProcess, so -WhatIf runs none of them. # --------------------------------------------------------------------------------------------------------------------- function Get-OtOpcUaExplicitAceState { # The explicit ACEs for $Sid on $Path and whether they are exactly the wanted one (or none when $Remove). param([string]$Path, [string]$Sid, [int]$Mask = 0, [string]$Type = 'Allow', [switch]$Remove) $acl = Get-Acl -LiteralPath $Path # #800: DirectoryInfo.GetAccessControl does not exist on PowerShell 7 (.NET) $identity = [System.Security.Principal.SecurityIdentifier]::new($Sid) $explicit = @($acl.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) | Where-Object { $_.IdentityReference -eq $identity }) $inherit = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit' # #800: @() keeps $wanted an array; the if-expression unrolled it to a scalar ($null on -Remove), and .Count on that # throws under Set-StrictMode -Version Latest on Windows PowerShell 5.1. $wanted = @(if (-not $Remove) { [System.Security.AccessControl.FileSystemAccessRule]::new($identity, [System.Security.AccessControl.FileSystemRights]$Mask, $inherit, [System.Security.AccessControl.PropagationFlags]::None, [System.Security.AccessControl.AccessControlType]$Type) }) $same = ($explicit.Count -eq $wanted.Count) -and ( $wanted.Count -eq 0 -or ( [int]$explicit[0].FileSystemRights -eq $Mask -and $explicit[0].AccessControlType -eq $wanted[0].AccessControlType -and $explicit[0].InheritanceFlags -eq $inherit -and $explicit[0].PropagationFlags -eq 'None')) $found = @($explicit | ForEach-Object { '{0} 0x{1:x} {2}' -f $_.AccessControlType, [int]$_.FileSystemRights, $_.InheritanceFlags }) -join '; ' [pscustomobject]@{ Acl = $acl; Identity = $identity; Wanted = $wanted; Same = $same; Found = $(if ($found) { $found } else { 'none' }) } } function Set-OtOpcUaExplicitAce { # Ensures exactly one explicit ACE for $Sid on $Path with the given type/mask/inheritance (or none when $Remove). param([string]$Path, [string]$Sid, [int]$Mask = 0, [string]$Type = 'Allow', [switch]$Remove) if (-not (Test-Path -LiteralPath $Path)) { if ($Remove) { return 'unchanged' } throw "$Path does not exist" } $state = Get-OtOpcUaExplicitAceState -Path $Path -Sid $Sid -Mask $Mask -Type $Type -Remove:$Remove if ($state.Same) { return 'unchanged' } $acl = $state.Acl $acl.PurgeAccessRules($state.Identity) foreach ($rule in $state.Wanted) { $acl.AddAccessRule($rule) } Set-Acl -LiteralPath $Path -AclObject $acl 'changed' } function Invoke-OtOpcUaSc { # Runs sc.exe with an explicitly quoted command line (CommandLineToArgvW rules), identical on PowerShell 5.1 and 7. # Returns the exit code. Used where an argument contains literal double quotes. param([string[]]$Arguments) $quoted = foreach ($a in $Arguments) { '"' + ($a -replace '(\\*)"', '$1$1\"' -replace '(\\+)$', '$1$1') + '"' } $psi = [System.Diagnostics.ProcessStartInfo]::new((Join-Path ([Environment]::SystemDirectory) 'sc.exe'), ($quoted -join ' ')) $psi.UseShellExecute = $false $psi.CreateNoWindow = $true $psi.RedirectStandardOutput = $true $psi.RedirectStandardError = $true $proc = [System.Diagnostics.Process]::Start($psi) $null = $proc.StandardOutput.ReadToEnd() $null = $proc.StandardError.ReadToEnd() $proc.WaitForExit() $proc.ExitCode } function Set-OtOpcUaStepAce { param([hashtable]$Data) if ($Data.ContainsKey('Remove')) { return (Set-OtOpcUaExplicitAce -Path $Data.Path -Sid $Data.Sid -Remove) } Set-OtOpcUaExplicitAce -Path $Data.Path -Sid $Data.Sid -Mask $Data.Mask -Type $Data.Type } function Invoke-ScriptWorkerIsolationStep { <# .SYNOPSIS Applies one plan step on Windows. Idempotent; returns 'changed' or 'unchanged'. #> [CmdletBinding()] param([Parameter(Mandatory)] $Step) $d = $Step.Data switch ($Step.Id) { 'AnchorService' { $svc = Get-Service -Name $d.Name -ErrorAction SilentlyContinue if ($d.ContainsKey('Remove')) { if (-not $svc) { return 'unchanged' } & sc.exe delete $d.Name | Out-Null if ($LASTEXITCODE -ne 0) { throw "sc.exe delete $($d.Name) failed ($LASTEXITCODE)" } return 'changed' } $verb = if ($svc) { 'config' } else { 'create' } $cfg = Get-CimInstance Win32_Service -Filter "Name='$($d.Name)'" -ErrorAction SilentlyContinue if ($cfg -and $cfg.StartMode -eq 'Disabled' -and $cfg.StartName -ieq $d.Identity -and $cfg.PathName -eq "`"$($d.BinPath)`"") { return 'unchanged' } # #883: the binPath value carries literal quotes. Windows PowerShell 5.1 passes native arguments with legacy quoting # (embedded quotes are dropped or mangled, so the stored path was unquoted and every re-run reported 'changed'), # PowerShell 7 escapes them. Build the command line explicitly so both hosts store the same value. $scExit = Invoke-OtOpcUaSc @($verb, $d.Name, 'binPath=', "`"$($d.BinPath)`"", 'start=', 'disabled', 'obj=', $d.Identity, 'DisplayName=', 'OtOpcUa ScriptWorker identity (anchor, never started)') if ($scExit -ne 0) { throw "sc.exe $verb $($d.Name) failed ($scExit)" } & sc.exe sidtype $d.Name unrestricted | Out-Null & sc.exe description $d.Name 'Anchors the NT SERVICE virtual account OtOpcUa ScriptWorker processes are restricted to (lmxopcua#780). Never started: the OtOpcUaHost launches workers itself under a restricted token.' | Out-Null return 'changed' } 'PeCacheDir' { if ($d.ContainsKey('Remove')) { if (-not (Test-Path -LiteralPath $d.Path)) { return 'unchanged' } Remove-Item -LiteralPath $d.Path -Recurse -Force return 'changed' } $changed = $false if (-not (Test-Path -LiteralPath $d.Path)) { New-Item -ItemType Directory -Path $d.Path -Force | Out-Null; $changed = $true } $current = (Get-Acl -LiteralPath $d.Path).GetSecurityDescriptorSddlForm('Access, Owner') $desired = [System.Security.AccessControl.DirectorySecurity]::new() $desired.SetSecurityDescriptorSddlForm($d.Sddl, 'Access, Owner') if ($current -ne $desired.GetSecurityDescriptorSddlForm('Access, Owner')) { Set-Acl -LiteralPath $d.Path -AclObject $desired # propagates to the key file, pe\ and workers\ (inherited ACEs) $changed = $true } return $(if ($changed) { 'changed' } else { 'unchanged' }) } 'PeEntriesDir' { if (-not $d.ContainsKey('Remove') -and -not (Test-Path -LiteralPath $d.Path)) { New-Item -ItemType Directory -Path $d.Path -Force | Out-Null } return (Set-OtOpcUaStepAce -Data $d) } { $_ -in 'WorkerFolderAcl', 'InstallRootDeny', 'SensitiveDirDeny' } { return (Set-OtOpcUaStepAce -Data $d) } 'DenyPathRecord' { $current = @(Get-OtOpcUaRecordedDenyPaths -Key $d.Key -Name $d.Name) if ($d.ContainsKey('Remove')) { if (-not (Test-Path -LiteralPath $d.Key)) { return 'unchanged' } Remove-Item -LiteralPath $d.Key -Recurse -Force return 'changed' } if ((@($current) -join "`n") -ceq (@($d.Paths) -join "`n")) { return 'unchanged' } if (-not (Test-Path -LiteralPath $d.Key)) { New-Item -Path $d.Key -Force | Out-Null } if (@($d.Paths).Count -eq 0) { Remove-ItemProperty -LiteralPath $d.Key -Name $d.Name -ErrorAction SilentlyContinue } else { Set-ItemProperty -LiteralPath $d.Key -Name $d.Name -Type MultiString -Value ([string[]]$d.Paths) } return 'changed' } 'SettingsRecord' { # Install only: -Uninstall deletes the whole key in its DenyPathRecord step. $changed = $false if (-not (Test-Path -LiteralPath $d.Key)) { New-Item -Path $d.Key -Force | Out-Null; $changed = $true } $current = Get-ItemProperty -LiteralPath $d.Key -ErrorAction SilentlyContinue foreach ($name in @($d.Settings.Keys)) { $want = $d.Settings[$name] $have = if ($current -and $current.PSObject.Properties[$name]) { $current.$name } else { $null } if ($null -ne $have -and "$have" -ceq "$want") { continue } $kind = if ($want -is [int]) { 'DWord' } else { 'String' } Set-ItemProperty -LiteralPath $d.Key -Name $name -Type $kind -Value $want $changed = $true } if (-not (Test-OtOpcUaRecordKeyAcl -Key $d.Key -Sddl $d.Sddl)) { $acl = Get-Acl -Path $d.Key $acl.SetSecurityDescriptorSddlForm($d.Sddl, 'Access') Set-Acl -Path $d.Key -AclObject $acl $changed = $true } return $(if ($changed) { 'changed' } else { 'unchanged' }) } 'FirewallBlockOutbound' { $rule = Get-NetFirewallRule -Name $d.Name -ErrorAction SilentlyContinue if ($d.ContainsKey('Remove')) { if (-not $rule) { return 'unchanged' } Remove-NetFirewallRule -Name $d.Name return 'changed' } if ($rule) { $app = ($rule | Get-NetFirewallApplicationFilter).Program if ($rule.Direction -eq 'Outbound' -and $rule.Action -eq 'Block' -and $rule.Enabled -eq 'True' -and $rule.Profile -eq 'Any' -and $app -ieq $d.Program) { return 'unchanged' } Remove-NetFirewallRule -Name $d.Name } New-NetFirewallRule -Name $d.Name -DisplayName 'OtOpcUa ScriptWorker - block outbound' ` -Description 'lmxopcua#780 design §6: OtOpcUa script workers never need the network; scripts are also denied System.Net at compile time.' ` -Direction Outbound -Action Block -Program $d.Program -Profile Any -Enabled True | Out-Null return 'changed' } 'HostServiceEnv' { $key = "HKLM:\SYSTEM\CurrentControlSet\Services\$($d.Service)" if (-not (Test-Path $key)) { if ($d.ContainsKey('Remove')) { return 'unchanged' } throw "service '$($d.Service)' is not installed (run Install-Services.ps1 first)" } $current = @((Get-ItemProperty -Path $key -Name Environment -ErrorAction SilentlyContinue).Environment | Where-Object { $_ }) $names = if ($d.ContainsKey('Remove')) { $d.Remove } else { @($d.Set.Keys) } $next = @($current | Where-Object { $n = ($_ -split '=', 2)[0]; $names -notcontains $n }) if (-not $d.ContainsKey('Remove')) { foreach ($k in $d.Set.Keys) { $next += "$k=$($d.Set[$k])" } } if ((@($current) -join "`n") -eq (@($next) -join "`n")) { return 'unchanged' } Set-ItemProperty -Path $key -Name Environment -Type MultiString -Value ([string[]]$next) return 'changed' } default { throw "unknown plan step '$($Step.Id)'" } } } function Get-OtOpcUaRecordedDenyPaths { # The -DenyPaths an earlier run recorded under the anchor service's key (lmxopcua#890); empty when there is none. param([string]$WorkerServiceName = 'OtOpcUaScriptWorker', [string]$Key, [string]$Name = $script:DenyRecordValue) if (-not $Key) { $Key = "HKLM:\SYSTEM\CurrentControlSet\Services\$WorkerServiceName\Parameters" } if (-not (Test-Path -LiteralPath $Key)) { return } $v = (Get-ItemProperty -LiteralPath $Key -Name $Name -ErrorAction SilentlyContinue) if ($v) { @($v.$Name | Where-Object { $_ }) } } function Test-OtOpcUaRecordKeyAcl { # True when the record key's DACL is exactly $Sddl (protected; SYSTEM + Administrators), compared in canonical form. param([string]$Key, [string]$Sddl = $script:RecordKeySddl) $want = [System.Security.AccessControl.RegistrySecurity]::new() $want.SetSecurityDescriptorSddlForm($Sddl, 'Access') # -Path, not -LiteralPath: Windows PowerShell 5.1's Get-Acl -LiteralPath does not find registry keys (windev, #901). (Get-Acl -Path $Key).GetSecurityDescriptorSddlForm('Access') -ceq $want.GetSecurityDescriptorSddlForm('Access') } function Get-OtOpcUaIsolationSettings { <# .SYNOPSIS The settings an earlier install recorded (lmxopcua#901) as a hashtable: InstallRoot, HostServiceAccount, PeCacheDir, HostServiceName, ConfigureHostService ([bool]) and DenyPaths. $null when the key or InstallRoot/HostServiceAccount is missing (nothing installed, or installed by an installer older than #901, which recorded only DenyPaths). #> param([string]$WorkerServiceName = 'OtOpcUaScriptWorker') $key = "HKLM:\SYSTEM\CurrentControlSet\Services\$WorkerServiceName\Parameters" if (-not (Test-Path -LiteralPath $key)) { return $null } $v = Get-ItemProperty -LiteralPath $key -ErrorAction SilentlyContinue if (-not $v) { return $null } $get = { param($n) if ($v.PSObject.Properties[$n]) { $v.$n } else { $null } } $root = & $get 'InstallRoot'; $account = & $get 'HostServiceAccount' if (-not $root -or -not $account) { return $null } @{ InstallRoot = [string]$root HostServiceAccount = [string]$account PeCacheDir = [string](& $get 'PeCacheDir') HostServiceName = $(if (& $get 'HostServiceName') { [string](& $get 'HostServiceName') } else { 'OtOpcUaHost' }) ConfigureHostService = [bool][int](& $get 'ConfigureHostService') DenyPaths = @(& $get $script:DenyRecordValue | Where-Object { $_ }) } } function Test-ScriptWorkerIsolationStep { <# .SYNOPSIS Checks one INSTALL plan step against the machine without changing anything (lmxopcua#901, -Verify). Returns [pscustomobject]@{ Ok; Detail }. Windows only. #> [CmdletBinding()] param([Parameter(Mandatory)] $Step) $d = $Step.Data $result = { param([bool]$Ok, [string]$Detail) [pscustomobject]@{ Ok = $Ok; Detail = $Detail } } switch ($Step.Id) { 'AnchorService' { $cfg = Get-CimInstance Win32_Service -Filter "Name='$($d.Name)'" -ErrorAction SilentlyContinue if (-not $cfg) { return (& $result $false "service '$($d.Name)' not found") } $ok = $cfg.StartMode -eq 'Disabled' -and $cfg.StartName -ieq $d.Identity -and $cfg.PathName -eq "`"$($d.BinPath)`"" return (& $result $ok "StartMode=$($cfg.StartMode) StartName=$($cfg.StartName) PathName=$($cfg.PathName)") } 'DenyPathRecord' { $current = @(Get-OtOpcUaRecordedDenyPaths -Key $d.Key -Name $d.Name) $ok = (@($current) -join "`n") -ceq (@($d.Paths) -join "`n") return (& $result $ok "recorded: $(if ($current.Count) { $current -join '; ' } else { 'none' })") } 'SettingsRecord' { if (-not (Test-Path -LiteralPath $d.Key)) { return (& $result $false 'record key missing') } $v = Get-ItemProperty -LiteralPath $d.Key $bad = @(foreach ($n in @($d.Settings.Keys)) { if (-not $v.PSObject.Properties[$n] -or "$($v.$n)" -cne "$($d.Settings[$n])") { $n } }) $aclOk = Test-OtOpcUaRecordKeyAcl -Key $d.Key -Sddl $d.Sddl if (-not $aclOk) { $bad += 'key ACL (want SYSTEM + Administrators only, protected)' } return (& $result ($bad.Count -eq 0) $(if ($bad.Count) { "differs: $($bad -join ', ')" } else { 'settings recorded, key locked' })) } 'PeCacheDir' { if (-not (Test-Path -LiteralPath $d.Path)) { return (& $result $false 'missing') } $desired = [System.Security.AccessControl.DirectorySecurity]::new() $desired.SetSecurityDescriptorSddlForm($d.Sddl, 'Access, Owner') $current = (Get-Acl -LiteralPath $d.Path).GetSecurityDescriptorSddlForm('Access, Owner') return (& $result ($current -eq $desired.GetSecurityDescriptorSddlForm('Access, Owner')) "SDDL $current") } { $_ -in 'PeEntriesDir', 'WorkerFolderAcl', 'InstallRootDeny', 'SensitiveDirDeny' } { if (-not (Test-Path -LiteralPath $d.Path)) { if ($d.ContainsKey('Remove')) { return (& $result $true 'absent') } return (& $result $false 'folder missing') } $state = if ($d.ContainsKey('Remove')) { Get-OtOpcUaExplicitAceState -Path $d.Path -Sid $d.Sid -Remove } else { Get-OtOpcUaExplicitAceState -Path $d.Path -Sid $d.Sid -Mask $d.Mask -Type $d.Type } $want = if ($d.ContainsKey('Remove')) { 'none' } else { '{0} 0x{1:x} ContainerInherit, ObjectInherit' -f $d.Type, $d.Mask } return (& $result $state.Same "explicit worker ACE: $($state.Found) (want $want)") } 'FirewallBlockOutbound' { $rule = Get-NetFirewallRule -Name $d.Name -ErrorAction SilentlyContinue if (-not $rule) { return (& $result $false "rule '$($d.Name)' missing") } $app = ($rule | Get-NetFirewallApplicationFilter).Program $ok = "$($rule.Direction)" -eq 'Outbound' -and "$($rule.Action)" -eq 'Block' -and "$($rule.Enabled)" -eq 'True' -and "$($rule.Profile)" -eq 'Any' -and $app -ieq $d.Program if ($ok -and -not (Test-Path -LiteralPath $d.Program)) { return (& $result $false "rule ok but $($d.Program) is missing") } return (& $result $ok "$($rule.Direction)/$($rule.Action)/$($rule.Profile)/enabled=$($rule.Enabled) program=$app") } 'HostServiceEnv' { $key = "HKLM:\SYSTEM\CurrentControlSet\Services\$($d.Service)" if (-not (Test-Path $key)) { return (& $result $false "service '$($d.Service)' not installed") } $current = @((Get-ItemProperty -Path $key -Name Environment -ErrorAction SilentlyContinue).Environment | Where-Object { $_ }) $missing = @(foreach ($k in $d.Set.Keys) { if ($current -notcontains "$k=$($d.Set[$k])") { $k } }) return (& $result ($missing.Count -eq 0) $(if ($missing.Count) { "missing or different: $($missing -join ', ')" } else { 'both entries present' })) } default { throw "unknown plan step '$($Step.Id)'" } } } function Find-OtOpcUaDenyCoverageGap { <# .SYNOPSIS After the denies are applied (lmxopcua#890): the files and folders under $Path that the inherited deny does NOT reach because their DACL is protected (inheritance switched off, e.g. a store that sets its own ACL on a private key) and that carry no explicit deny of their own. Each one is reported as a WARN; the operator decides. $Skip folders (script-worker\, the PE cache, ...) are not walked. .NOTES lmxopcua#905: this walk was the whole cost of a swap's stopped window (vd03 denies all of E:\ApiInstall). It used Get-ChildItem -Recurse + Get-Acl per item + a pipeline per item for $Skip: about 3.4 ms an item on Windows PowerShell 5.1 (59 s for 17.5k items on windev). It now enumerates with .NET, prunes $Skip folders instead of filtering every descendant, and reads only the DACL (GetAccessControl(Access): no owner/group lookup, no PS wrapping), about 0.15 ms an item. Reparse-point folders (junctions, symlinks) are not followed: the target keeps its own DACL, and following them can loop. #> param([Parameter(Mandatory)] [string]$Path, [Parameter(Mandatory)] [string]$WorkerSid, [string[]]$Skip = @()) $start = $Path.TrimEnd('\') if (-not [System.IO.Directory]::Exists($start)) { return } $skipList = @($Skip | Where-Object { $_ } | ForEach-Object { $_.TrimEnd('\') }) foreach ($s in $skipList) { if (Test-OtOpcUaPathWithin -Path $start -Ancestor $s) { return } } $identity = [System.Security.Principal.SecurityIdentifier]::new($WorkerSid) $core = $PSVersionTable.PSEdition -eq 'Core' # #800: FileSystemInfo.GetAccessControl does not exist on .NET (PS 7) $sections = [System.Security.AccessControl.AccessControlSections]::Access $dirAttr = [System.IO.FileAttributes]::Directory $reparse = [System.IO.FileAttributes]::ReparsePoint $stack = [System.Collections.Generic.Stack[System.IO.DirectoryInfo]]::new() $stack.Push([System.IO.DirectoryInfo]::new($start)) while ($stack.Count -gt 0) { $dir = $stack.Pop() $entries = $null try { $entries = @($dir.EnumerateFileSystemInfos()) } catch { $entries = @() } # unreadable folder: skip, as before foreach ($item in $entries) { if (($item.Attributes -band $dirAttr) -ne 0) { $skipped = $false foreach ($s in $skipList) { if ($item.FullName -ieq $s) { $skipped = $true; break } } if ($skipped) { continue } if (($item.Attributes -band $reparse) -eq 0) { $stack.Push($item) } } $acl = $null try { $acl = if ($core) { [System.IO.FileSystemAclExtensions]::GetAccessControl($item, $sections) } else { $item.GetAccessControl($sections) } } catch { continue } if (-not $acl.AreAccessRulesProtected) { continue } $explicitDeny = @($acl.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) | Where-Object { $_.IdentityReference -eq $identity -and $_.AccessControlType -eq 'Deny' }) if ($explicitDeny.Count -eq 0) { $item.FullName } } } } function Test-OtOpcUaStepInInstallRoot { # lmxopcua#905: true when a plan step's state lives in the install folder, so a folder swap can lose it: a folder ACE # on InstallRoot or below (WorkerFolderAcl, InstallRootDeny, a -DenyPaths folder inside it such as vd03's pki\). # Everything else (the anchor service, the records, the PE cache, folders outside, the host env, the firewall rule) is # untouched by a swap. The firewall rule names the worker exe by path, and that path does not change; its check is # Outside because Get-NetFirewallRule + its application filter cost about 3-5 s a run (windev), which the stopped # window should not pay. Invoke-OtOpcUaInstallSwap.ps1 checks that the candidate carries the worker exe before the stop. # Pure. param([Parameter(Mandatory)] $Step, [Parameter(Mandatory)] [string]$InstallRoot) $d = $Step.Data if ($d -is [System.Collections.IDictionary] -and $d.Contains('Path') -and $d['Path']) { return [bool](Test-OtOpcUaPathWithin -Path ([string]$d['Path']) -Ancestor $InstallRoot) } $false } function Select-ScriptWorkerIsolationPlanScope { <# .SYNOPSIS lmxopcua#905: the part of a plan a -Scope covers. 'InstallRoot' = the steps a folder swap can undo (see Test-OtOpcUaStepInInstallRoot); 'Outside' = all the others; 'All' = the plan unchanged. InstallRoot and Outside partition the plan, so an Outside run before the service stops plus an InstallRoot run after the swap cover every step. Pure. #> param([Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Plan, [Parameter(Mandatory)] [string]$InstallRoot, [ValidateSet('All', 'InstallRoot', 'Outside')] [string]$Scope = 'All') if ($Scope -eq 'All') { return $Plan } $wantIn = $Scope -eq 'InstallRoot' foreach ($step in $Plan) { if ((Test-OtOpcUaStepInInstallRoot -Step $step -InstallRoot $InstallRoot) -eq $wantIn) { $step } } } function Get-OtOpcUaCoverageWalkRoots { <# .SYNOPSIS lmxopcua#905: which folders the protected-DACL coverage scan walks for a -Scope, and what it must skip besides script-worker\ and the PE cache. Only the outermost folders are walked: a denied folder inside another one (InstallRoot or pki\ inside E:\ApiInstall on vd03) was walked a second (and third) time before. 'InstallRoot' walks InstallRoot alone; 'Outside' walks the denied folders not inside InstallRoot and skips InstallRoot; 'All' walks both. Pure. Returns [pscustomobject]@{ Roots; Skip }. #> param([Parameter(Mandatory)] [string]$InstallRoot, [string[]]$DeniedPaths = @(), [ValidateSet('All', 'InstallRoot', 'Outside')] [string]$Scope = 'All') $root = $InstallRoot.TrimEnd('\') $denied = @($DeniedPaths | Where-Object { $_ } | ForEach-Object { $_.TrimEnd('\') }) $candidates = switch ($Scope) { 'InstallRoot' { @($root) } 'Outside' { @($denied | Where-Object { -not (Test-OtOpcUaPathWithin -Path $_ -Ancestor $root) }) } default { @(@($root) + $denied) } } $outer = [System.Collections.Generic.List[string]]::new() foreach ($c in $candidates) { $nested = @($candidates | Where-Object { $_ -ine $c -and (Test-OtOpcUaPathWithin -Path $c -Ancestor $_) }).Count -gt 0 if (-not $nested -and -not ($outer | Where-Object { $_ -ieq $c })) { $outer.Add($c) } } [pscustomobject]@{ Roots = $outer.ToArray(); Skip = $(if ($Scope -eq 'Outside') { @($root) } else { @() }) } } function Get-OtOpcUaProcessSnapshot { <# .SYNOPSIS Every process as Id, Name, Path (image path; '' when unreadable), StartTime (UTC or $null). Windows: Win32_Process (sees service processes in session 0 when elevated); elsewhere Get-Process (the dry-run harness injects its own). #> if ($PSVersionTable.PSEdition -eq 'Desktop' -or $IsWindows) { foreach ($p in @(Get-CimInstance -ClassName Win32_Process -ErrorAction Stop)) { [pscustomobject]@{ Id = [int]$p.ProcessId; Name = [string]$p.Name; Path = [string]$p.ExecutablePath StartTime = $(if ($p.CreationDate) { ([datetime]$p.CreationDate).ToUniversalTime() } else { $null }) } } } else { foreach ($p in @(Get-Process)) { $path = ''; $start = $null try { $path = [string]$p.Path } catch { } try { $start = $p.StartTime.ToUniversalTime() } catch { } [pscustomobject]@{ Id = $p.Id; Name = $p.ProcessName; Path = $path; StartTime = $start } } } } function Find-OtOpcUaInstallHolder { <# .SYNOPSIS The processes that still pin InstallRoot (lmxopcua#908): the service's host process (by the PID captured before the stop; when its start time was captured too, a different process reusing the PID does not count) and any process whose image is under InstallRoot (OtOpcUa.Host, ZB.MOM.WW.OtOpcUa.ScriptWorker in script-worker\). Pure: the snapshot is passed in. #> param( [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Snapshot, [Parameter(Mandatory)] [string]$InstallRoot, [int]$ProcessId = 0, [Nullable[datetime]]$ProcessStartTime = $null ) @($Snapshot | Where-Object { $isHost = $ProcessId -gt 0 -and $_.Id -eq $ProcessId -and ($null -eq $ProcessStartTime -or $null -eq $_.StartTime -or [math]::Abs(([datetime]$_.StartTime - [datetime]$ProcessStartTime).TotalSeconds) -lt 1) $isUnder = $_.Path -and (Test-OtOpcUaPathWithin -Path ([string]$_.Path) -Ancestor $InstallRoot) $isHost -or $isUnder }) } function Wait-OtOpcUaInstallReleased { <# .SYNOPSIS After the service reports Stopped, waits until no process pins InstallRoot (Find-OtOpcUaInstallHolder), polling every -PollMilliseconds for at most -TimeoutSeconds; throws with the holders' names, PIDs and paths when they are still there (lmxopcua#908: SCM reports Stopped while the host is still exiting, and the folder move fails "in use"). Returns the seconds waited. -GetSnapshot replaces the process list (tests). #> param( [Parameter(Mandatory)] [string]$InstallRoot, [int]$ProcessId = 0, [Nullable[datetime]]$ProcessStartTime = $null, [int]$TimeoutSeconds = 60, [int]$PollMilliseconds = 250, [scriptblock]$GetSnapshot = { Get-OtOpcUaProcessSnapshot } ) $sw = [System.Diagnostics.Stopwatch]::StartNew() while ($true) { $holders = @(Find-OtOpcUaInstallHolder -Snapshot @(& $GetSnapshot) -InstallRoot $InstallRoot -ProcessId $ProcessId -ProcessStartTime $ProcessStartTime) if ($holders.Count -eq 0) { return $sw.Elapsed.TotalSeconds } if ($sw.Elapsed.TotalSeconds -ge $TimeoutSeconds) { $list = ($holders | ForEach-Object { '{0} (PID {1}, {2})' -f $_.Name, $_.Id, $(if ($_.Path) { $_.Path } else { 'path unreadable' }) }) -join '; ' throw ("$InstallRoot is still in use $TimeoutSeconds s after the service stopped (lmxopcua#908): $list. Nothing was moved. " + 'Wait for or end those processes, then re-run the swap (or raise -ProcessExitTimeoutSeconds).') } Start-Sleep -Milliseconds $PollMilliseconds } } function Test-OtOpcUaSharingViolation { # True when a folder rename failed because something under it is open (lmxopcua#908, all seen on windev): # [IO.Directory]::Move reports an IOException 0x80070005 "Access to the path '...' is denied." on both PowerShell hosts; # Move-Item reports "The process cannot access the file because it is being used by another process" (IOException, # ERROR_SHARING_VIOLATION 32 / ERROR_LOCK_VIOLATION 33) on PowerShell 7 and IOException COR_E_IO "Access to the path # ... is denied." on 5.1. An ACL denial is an UnauthorizedAccessException and is not matched; an IOException of the # access-denied shape that is a real denial only costs the bounded retry budget. Checks the exception and every inner one. param([Exception]$Exception) for ($e = $Exception; $e; $e = $e.InnerException) { if ($e -is [System.IO.IOException] -and (($e.HResult -band 0xFFFF) -in 32, 33)) { return $true } if ($e.Message -like '*being used by another process*') { return $true } if ($e -is [System.IO.IOException] -and (($e.HResult -band 0xFFFF) -eq 5 -or $e.Message -like 'Access to the path*is denied*')) { return $true } } $false } function Invoke-OtOpcUaMoveWithRetry { <# .SYNOPSIS Runs -Move (the first folder rename of the swap, before anything has moved) and retries it while it fails with a sharing violation, for at most -RetrySeconds (lmxopcua#908 backstop; the process wait is the fix). Any other error, or a sharing violation past the budget, is rethrown. Returns the number of attempts. #> param( [Parameter(Mandatory)] [scriptblock]$Move, [int]$RetrySeconds = 10, [int]$DelayMilliseconds = 500 ) $sw = [System.Diagnostics.Stopwatch]::StartNew() $attempt = 0 while ($true) { $attempt++ try { & $Move; return $attempt } catch { if (-not (Test-OtOpcUaSharingViolation -Exception $_.Exception) -or $sw.Elapsed.TotalSeconds -ge $RetrySeconds) { throw } Write-Warning ("move attempt {0} failed (in use), retrying in {1} ms: {2}" -f $attempt, $DelayMilliseconds, $_.Exception.Message) Start-Sleep -Milliseconds $DelayMilliseconds } } } Export-ModuleMember -Function Get-OtOpcUaServiceSid, Get-OtOpcUaPeCacheSddl, Get-OtOpcUaPeEntriesAce, Get-OtOpcUaScriptWorkerFolderAce, Get-OtOpcUaInstallRootDenyAce, Resolve-OtOpcUaDenyPaths, Get-ScriptWorkerIsolationPlan, Invoke-ScriptWorkerIsolationStep, Get-OtOpcUaRecordedDenyPaths, Find-OtOpcUaDenyCoverageGap, Get-OtOpcUaIsolationSettings, Test-OtOpcUaPathWithin, Test-ScriptWorkerIsolationStep, Test-OtOpcUaRecordKeyAcl, Test-OtOpcUaStepInInstallRoot, Select-ScriptWorkerIsolationPlanScope, Get-OtOpcUaCoverageWalkRoots, Get-OtOpcUaProcessSnapshot, Find-OtOpcUaInstallHolder, Wait-OtOpcUaInstallReleased, Test-OtOpcUaSharingViolation, Invoke-OtOpcUaMoveWithRetry