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
@@ -50,12 +50,15 @@
.\Install-ScriptWorkerIsolation.ps1 -Reapply # the recorded settings; prints changed/unchanged per step
.\Install-ScriptWorkerIsolation.ps1 -Verify # check only; exit 1 and a [MISSING] line per gap
Invoke-OtOpcUaInstallSwap.ps1 does both after its swap and does not start the service when -Verify fails. The host
must be restarted for -ConfigureHostService to take effect.
Invoke-OtOpcUaInstallSwap.ps1 does both around its swap and does not start the service when -Verify fails: -Scope
Outside while the old build still serves, -Scope InstallRoot after the swap (lmxopcua#905). The host must be restarted
for -ConfigureHostService to take effect.
After an apply it walks every denied folder and WARNs about any file or folder whose DACL is protected (does not
inherit) and has no deny of its own; the inherited deny does not reach those. Without -DenyPaths it WARNs when the
folder around InstallRoot has a pki, data, logs, backup, config or secrets sibling.
folder around InstallRoot has a pki, data, logs, backup, config or secrets sibling. The walk covers each denied
folder once (a folder inside another denied folder is walked with it) and prints its time; about 0.15 ms an item
(lmxopcua#905: before, Get-Acl per item made it about 3.4 ms, 59 s for 17.5k items).
.PARAMETER InstallRoot
The host install folder (holds OtOpcUa.Host.exe and script-worker\).
@@ -94,6 +97,18 @@
when anything is missing (0 when all is in place). Uses the recorded settings, or -InstallRoot/-HostServiceAccount/...
when given. Protected-DACL coverage gaps are WARNed, as after an apply, and do not fail the check.
.PARAMETER Scope
lmxopcua#905, with -Reapply, -Verify or -PlanOnly. Which install steps run:
All every step (default).
InstallRoot only what a folder swap can undo: the folder ACEs on InstallRoot and below (WorkerFolderAcl,
InstallRootDeny, a -DenyPaths folder inside InstallRoot such as vd03's pki\). The coverage scan walks
InstallRoot only.
Outside every other step: the anchor service, the records, the PE cache, the denies outside InstallRoot, the
firewall rule (its path to the worker exe does not change with a swap; the query costs seconds), the
host service env. The coverage scan walks the denied folders and skips InstallRoot.
InstallRoot and Outside together are exactly All. Invoke-OtOpcUaInstallSwap.ps1 runs Outside before it stops the
service and InstallRoot inside the stopped window.
.PARAMETER PlanOnly
Print the plan and return it as objects without touching (or even reading) the machine. Runs on any OS; used by the
dry-run harness scripts/install/tests/Test-ScriptWorkerIsolation.ps1. -HostServiceAccount must then be a SID.
@@ -131,7 +146,9 @@ param(
[switch]$RemovePeCache,
[switch]$Reapply,
[switch]$Verify,
[switch]$PlanOnly
[switch]$PlanOnly,
# lmxopcua#905: with -Reapply / -Verify / -PlanOnly only. Which steps run (see .PARAMETER Scope).
[ValidateSet('All', 'InstallRoot', 'Outside')] [string]$Scope = 'All'
)
Set-StrictMode -Version Latest
@@ -140,6 +157,7 @@ Import-Module (Join-Path $PSScriptRoot 'ScriptWorkerIsolation.psm1') -Force
# lmxopcua#901: -Reapply / -Verify / -Uninstall without arguments run from what the last install recorded.
if (@($Reapply, $Verify, $Uninstall, $PlanOnly | Where-Object { $_ }).Count -gt 1) { throw '-Reapply, -Verify, -Uninstall and -PlanOnly are exclusive' }
if ($Scope -ne 'All' -and -not ($Reapply -or $Verify -or $PlanOnly)) { throw '-Scope works with -Reapply, -Verify or -PlanOnly only (an install or -Uninstall always covers every step)' }
if ($Reapply) {
$given = @('InstallRoot', 'HostServiceAccount', 'PeCacheDir', 'HostServiceName', 'DenyPaths', 'ConfigureHostService', 'RemovePeCache' |
Where-Object { $PSBoundParameters.ContainsKey($_) })
@@ -188,11 +206,25 @@ $plan = Get-ScriptWorkerIsolationPlan -InstallRoot $InstallRoot -HostSid $hostSi
-WorkerServiceName $WorkerServiceName -HostServiceName $HostServiceName -DenyPaths $DenyPaths -RecordedDenyPaths $recorded `
-ConfigureHostService:$ConfigureHostService -Uninstall:$Uninstall -RemovePeCache:$RemovePeCache
$denied = @($plan | Where-Object { $_.Id -eq 'DenyPathRecord' -and $_.Data.ContainsKey('Paths') } | ForEach-Object { $_.Data.Paths } | Where-Object { $_ })
# lmxopcua#905: a swap runs -Scope Outside while the old build still serves and -Scope InstallRoot after the swap, so only
# the steps the swap can undo run while the service is stopped. The two scopes partition the plan.
$plan = @(Select-ScriptWorkerIsolationPlanScope -Plan $plan -InstallRoot $InstallRoot -Scope $Scope)
$deniedInScope = @($denied | Where-Object { $Scope -eq 'All' -or ((Test-OtOpcUaPathWithin -Path $_ -Ancestor $InstallRoot) -eq ($Scope -eq 'InstallRoot')) })
function Invoke-CoverageScan {
# The protected-DACL coverage scan (lmxopcua#890), over the outermost folders of this scope only (lmxopcua#905).
$walk = Get-OtOpcUaCoverageWalkRoots -InstallRoot $InstallRoot -DeniedPaths $denied -Scope $Scope
$skip = @((Join-Path $InstallRoot.TrimEnd('\') 'script-worker'), $PeCacheDir.TrimEnd('\')) + @($walk.Skip)
$sw = [System.Diagnostics.Stopwatch]::StartNew()
$found = @(foreach ($p in $walk.Roots) { Find-OtOpcUaDenyCoverageGap -Path $p -WorkerSid $workerSid -Skip $skip })
$sw.Stop()
[pscustomobject]@{ Gaps = @($found | Sort-Object -Unique); Roots = @($walk.Roots); Seconds = $sw.Elapsed.TotalSeconds }
}
$workerSid = Get-OtOpcUaServiceSid -Name $WorkerServiceName
Write-Host ("ScriptWorker isolation {0}: worker identity NT SERVICE\{1} ({2}), host account {3} ({4}){5}" -f `
Write-Host ("ScriptWorker isolation {0}: worker identity NT SERVICE\{1} ({2}), host account {3} ({4}){5}{6}" -f `
$(if ($Uninstall) { 'ROLLBACK' } elseif ($Verify) { 'VERIFY' } elseif ($Reapply) { 'REAPPLY' } else { 'install' }), $WorkerServiceName,
$workerSid, $HostServiceAccount, $hostSid, $(if ($fromRecord) { ', settings from the record' } else { '' }))
$workerSid, $HostServiceAccount, $hostSid, $(if ($fromRecord) { ', settings from the record' } else { '' }),
$(if ($Scope -ne 'All') { ", scope $Scope ($($plan.Count) step(s))" } else { '' }))
if ($fromRecord) {
Write-Host (" InstallRoot {0}; PeCacheDir {1}; DenyPaths {2}; ConfigureHostService {3}" -f $InstallRoot, $PeCacheDir,
$(if (@($DenyPaths).Count) { $DenyPaths -join ', ' } else { 'none' }), [bool]$ConfigureHostService)
@@ -212,17 +244,19 @@ if ($Verify) {
Write-Host (" [{0,-7}] {1,-22} {2} - {3}" -f $(if ($r.Ok) { 'ok' } else { 'MISSING' }), $step.Id, $step.Target, $r.Detail)
[pscustomobject]@{ Id = $step.Id; Target = $step.Target; Ok = [bool]$r.Ok; Detail = $r.Detail }
}
$workerFolder = Join-Path $InstallRoot.TrimEnd('\') 'script-worker'
$gaps = @(foreach ($p in @($InstallRoot.TrimEnd('\')) + $denied) { Find-OtOpcUaDenyCoverageGap -Path $p -WorkerSid $workerSid -Skip @($workerFolder, $PeCacheDir.TrimEnd('\')) })
foreach ($g in @($gaps | Sort-Object -Unique)) { Write-Warning "$g does not inherit the worker deny (protected DACL, no deny of its own); check it grants nothing to Everyone, BUILTIN\Users or RESTRICTED." }
$scan = Invoke-CoverageScan
foreach ($g in $scan.Gaps) { Write-Warning "$g does not inherit the worker deny (protected DACL, no deny of its own); check it grants nothing to Everyone, BUILTIN\Users or RESTRICTED." }
Write-Host (" coverage scan: {0} folder(s) walked ({1}), {2} protected item(s) the deny does not reach, {3:N1} s" -f `
$scan.Roots.Count, $(if ($scan.Roots.Count) { $scan.Roots -join '; ' } else { 'none' }), $scan.Gaps.Count, $scan.Seconds)
$scopeNote = if ($Scope -ne 'All') { " (scope $Scope)" } else { '' }
$missing = @($checks | Where-Object { -not $_.Ok })
Write-Host ''
if ($missing.Count) {
Write-Host ("VERIFY FAILED: {0} of {1} step(s) not in place: {2}. Run -Reapply (as administrator) and verify again." -f `
$missing.Count, @($checks).Count, (($missing | ForEach-Object { "$($_.Id) $($_.Target)" }) -join '; '))
Write-Host ("VERIFY FAILED{3}: {0} of {1} step(s) not in place: {2}. Run -Reapply (as administrator) and verify again." -f `
$missing.Count, @($checks).Count, (($missing | ForEach-Object { "$($_.Id) $($_.Target)" }) -join '; '), $scopeNote)
exit 1
}
Write-Host ("VERIFY OK: {0}/{0} step(s) in place." -f @($checks).Count)
Write-Host ("VERIFY OK{1}: {0}/{0} step(s) in place." -f @($checks).Count, $scopeNote)
exit 0
}
if (-not $Uninstall -and -not (Test-Path -LiteralPath (Join-Path $InstallRoot 'script-worker'))) {
@@ -230,7 +264,7 @@ if (-not $Uninstall -and -not (Test-Path -LiteralPath (Join-Path $InstallRoot 's
}
if (-not $Uninstall) {
# Checked up front so a typo stops the run before any step changes the machine.
$missing = @($denied | Where-Object { -not (Test-Path -LiteralPath $_ -PathType Container) })
$missing = @($deniedInScope | Where-Object { -not (Test-Path -LiteralPath $_ -PathType Container) })
if ($missing.Count) { throw "DenyPaths folder(s) not found: $($missing -join '; ')" }
if ($denied.Count -eq 0) {
$parent = Split-Path -Parent $InstallRoot.TrimEnd('\')
@@ -262,13 +296,13 @@ if (-not $WhatIfPreference) {
if (-not $WhatIfPreference -and -not $Uninstall) {
# The inherited deny stops at a protected DACL; list every such item that has no deny of its own (lmxopcua#890).
$workerFolder = Join-Path $InstallRoot.TrimEnd('\') 'script-worker'
$gaps = @(foreach ($p in @($InstallRoot.TrimEnd('\')) + $denied) { Find-OtOpcUaDenyCoverageGap -Path $p -WorkerSid $workerSid -Skip @($workerFolder, $PeCacheDir.TrimEnd('\')) })
$gaps = @($gaps | Sort-Object -Unique)
$scan = Invoke-CoverageScan
$gaps = $scan.Gaps
foreach ($g in $gaps) {
Write-Warning "$g does not inherit the worker deny (protected DACL, no deny of its own). Check its ACL grants nothing to Everyone, BUILTIN\Users or RESTRICTED, or add it to -DenyPaths."
}
Write-Host (" coverage: {0} denied folder(s); {1} protected item(s) the deny does not reach" -f (1 + $denied.Count), $gaps.Count)
Write-Host (" coverage: {0} denied folder(s); {1} folder(s) walked; {2} protected item(s) the deny does not reach; {3:N1} s" -f `
(1 + $denied.Count), $scan.Roots.Count, $gaps.Count, $scan.Seconds)
}
if (-not $WhatIfPreference) {