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:
@@ -201,6 +201,71 @@ Assert-Throws { & $entry -Reapply -Verify 6>$null } '*exclusive*' '-Reapply and
|
||||
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 }
|
||||
}
|
||||
$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'),
|
||||
(First-Offset 'Move-Item' '$Candidate'), (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 ','))"
|
||||
|
||||
if ($IsWindows) {
|
||||
Write-Host 'SDDL parses on Windows'
|
||||
$ds = [System.Security.AccessControl.DirectorySecurity]::new()
|
||||
|
||||
Reference in New Issue
Block a user