fix(install): cut the swap's stopped window to the swapped-in folder (#905)
v2-ci / unit-tests (push) Has been cancelled

Root cause: the protected-DACL coverage scan, not the ACL writes. Both -Reapply and
-Verify walked every denied folder (all of E:\ApiInstall on vd03, nested roots again)
with Get-ChildItem -Recurse + Get-Acl per item + a pipeline per item for -Skip, about
3.4 ms an item on PS 5.1, all while the service was stopped. An unchanged deny is read,
not rewritten; the re-applied install-folder deny propagates over ~1,400 files in 0.5 s.

- Find-OtOpcUaDenyCoverageGap: .NET enumeration, -Skip folders pruned, DACL-only
  GetAccessControl(Access) (about 0.15 ms an item); reparse points not followed.
- Get-OtOpcUaCoverageWalkRoots: each denied folder walked once (outermost only).
- Install-ScriptWorkerIsolation.ps1 -Scope All|InstallRoot|Outside (with -Reapply,
  -Verify, -PlanOnly). InstallRoot = the folder ACEs a swap drops; Outside = the rest,
  including the firewall rule (its query costs 3-5 s; its exe path does not change).
  The two partition the plan. Prints the scan time.
- Invoke-OtOpcUaInstallSwap.ps1: -Reapply/-Verify -Scope Outside before the stop (a
  failure stops before anything moves), -Scope InstallRoot inside the window; preflight
  checks the candidate carries the worker exe; prints the stopped window and its parts.
  -NoStart, -NoCarry and the pre-#901 refusal unchanged.

windev, vd03-like tree (17,851 items): stopped window 153.1 s -> 6.0 s (PS 5.1),
101.6 s -> 6.2 s (PS 7); the Outside part takes ~23 s with the old build serving.
Dry-run harness 101 -> 123 passed; Windows test 72 passed on PS 5.1 and PS 7.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
This commit is contained in:
Joseph Doherty
2026-11-13 03:51:41 -05:00
parent f817c77e02
commit 0c19f26c81
8 changed files with 475 additions and 58 deletions
+101 -11
View File
@@ -588,22 +588,112 @@ function Find-OtOpcUaDenyCoverageGap {
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 (script-worker\) is not walked.
$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 = @())
if (-not (Test-Path -LiteralPath $Path)) { return }
$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)
foreach ($item in @(Get-ChildItem -LiteralPath $Path -Recurse -Force -ErrorAction SilentlyContinue)) {
if (@($Skip | Where-Object { $_ -and (Test-OtOpcUaPathWithin -Path $item.FullName -Ancestor $_) }).Count) { continue }
try { $acl = Get-Acl -LiteralPath $item.FullName } 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 }
$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 { @() }) }
}
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-ScriptWorkerIsolationStep, Test-OtOpcUaRecordKeyAcl
Invoke-ScriptWorkerIsolationStep, Get-OtOpcUaRecordedDenyPaths, Find-OtOpcUaDenyCoverageGap, Get-OtOpcUaIsolationSettings, Test-OtOpcUaPathWithin,
Test-ScriptWorkerIsolationStep, Test-OtOpcUaRecordKeyAcl, Test-OtOpcUaStepInInstallRoot, Select-ScriptWorkerIsolationPlanScope,
Get-OtOpcUaCoverageWalkRoots