fix(install): cut the swap's stopped window to the swapped-in folder (#905)
v2-ci / unit-tests (push) Has been cancelled
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user