diff --git a/Tests/Lab/Invoke-NTFSSecurityLabTest.ps1 b/Tests/Lab/Invoke-NTFSSecurityLabTest.ps1 index 0b46427..683aee9 100644 --- a/Tests/Lab/Invoke-NTFSSecurityLabTest.ps1 +++ b/Tests/Lab/Invoke-NTFSSecurityLabTest.ps1 @@ -466,25 +466,51 @@ $fileServerSetupScript = { $null = New-LocalGroup -Name $LocalGroupName -Description 'NTFSSecurity live tests' } - if ($SubjectSid -notin @(Get-LocalGroupMember -Name $LocalGroupName | ForEach-Object -Process { $_.SID.Value })) { + # Add the members and ignore the error for a member that exists. A check with Get-LocalGroupMember would fail with "Failed to + # compare two elements in the array" in Windows PowerShell 5.1 as soon as the group holds an orphaned SID, for example that of + # an account that an earlier run deleted. + try { Add-LocalGroupMember -Name $LocalGroupName -Member $SubjectSid } + catch { + if ($_.Exception.GetType().Name -ne 'MemberExistsException') { + throw + } + } foreach ($sid in $AdministratorSid) { - if ($sid -notin @(Get-LocalGroupMember -SID 'S-1-5-32-544' | ForEach-Object -Process { $_.SID.Value })) { + try { Add-LocalGroupMember -SID 'S-1-5-32-544' -Member $sid } + catch { + if ($_.Exception.GetType().Name -ne 'MemberExistsException') { + throw + } + } } if (-not (Test-Path -LiteralPath $ShareLocalPath)) { $null = New-Item -ItemType Directory -Path $ShareLocalPath } - # The folders of earlier runs, also those with paths longer than 260 characters, which PowerShell 7 removes. - $command = '$ErrorActionPreference = ''Stop''; Get-ChildItem -LiteralPath ''{0}'' -Force | Remove-Item -Recurse -Force' -f $ShareLocalPath - $output = & (Join-Path -Path $env:ProgramFiles -ChildPath 'PowerShell\7\pwsh.exe') -NoProfile -NonInteractive -EncodedCommand ([Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($command))) 2>&1 - if ($LASTEXITCODE -ne 0) { - throw "The folders of earlier runs could not be removed: $output" + # The folders of earlier runs, also those with paths longer than 260 characters, which PowerShell 7 removes. A recursive removal can + # fail with "The directory is not empty" while another process, such as a virus scanner, still holds a handle to an item that was + # just deleted (seen on Windows Server 2019), so it is repeated. The command writes its errors to its output: a line on stderr would + # end this script at once, because the error action here is Stop and 2>&1 turns that line into a terminating error. + $command = '$errors = @(); Get-ChildItem -LiteralPath ''__PATH__'' -Force | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue -ErrorVariable errors; $errors | ForEach-Object -Process { "$_" }'.Replace('__PATH__', $ShareLocalPath) + $encoded = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($command)) + $attempt = 0 + do { + $attempt++ + if ($attempt -gt 1) { + Start-Sleep -Seconds 5 + } + + $output = & (Join-Path -Path $env:ProgramFiles -ChildPath 'PowerShell\7\pwsh.exe') -NoProfile -NonInteractive -EncodedCommand $encoded 2>&1 + } while (@(Get-ChildItem -LiteralPath $ShareLocalPath -Force -ErrorAction SilentlyContinue).Count -gt 0 -and $attempt -lt 6) + + if (@(Get-ChildItem -LiteralPath $ShareLocalPath -Force -ErrorAction SilentlyContinue).Count -gt 0) { + throw "The folders of earlier runs could not be removed in $attempt attempts: $output" } # Administrators and the system own the share; the delegated group may read it, and the folders of the cases grant @@ -524,9 +550,15 @@ $clientSetupScript = { @{ Group = 'S-1-5-32-580'; Members = $RemoteUserSid } )) { foreach ($sid in $membership.Members) { - if ($sid -notin @(Get-LocalGroupMember -SID $membership.Group | ForEach-Object -Process { $_.SID.Value })) { + # See the file server setup: Get-LocalGroupMember fails on an orphaned SID. + try { Add-LocalGroupMember -SID $membership.Group -Member $sid } + catch { + if ($_.Exception.GetType().Name -ne 'MemberExistsException') { + throw + } + } } } @@ -865,12 +897,28 @@ $removeFileServerScript = { } foreach ($path in $ShareLocalPath, $PayloadPath) { - if (Test-Path -LiteralPath $path) { - $command = '$ErrorActionPreference = ''Stop''; Remove-Item -LiteralPath ''{0}'' -Recurse -Force' -f $path - $output = & (Join-Path -Path $env:ProgramFiles -ChildPath 'PowerShell\7\pwsh.exe') -NoProfile -NonInteractive -EncodedCommand ([Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($command))) 2>&1 - if ($LASTEXITCODE -ne 0) { - throw "'$path' could not be removed: $output" + # PowerShell 7 removes the symbolic links of the tests without following them, which Windows PowerShell 5.1 doesn't do. A recursive + # removal can still fail with "The directory is not empty" while another process, such as a virus scanner, holds a handle to an item + # that was just deleted (seen on Windows Server 2019); a moment later nothing is left. So the removal is repeated before it fails. + # The command writes its errors to its output: a line on stderr would end this script at once (see the setup of the file server). + $command = '$errors = @(); Remove-Item -LiteralPath ''__PATH__'' -Recurse -Force -ErrorAction SilentlyContinue -ErrorVariable errors; $errors | ForEach-Object -Process { "$_" }'.Replace('__PATH__', $path) + $encoded = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($command)) + $attempt = 0 + while ((Test-Path -LiteralPath $path) -and $attempt -lt 6) { + $attempt++ + if ($attempt -gt 1) { + Start-Sleep -Seconds 5 } + + $output = & (Join-Path -Path $env:ProgramFiles -ChildPath 'PowerShell\7\pwsh.exe') -NoProfile -NonInteractive -EncodedCommand $encoded 2>&1 + } + + if (Test-Path -LiteralPath $path) { + throw "'$path' could not be removed in $attempt attempts: $output" + } + + if ($attempt -gt 1) { + "'$path' was removed in $attempt attempts." } } @@ -893,7 +941,18 @@ $removeClientScript = { } } - Get-CimInstance -ClassName Win32_UserProfile | Where-Object -FilterScript { $_.SID -in $Sid } | Remove-CimInstance + # A profile that is gone in the meantime needs no removal; one that is still there after the error does. + foreach ($userProfile in @(Get-CimInstance -ClassName Win32_UserProfile | Where-Object -FilterScript { $_.SID -in $Sid })) { + try { + Remove-CimInstance -InputObject $userProfile + } + catch { + if (Get-CimInstance -ClassName Win32_UserProfile -Filter ("SID = '{0}'" -f $userProfile.SID) -ErrorAction SilentlyContinue) { + throw + } + } + } + if (Test-Path -LiteralPath $PayloadPath) { Remove-Item -LiteralPath $PayloadPath -Recurse -Force } @@ -955,7 +1014,10 @@ if ($RemoveFixture) { } $accountSids = @(Invoke-LabCommand -ComputerName $DomainController -ActivityName 'Read the accounts' -ScriptBlock $accountSidScript -ArgumentList $organizationalUnitName @labCommand) - $null = Invoke-LabCommand -ComputerName $FileServer -ActivityName 'Remove the share and the folders' -ScriptBlock $removeFileServerScript -ArgumentList $shareName, $shareLocalPath, $payloadPath, $localGroupName, $accountSids @labCommand + $removed = Invoke-LabCommand -ComputerName $FileServer -ActivityName 'Remove the share and the folders' -ScriptBlock $removeFileServerScript -ArgumentList $shareName, $shareLocalPath, $payloadPath, $localGroupName, $accountSids @labCommand + foreach ($message in @($removed)) { + Write-LabProgress "${FileServer}: $message" + } $null = Invoke-LabCommand -ComputerName $Client -ActivityName 'Remove the members and the folder' -ScriptBlock $removeClientScript -ArgumentList $payloadPath, $accountSids @labCommand $null = Invoke-LabCommand -ComputerName $DomainController -ActivityName 'Remove the accounts' -ScriptBlock $removeAccountScript -ArgumentList $organizationalUnitName @labCommand foreach ($computer in $ForeignDomainController) {