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:
@@ -2,7 +2,8 @@
|
||||
.SYNOPSIS
|
||||
Windows-executed test of Install-ScriptWorkerIsolation.ps1 (lmxopcua#883, #890, #901): apply -> re-apply -> verify -> shorter
|
||||
-DenyPaths -> -Verify -> folder swap -> -Verify fails -> -Reapply -> -Verify passes -> -Reapply unchanged ->
|
||||
Invoke-OtOpcUaInstallSwap.ps1 (re-apply + verify built in; refuses a pre-#901 record) -> uninstall (from the record,
|
||||
Invoke-OtOpcUaInstallSwap.ps1 (re-apply + verify built in; refuses a pre-#901 record) -> -Scope InstallRoot/Outside
|
||||
after a swap, the swap's pre-stop gate, -NoStart, a -NoCarry rollback (lmxopcua#905) -> uninstall (from the record,
|
||||
without -DenyPaths) -> uninstall. ACL level only; the restricted-token proof that
|
||||
the worker cannot read the denied folders is InstallerLayoutIsolationTests (ScriptWorker.Tests, OTOPCUA_ISOLATION_INSTALLER_E2E=1).
|
||||
The dry-run harness (Test-ScriptWorkerIsolation.ps1) only asserts plan data; it cannot see host-specific failures such as
|
||||
@@ -12,6 +13,13 @@
|
||||
pwsh -NoProfile -File scripts\install\tests\Test-ScriptWorkerIsolation.Windows.ps1
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\install\tests\Test-ScriptWorkerIsolation.Windows.ps1
|
||||
|
||||
lmxopcua#905 timing: -TimingFiles N also builds a vd03-like tree (a denied ApiInstall folder holding the install, a
|
||||
candidate and N more files in other app folders, 48 protected-DACL files), installs the isolation on it and times
|
||||
Invoke-OtOpcUaInstallSwap.ps1. -TimingOnly skips the functional part. -ScriptsDir runs the timing against another
|
||||
copy of scripts\install (e.g. the scripts before #905) for a before/after:
|
||||
|
||||
pwsh -NoProfile -File ...\Test-ScriptWorkerIsolation.Windows.ps1 -TimingOnly -TimingFiles 17000 -ScriptsDir C:\old\install
|
||||
|
||||
.DESCRIPTION
|
||||
Needs an elevated Windows session. It works in a scratch folder under $env:TEMP with a scratch worker service name and a
|
||||
scratch stand-in host service (a disabled service that is never started), and uses -HostServiceAccount 'NT AUTHORITY\SYSTEM',
|
||||
@@ -22,7 +30,7 @@
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param([string]$WorkerServiceName = 'OtOpcUaIsoTestWorker', [string]$HostServiceName = 'OtOpcUaIsoTestHost', [switch]$LocalHostAccount,
|
||||
[string]$LocalHostAccountName = 'otopcuaIsoTestAcct')
|
||||
[string]$LocalHostAccountName = 'otopcuaIsoTestAcct', [int]$TimingFiles = 0, [switch]$TimingOnly, [string]$ScriptsDir)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
@@ -32,6 +40,8 @@ $principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIde
|
||||
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { Write-Host 'run elevated'; exit 2 }
|
||||
|
||||
$installer = Join-Path (Split-Path -Parent $PSScriptRoot) 'Install-ScriptWorkerIsolation.ps1'
|
||||
if (-not $ScriptsDir) { $ScriptsDir = Split-Path -Parent $PSScriptRoot }
|
||||
$exe = if ($PSVersionTable.PSEdition -eq 'Core') { (Get-Process -Id $PID).Path } else { 'powershell.exe' }
|
||||
Import-Module (Join-Path (Split-Path -Parent $PSScriptRoot) 'ScriptWorkerIsolation.psm1') -Force
|
||||
$ruleName = 'OtOpcUa-ScriptWorker-BlockOutbound'
|
||||
if (Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue) { Write-Host "refusing: firewall rule $ruleName already exists (real isolation installed on this node)"; exit 2 }
|
||||
@@ -54,12 +64,12 @@ function Invoke-Installer([string[]]$Extra) {
|
||||
$steps = @([regex]::Matches($out, '\[(?<o>[a-z]+)\s*\]\s+(?<id>[A-Za-z]+)\s') | ForEach-Object { [pscustomobject]@{ Outcome = $_.Groups['o'].Value; Id = $_.Groups['id'].Value } })
|
||||
[pscustomobject]@{ Exit = $LASTEXITCODE; Output = $out; Steps = $steps }
|
||||
}
|
||||
function Invoke-Mode([string]$Mode) {
|
||||
function Invoke-Mode([string]$Mode, [string[]]$Extra = @()) {
|
||||
# lmxopcua#901: -Reapply / -Verify with NO settings argument, only the scratch worker name (the record selector).
|
||||
$exe = if ($PSVersionTable.PSEdition -eq 'Core') { (Get-Process -Id $PID).Path } else { 'powershell.exe' }
|
||||
# Continue: an expected failure writes to stderr, which Stop would turn into a terminating error here. Whitespace is
|
||||
# collapsed (and pwsh's '|' margins dropped) because both hosts wrap the error text at the console width.
|
||||
$out = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $installer "-$Mode" -WorkerServiceName $WorkerServiceName 2>&1 | Out-String }
|
||||
$out = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $installer "-$Mode" -WorkerServiceName $WorkerServiceName @Extra 2>&1 | Out-String }
|
||||
$steps = @([regex]::Matches($out, '\[(?<o>[a-z]+)\s*\]\s+(?<id>[A-Za-z]+)\s+(?<t>\S+)') | ForEach-Object { [pscustomobject]@{ Outcome = $_.Groups['o'].Value; Id = $_.Groups['id'].Value; Target = $_.Groups['t'].Value } })
|
||||
$code = $LASTEXITCODE
|
||||
$missing = @([regex]::Matches($out, '\[MISSING\]\s+(?<id>[A-Za-z]+)\s+(?<t>\S+)') | ForEach-Object { "$($_.Groups['id'].Value) $($_.Groups['t'].Value)" })
|
||||
@@ -100,6 +110,7 @@ try {
|
||||
Set-ItemProperty -Path $hostKey -Name Environment -Type MultiString -Value ([string[]]@('Keep__Me=1'))
|
||||
Write-Host "Host: $($PSVersionTable.PSEdition) $($PSVersionTable.PSVersion); scratch $scratch; host account $script:hostAccount"
|
||||
|
||||
if (-not $TimingOnly) {
|
||||
Write-Host 'Dry run'
|
||||
$dry = Invoke-Installer @('-WhatIf', '-DenyPaths', ($deny -join ','))
|
||||
Assert ($dry.Exit -eq 0) "-WhatIf exits 0 ($($dry.Output))"
|
||||
@@ -223,6 +234,95 @@ try {
|
||||
Assert ($r4.Exit -ne 0 -and $r4.Flat -match 'full arguments') "-Reapply without a full record refuses and says how to fix it ($($r4.Exit))"
|
||||
Set-ItemProperty -Path $recordKey -Name InstallRoot -Value $saved
|
||||
|
||||
Write-Host 'Scoped -Reapply / -Verify after a raw swap (lmxopcua#905)'
|
||||
$cand4 = Join-Path $scratch 'install.new4'
|
||||
New-Item -ItemType Directory -Path (Join-Path $cand4 'script-worker') -Force | Out-Null
|
||||
Set-Content -LiteralPath (Join-Path $cand4 'script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe') -Value 'stub v4'
|
||||
$prev4 = Join-Path $scratch 'install.bak.4'
|
||||
Move-Item -LiteralPath $root -Destination $prev4
|
||||
Move-Item -LiteralPath $cand4 -Destination $root
|
||||
& robocopy (Join-Path $prev4 'pki') (Join-Path $root 'pki') /E /COPY:DAT /R:0 /W:0 /NFL /NDL /NJH /NJS /NP | Out-Null
|
||||
$vo = Invoke-Mode 'Verify' @('-Scope', 'Outside'); Show 'Verify -Scope Outside (after swap)' $vo
|
||||
Assert ($vo.Exit -eq 0 -and $vo.Output -match 'VERIFY OK \(scope Outside\): 9/9') "a swap leaves the 9 Outside steps intact ($($vo.Output))"
|
||||
$vi = Invoke-Mode 'Verify' @('-Scope', 'InstallRoot'); Show 'Verify -Scope InstallRoot (after swap)' $vi
|
||||
Assert ($vi.Exit -eq 1 -and (@($vi.Missing | Sort-Object) -join '|') -eq (@($expectMissing | Sort-Object) -join '|') -and ($vi.Steps.Count + $vi.Missing.Count) -eq 3) `
|
||||
"the InstallRoot scope checks 3 steps and finds exactly the three dropped ACEs ($($vi.Missing -join ' | '))"
|
||||
$ri = Invoke-Mode 'Reapply' @('-Scope', 'InstallRoot'); Show 'Reapply -Scope InstallRoot' $ri
|
||||
$riChanged = @($ri.Steps | Where-Object Outcome -eq 'changed' | ForEach-Object { "$($_.Id) $($_.Target)" })
|
||||
Assert ($ri.Exit -eq 0 -and $ri.Steps.Count -eq 3 -and (@($riChanged | Sort-Object) -join '|') -eq (@($expectMissing | Sort-Object) -join '|')) `
|
||||
"scoped reapply runs 3 steps and changes exactly the three ($($riChanged -join ' | '))"
|
||||
$covLine = [regex]::Match($ri.Flat, 'coverage: \d+ denied folder.s.; \d+ folder.s. walked').Value
|
||||
Assert ($ri.Flat -match 'coverage: 4 denied folder\(s\); 1 folder\(s\) walked') "scoped reapply walks InstallRoot only ($covLine)"
|
||||
$vi2 = Invoke-Mode 'Verify' @('-Scope', 'InstallRoot'); $vAll = Invoke-Mode 'Verify'; Show 'Verify (full, after scoped Reapply)' $vAll
|
||||
Assert ($vi2.Exit -eq 0 -and $vAll.Exit -eq 0 -and $vAll.Output -match 'VERIFY OK: 12/12') 'after the scoped reapply both the scoped and the full verify pass'
|
||||
$scanLine = [regex]::Match($vAll.Flat, 'coverage scan: \d+ folder.s. walked').Value
|
||||
Assert ($vAll.Flat -match 'coverage scan: 1 folder\(s\) walked') "the full verify walks the outermost denied folder once ($scanLine)"
|
||||
|
||||
Write-Host 'Negative control: each scope sees only its own entries'
|
||||
& icacls $cache /grant '*S-1-5-32-545:(OI)(CI)(RX)' /Q | Out-Null # an Outside entry broken
|
||||
$vi3 = Invoke-Mode 'Verify' @('-Scope', 'InstallRoot'); $vo3 = Invoke-Mode 'Verify' @('-Scope', 'Outside'); Show 'Verify -Scope Outside (cache broken)' $vo3
|
||||
Assert ($vi3.Exit -eq 0 -and $vo3.Exit -eq 1 -and ($vo3.Missing -join '|') -eq "PeCacheDir $cache") "the cache ACL is Outside's to catch, not InstallRoot's ($($vo3.Missing -join ' | '))"
|
||||
$ro3 = Invoke-Mode 'Reapply' @('-Scope', 'Outside')
|
||||
Assert ($ro3.Exit -eq 0 -and (@($ro3.Steps | Where-Object Outcome -eq 'changed' | ForEach-Object Id) -join ',') -eq 'PeCacheDir' -and $ro3.Steps.Count -eq 9) 'Reapply -Scope Outside repairs it (9 steps, 1 changed)'
|
||||
|
||||
Write-Host 'Coverage scan: protected items found once, script-worker\ skipped, scope-limited'
|
||||
$prot = Join-Path $root 'pki\own.key'; $protSkip = Join-Path $root 'script-worker\private.key'
|
||||
foreach ($f in $prot, $protSkip) { Set-Content -LiteralPath $f -Value 'k'; & icacls $f /inheritance:r /grant:r '*S-1-5-18:(F)' '*S-1-5-32-544:(F)' /Q | Out-Null }
|
||||
$squash = { param($t) $t -replace '[\s|]+', '' }
|
||||
$vc = Invoke-Mode 'Verify' @('-Scope', 'InstallRoot'); $vco = Invoke-Mode 'Verify' @('-Scope', 'Outside'); $vca = Invoke-Mode 'Verify'
|
||||
$hit = (& $squash $prot) + 'doesnotinherit'
|
||||
Assert ($vc.Exit -eq 0 -and (& $squash $vc.Output).Contains($hit)) 'InstallRoot scope warns the protected pki\ file (a warning, not a failure)'
|
||||
Assert (-not (& $squash $vc.Output).Contains((& $squash $protSkip))) 'script-worker\ is not walked'
|
||||
Assert (-not (& $squash $vco.Output).Contains($hit)) 'the Outside scope skips InstallRoot'
|
||||
Assert (([regex]::Matches((& $squash $vca.Output), [regex]::Escape($hit))).Count -eq 1) 'the full verify reports it exactly once (pki\ is inside two denied folders)'
|
||||
foreach ($f in $prot, $protSkip) { & icacls $f /reset /Q | Out-Null; Remove-Item -LiteralPath $f -Force }
|
||||
|
||||
Write-Host 'Invoke-OtOpcUaInstallSwap.ps1 refuses a candidate without the worker exe the firewall rule names (lmxopcua#905)'
|
||||
$cand6 = Join-Path $scratch 'install.new6'
|
||||
New-Item -ItemType Directory -Path (Join-Path $cand6 'script-worker') -Force | Out-Null
|
||||
$sw6 = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $swapScript -InstallRoot $root -Candidate $cand6 `
|
||||
-PreviousInstall (Join-Path $scratch 'install.bak.6') -NoServiceControl -WorkerServiceName $WorkerServiceName 2>&1 | Out-String }
|
||||
$sw6Exit = $LASTEXITCODE
|
||||
Assert ($sw6Exit -ne 0 -and ($sw6 -replace '[\s|]+', '') -match 'candidatehasno' -and (Test-Path $cand6) -and -not (Test-Path (Join-Path $scratch 'install.bak.6')) -and
|
||||
$sw6 -notmatch 'Scope Outside') "refused in preflight, before the pre-stop isolation; nothing moved ($sw6)"
|
||||
Remove-Item -LiteralPath $cand6 -Recurse -Force
|
||||
|
||||
Write-Host 'Invoke-OtOpcUaInstallSwap.ps1: the pre-stop gate stops before anything moves'
|
||||
$dataDir = Join-Path $scratch 'data'
|
||||
& icacls $dataDir /reset /Q | Out-Null; Remove-Item -LiteralPath $dataDir -Force # an Outside deny folder gone: -Reapply -Scope Outside refuses
|
||||
$cand5 = Join-Path $scratch 'install.new5'
|
||||
New-Item -ItemType Directory -Path (Join-Path $cand5 'script-worker') -Force | Out-Null
|
||||
Set-Content -LiteralPath (Join-Path $cand5 'script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe') -Value 'stub v5'
|
||||
$prev5 = Join-Path $scratch 'install.bak.5'
|
||||
$sw3 = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $swapScript -InstallRoot $root -Candidate $cand5 `
|
||||
-PreviousInstall $prev5 -Carry 'appsettings*.json,pki' -ServiceName $HostServiceName -WorkerServiceName $WorkerServiceName 2>&1 | Out-String }
|
||||
$sw3Exit = $LASTEXITCODE
|
||||
Write-Host (" {0,-34} exit {1}; {2}" -f 'swap with an Outside gap', $sw3Exit, $(if ((& $squash $sw3) -match 'ScopeOutsidefailed') { 'refused before the stop' } else { 'NOT refused' }))
|
||||
Assert ($sw3Exit -ne 0 -and (& $squash $sw3) -match 'ScopeOutsidefailed.*nothingstoppedorswapped' -and (Test-Path $cand5) -and -not (Test-Path $prev5) -and
|
||||
(Get-Content -LiteralPath $workerExe) -eq 'stub v4' -and $sw3 -notmatch '==> stop') "refused before the stop, nothing moved ($sw3)"
|
||||
New-Item -ItemType Directory -Path $dataDir -Force | Out-Null
|
||||
|
||||
Write-Host 'Invoke-OtOpcUaInstallSwap.ps1 -NoStart with service control (the stand-in host service is disabled: a start would fail)'
|
||||
$sw4 = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $swapScript -InstallRoot $root -Candidate $cand5 `
|
||||
-PreviousInstall $prev5 -Carry 'appsettings*.json,pki' -ServiceName $HostServiceName -NoStart -WorkerServiceName $WorkerServiceName 2>&1 | Out-String }
|
||||
$sw4Exit = $LASTEXITCODE
|
||||
$w4 = [regex]::Match($sw4, 'stop -> swapped and verified \(left stopped\): [^\r\n]*').Value
|
||||
Write-Host (" {0,-34} exit {1}; {2}" -f 'swap -NoStart', $sw4Exit, $w4)
|
||||
Assert ($sw4Exit -eq 0 -and $sw4 -match 'Swap complete' -and $w4 -and $sw4 -notmatch '==> start' -and (Get-Service $HostServiceName).Status -eq 'Stopped') "-NoStart: swapped, verified, not started ($sw4)"
|
||||
Assert ($sw4 -match 'pre-stop isolation' -and $sw4 -match 'VERIFY OK \(scope Outside\)' -and $sw4 -match 'VERIFY OK \(scope InstallRoot\)') 'both scoped gates ran (Outside before the stop, InstallRoot after the swap)'
|
||||
Assert ($sw4.IndexOf('VERIFY OK (scope Outside)') -lt $sw4.IndexOf('==> stop') -and $sw4.IndexOf('==> stop') -lt $sw4.IndexOf('-Reapply -Scope InstallRoot')) 'Outside gate before the stop, InstallRoot after it'
|
||||
Assert ((Get-Content -LiteralPath $workerExe) -eq 'stub v5' -and @(Get-Explicit $root $workerSid).Count -eq 1 -and @(Get-Explicit (Join-Path $root 'pki') $workerSid).Count -eq 1) 'v5 live, its ACEs in place'
|
||||
|
||||
Write-Host 'Invoke-OtOpcUaInstallSwap.ps1 -NoCarry: the rollback swaps the previous install back'
|
||||
$failed5 = Join-Path $scratch 'install.failed.5'
|
||||
$sw5 = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $swapScript -InstallRoot $root -Candidate $prev5 `
|
||||
-PreviousInstall $failed5 -NoCarry -NoServiceControl -WorkerServiceName $WorkerServiceName 2>&1 | Out-String }
|
||||
$sw5Exit = $LASTEXITCODE
|
||||
Write-Host (" {0,-34} exit {1}; {2}" -f 'swap -NoCarry (rollback)', $sw5Exit, [regex]::Match($sw5, 'swap window[^\r\n]*').Value)
|
||||
Assert ($sw5Exit -eq 0 -and $sw5 -notmatch 'carried' -and (Get-Content -LiteralPath $workerExe) -eq 'stub v4' -and $sw5 -match 'VERIFY OK \(scope InstallRoot\)') "rollback: v4 back, nothing carried, gate passed ($sw5)"
|
||||
$vEnd = Invoke-Mode 'Verify'
|
||||
Assert ($vEnd.Exit -eq 0) 'full verify passes after the rollback'
|
||||
|
||||
Write-Host 'Uninstall WITHOUT -DenyPaths (the record drives it), twice'
|
||||
$u1 = Invoke-Installer @('-Uninstall')
|
||||
Assert ($u1.Exit -eq 0) "uninstall exits 0 ($($u1.Output))"
|
||||
@@ -239,6 +339,53 @@ try {
|
||||
$u2 = Invoke-Installer @('-Uninstall')
|
||||
Assert ($u2.Exit -eq 0) "second uninstall exits 0 ($($u2.Output))"
|
||||
Assert ($u2.Steps.Count -eq 7 -and @($u2.Steps | Where-Object Outcome -ne 'unchanged').Count -eq 0) "second uninstall: 7 steps, all unchanged ($(($u2.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))"
|
||||
}
|
||||
|
||||
if ($TimingFiles -gt 0) {
|
||||
# lmxopcua#905: a vd03-like layout. ApiInstall is denied as a whole and holds the install folder, the candidate and
|
||||
# other app folders; pki\ is inside the install; 48 protected-DACL files (vd03 reported 48).
|
||||
Write-Host "Swap timing (lmxopcua#905): scripts from $ScriptsDir"
|
||||
$tInstaller = Join-Path $ScriptsDir 'Install-ScriptWorkerIsolation.ps1'
|
||||
$tSwap = Join-Path $ScriptsDir 'Invoke-OtOpcUaInstallSwap.ps1'
|
||||
$api = Join-Path $scratch 'ApiInstall'; $tRoot = Join-Path $api 'OtOpcUa'; $tCand = Join-Path $api 'OtOpcUa.new'
|
||||
$tData = Join-Path $api 'OtOpcUa-data'; $tKek = Join-Path $scratch 'kek'; $tCache = Join-Path $scratch 'pe-cache-t'
|
||||
function New-DummyApp([string]$Dir, [int]$N, [string]$Stub) {
|
||||
for ($d = 0; $d -lt 60; $d++) { [IO.Directory]::CreateDirectory("$Dir\sub$d") | Out-Null }
|
||||
for ($i = 0; $i -lt $N; $i++) { [IO.File]::WriteAllText("$Dir\sub$($i % 60)\f$i.dll", 'x') }
|
||||
if ($Stub) { [IO.Directory]::CreateDirectory("$Dir\script-worker") | Out-Null; [IO.File]::WriteAllText("$Dir\script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe", $Stub) }
|
||||
}
|
||||
New-DummyApp $tRoot 1400 'live'
|
||||
foreach ($d in "$tRoot\pki\own\private", "$tRoot\logs", $tData, $tKek) { [IO.Directory]::CreateDirectory($d) | Out-Null }
|
||||
[IO.File]::WriteAllText("$tRoot\appsettings.json", '{}')
|
||||
New-DummyApp $tCand 1400 'candidate'
|
||||
$left = [Math]::Max(0, $TimingFiles - 2800); $n = 0
|
||||
while ($left -gt 0) { $n++; $take = [Math]::Min(1400, $left); New-DummyApp (Join-Path $api "app$n") $take ''; $left -= $take }
|
||||
$protDir = if ($n) { Join-Path $api 'app1\sub0' } else { $tData }
|
||||
for ($p = 0; $p -lt 48; $p++) { $f = "$protDir\prot$p.key"; [IO.File]::WriteAllText($f, 'k'); & icacls $f /inheritance:r /grant:r '*S-1-5-18:(F)' '*S-1-5-32-544:(F)' /Q | Out-Null }
|
||||
$items = @([IO.DirectoryInfo]::new($api).EnumerateFileSystemInfos('*', [IO.SearchOption]::AllDirectories)).Count
|
||||
$tDeny = "$api,$tRoot\pki,$tData,$tKek"
|
||||
$ti = [Diagnostics.Stopwatch]::StartNew()
|
||||
$tiOut = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $tInstaller -InstallRoot $tRoot -HostServiceAccount $script:hostAccount `
|
||||
-PeCacheDir $tCache -WorkerServiceName $WorkerServiceName -HostServiceName $HostServiceName -ConfigureHostService -DenyPaths $tDeny 2>&1 | Out-String }
|
||||
$tiExit = $LASTEXITCODE; $ti.Stop()
|
||||
Assert ($tiExit -eq 0) "timing: isolation install exits 0 ($tiOut)"
|
||||
$ts = [Diagnostics.Stopwatch]::StartNew()
|
||||
$tsOut = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $tSwap -InstallRoot $tRoot -Candidate $tCand `
|
||||
-PreviousInstall (Join-Path $api 'OtOpcUa.bak.t') -NoServiceControl -WorkerServiceName $WorkerServiceName 2>&1 | Out-String }
|
||||
$tsExit = $LASTEXITCODE; $ts.Stop()
|
||||
Assert ($tsExit -eq 0 -and $tsOut -match 'Swap complete' -and [IO.File]::ReadAllText("$tRoot\script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe") -eq 'candidate') "timing: swap exits 0 ($tsOut)"
|
||||
$wm = [regex]::Match($tsOut, 'swap window[^:]*: (?<s>[0-9.,]+) s(?<rest>[^\r\n]*)')
|
||||
# Before #905 the whole swap script ran inside the stopped window (nothing ran before the stop).
|
||||
$window = if ($wm.Success) { [double]::Parse($wm.Groups['s'].Value, [Globalization.CultureInfo]::CurrentCulture) } else { $ts.Elapsed.TotalSeconds }
|
||||
$pre = [regex]::Match($tsOut, 'pre-stop isolation (?<s>[0-9.,]+) s').Groups['s'].Value
|
||||
Write-Host (" TIMING {0} {1}: {2} items under the denied folder; install {3:N1} s; swap script {4:N1} s; pre-stop {5}; STOPPED WINDOW {6:N1} s {7}" -f `
|
||||
$PSVersionTable.PSEdition, $PSVersionTable.PSVersion, $items, $ti.Elapsed.TotalSeconds, $ts.Elapsed.TotalSeconds,
|
||||
$(if ($pre) { "$pre s" } else { 'none (all in the window)' }), $window, $wm.Groups['rest'].Value)
|
||||
$tv = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $tInstaller -Verify -WorkerServiceName $WorkerServiceName 2>&1 | Out-String }
|
||||
Assert ($LASTEXITCODE -eq 0) 'timing: full verify passes after the swap'
|
||||
$tu = & { $ErrorActionPreference = 'Continue'; & $exe -NoProfile -ExecutionPolicy Bypass -File $tInstaller -Uninstall -WorkerServiceName $WorkerServiceName 2>&1 | Out-String }
|
||||
Assert ($LASTEXITCODE -eq 0 -and -not (Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue)) "timing: uninstall from the record ($tu)"
|
||||
}
|
||||
}
|
||||
catch { $script:failures++; Write-Host " FAIL: unexpected error: $($_ | Out-String)" -ForegroundColor Red }
|
||||
finally {
|
||||
|
||||
@@ -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