You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

511 lines
25 KiB

<#
Tests how the cmdlets of the module built in NTFSSecurity\bin\Release behave when a later command in the pipeline
ends it: a break or continue in a script block, Select-Object -First, or a terminating error such as a throw. The
exception that carries it passes through the cmdlet while it writes an object, a verbose message, or a debug message.
A catch for the failures of an item must not report it as an error of that item and go on with the next one: a
cmdlet that removes, copies, moves, or changes items would change them all, although the caller ended the pipeline,
and the caller would never see the exception. Every test works on files and folders in a sandbox.
#>
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
'PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Pester shares variables between blocks.'
)]
param ()
BeforeDiscovery {
Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force
$canReadAudit = Test-PrivilegeHeld -Name 'SeSecurityPrivilege'
$names = @(
'Get-ChildItem2', 'Get-DiskSpace', 'Get-FileHash2', 'Get-Item2', 'Get-NTFSAccess', 'Get-NTFSEffectiveAccess',
'Get-NTFSHardLink', 'Get-NTFSInheritance', 'Get-NTFSOrphanedAccess', 'Get-NTFSOwner', 'Get-NTFSSecurityDescriptor',
'Get-NTFSSimpleAccess', 'Get-Privileges', 'Test-Path2', 'Add-NTFSAccess', 'Remove-NTFSAccess',
'Disable-NTFSAccessInheritance', 'Enable-NTFSAccessInheritance', 'Set-NTFSInheritance', 'Set-NTFSOwner',
'Set-NTFSSecurityDescriptor', 'Copy-Item2', 'Move-Item2', 'Remove-Item2'
)
$auditNames = @(
'Get-NTFSAudit', 'Get-NTFSOrphanedAudit', 'Add-NTFSAudit', 'Remove-NTFSAudit', 'Disable-NTFSAuditInheritance',
'Enable-NTFSAuditInheritance'
)
$loopCases = foreach ($name in $names) {
foreach ($keyword in 'break', 'continue') {
@{ Name = $name; Keyword = $keyword }
}
}
$stopCases = foreach ($name in $names) {
@{ Name = $name }
}
$auditLoopCases = foreach ($name in $auditNames) {
foreach ($keyword in 'break', 'continue') {
@{ Name = $name; Keyword = $keyword }
}
}
$auditStopCases = foreach ($name in $auditNames) {
@{ Name = $name }
}
$failureCases = foreach ($name in $names) {
foreach ($style in 'throw', 'throw UnauthorizedAccessException', 'Write-Error -ErrorAction Stop') {
@{ Name = $name; Style = $style }
}
}
$auditFailureCases = foreach ($name in $auditNames) {
foreach ($style in 'throw', 'throw UnauthorizedAccessException', 'Write-Error -ErrorAction Stop') {
@{ Name = $name; Style = $style }
}
}
$streamCases = foreach ($case in @(
@{ Name = 'Get-FileHash2'; Stream = 'verbose' }
@{ Name = 'Set-NTFSSecurityDescriptor'; Stream = 'verbose' }
@{ Name = 'Set-NTFSOwner'; Stream = 'debug' }
)) {
foreach ($style in 'Select-Object -First 1', 'throw') {
@{ Name = $case.Name; Stream = $case.Stream; Style = $style }
}
}
}
BeforeAll {
Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force
$modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\NTFSSecurity\bin\Release\NTFSSecurity.psd1'
Import-Module -Name $modulePath -Force -ErrorAction Stop
$sandbox = New-TestSandbox -Name 'PipelineControl'
Push-Location -LiteralPath $sandbox
$sidType = [System.Security.Principal.SecurityIdentifier]
$currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
$orphan = 'S-1-5-21-1-2-3-1001'
$privateData = (Get-Module -Name NTFSSecurity).PrivateData
function New-Pair {
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
'PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Test helper that only writes to the sandbox.'
)]
param ([switch] $Directory)
@{
First = New-TestSandboxItem -Sandbox $sandbox -Name 'First' -Directory:$Directory
Second = New-TestSandboxItem -Sandbox $sandbox -Name 'Second' -Directory:$Directory
}
}
function Test-ExplicitEntry {
param ([string] $Path, [string] $Account)
@((Get-Acl -LiteralPath $Path).GetAccessRules($true, $false, $sidType) |
Where-Object -FilterScript { $_.IdentityReference.Value -eq $Account }).Count -gt 0
}
# Each case runs one command over the two items of its context. Untouched tells whether the second item is as it
# was, which it is only when the command stopped after the first one.
$cases = @{
'Get-ChildItem2' = @{
# The first file is two levels below the folder, so the exception passes the frames of the recursion.
Prepare = {
$top = New-TestSandboxItem -Sandbox $sandbox -Name 'Tree' -Directory
foreach ($relative in 'A\B\Two.txt', 'C\Three.txt') {
$file = Join-Path -Path $top -ChildPath $relative
Assert-TestSandboxPath -Sandbox $sandbox -Path $file
New-Item -ItemType Directory -Path (Split-Path -Path $file -Parent) -Force | Out-Null
Set-Content -LiteralPath $file -Value 'Tree'
}
@{ Top = $top }
}
Run = { param ($Context) Get-ChildItem2 -Path $Context.Top -Recurse -File -ErrorAction SilentlyContinue }
}
'Get-DiskSpace' = @{
Prepare = { @{} }
Run = { Get-DiskSpace -ErrorAction SilentlyContinue }
}
'Get-FileHash2' = @{
Prepare = { New-Pair }
Run = { param ($Context) Get-FileHash2 -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue }
}
'Get-Item2' = @{
Prepare = { New-Pair }
Run = { param ($Context) Get-Item2 -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue }
}
'Get-NTFSAccess' = @{
Prepare = { New-Pair }
Run = { param ($Context) Get-NTFSAccess -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue }
}
'Get-NTFSEffectiveAccess' = @{
Prepare = { New-Pair }
Run = { param ($Context) Get-NTFSEffectiveAccess -Path $Context.First, $Context.Second -WarningAction SilentlyContinue -ErrorAction SilentlyContinue }
}
'Get-NTFSHardLink' = @{
Prepare = { New-Pair }
Run = { param ($Context) Get-NTFSHardLink -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue }
}
'Get-NTFSInheritance' = @{
Prepare = { New-Pair }
Run = { param ($Context) Get-NTFSInheritance -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue }
}
'Get-NTFSOrphanedAccess' = @{
Prepare = {
$context = New-Pair
Add-NTFSAccess -Path $context.First, $context.Second -Account $orphan -AccessRights ReadData -ErrorAction Stop
$context
}
Run = { param ($Context) Get-NTFSOrphanedAccess -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue }
}
'Get-NTFSOwner' = @{
Prepare = { New-Pair }
Run = { param ($Context) Get-NTFSOwner -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue }
}
'Get-NTFSSecurityDescriptor' = @{
Prepare = { New-Pair }
Run = { param ($Context) Get-NTFSSecurityDescriptor -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue }
}
'Get-NTFSSimpleAccess' = @{
Prepare = { New-Pair -Directory }
Run = { param ($Context) Get-NTFSSimpleAccess -Path $Context.First, $Context.Second -IncludeRootFolder:$false -ErrorAction SilentlyContinue }
}
'Get-Privileges' = @{
Prepare = { @{} }
Run = { Get-Privileges -ErrorAction SilentlyContinue }
}
'Test-Path2' = @{
Prepare = { New-Pair }
Run = { param ($Context) Test-Path2 -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue }
}
'Add-NTFSAccess' = @{
Prepare = { New-Pair }
Run = { param ($Context) Add-NTFSAccess -Path $Context.First, $Context.Second -Account 'S-1-1-0' -AccessRights ReadData -PassThru -ErrorAction SilentlyContinue }
Untouched = { param ($Context) -not (Test-ExplicitEntry -Path $Context.Second -Account 'S-1-1-0') }
}
'Remove-NTFSAccess' = @{
Prepare = {
$context = New-Pair
Add-NTFSAccess -Path $context.First, $context.Second -Account 'S-1-1-0' -AccessRights ReadData -ErrorAction Stop
$context
}
Run = { param ($Context) Remove-NTFSAccess -Path $Context.First, $Context.Second -Account 'S-1-1-0' -AccessRights ReadData -PassThru -ErrorAction SilentlyContinue }
Untouched = { param ($Context) Test-ExplicitEntry -Path $Context.Second -Account 'S-1-1-0' }
}
'Disable-NTFSAccessInheritance' = @{
Prepare = { New-Pair }
Run = { param ($Context) Disable-NTFSAccessInheritance -Path $Context.First, $Context.Second -PassThru -ErrorAction SilentlyContinue }
Untouched = { param ($Context) -not (Get-Acl -LiteralPath $Context.Second).AreAccessRulesProtected }
}
'Enable-NTFSAccessInheritance' = @{
Prepare = {
$context = New-Pair
Disable-NTFSAccessInheritance -Path $context.First, $context.Second -ErrorAction Stop
$context
}
Run = { param ($Context) Enable-NTFSAccessInheritance -Path $Context.First, $Context.Second -PassThru -ErrorAction SilentlyContinue }
Untouched = { param ($Context) (Get-Acl -LiteralPath $Context.Second).AreAccessRulesProtected }
}
'Set-NTFSInheritance' = @{
Prepare = { New-Pair }
Run = { param ($Context) Set-NTFSInheritance -Path $Context.First, $Context.Second -AccessInheritanceEnabled $false -PassThru -ErrorAction SilentlyContinue }
Untouched = { param ($Context) -not (Get-Acl -LiteralPath $Context.Second).AreAccessRulesProtected }
}
'Set-NTFSOwner' = @{
Prepare = { New-Pair }
Run = { param ($Context) Set-NTFSOwner -Path $Context.First, $Context.Second -Account $currentUser -PassThru -ErrorAction SilentlyContinue }
}
'Set-NTFSSecurityDescriptor' = @{
Prepare = {
$context = New-Pair
$context.Descriptors = @(Get-NTFSSecurityDescriptor -Path $context.First, $context.Second -ErrorAction Stop)
Add-NTFSAccess -SecurityDescriptor $context.Descriptors -Account 'S-1-1-0' -AccessRights ReadData -ErrorAction Stop
$context
}
Run = { param ($Context) Set-NTFSSecurityDescriptor -SecurityDescriptor $Context.Descriptors -PassThru -ErrorAction SilentlyContinue }
Untouched = { param ($Context) -not (Test-ExplicitEntry -Path $Context.Second -Account 'S-1-1-0') }
}
'Copy-Item2' = @{
Prepare = {
$context = New-Pair
$context.Destination = New-TestSandboxItem -Sandbox $sandbox -Name 'CopyTo' -Directory
$context
}
Run = { param ($Context) Copy-Item2 -Path $Context.First, $Context.Second -Destination $Context.Destination -PassThru $true -ErrorAction SilentlyContinue }
Untouched = { param ($Context) -not (Test-Path -LiteralPath (Join-Path -Path $Context.Destination -ChildPath (Split-Path -Path $Context.Second -Leaf))) }
}
'Move-Item2' = @{
Prepare = {
$context = New-Pair
$context.Destination = New-TestSandboxItem -Sandbox $sandbox -Name 'MoveTo' -Directory
$context
}
Run = { param ($Context) Move-Item2 -Path $Context.First, $Context.Second -Destination $Context.Destination -PassThru $true -ErrorAction SilentlyContinue }
Untouched = { param ($Context) Test-Path -LiteralPath $Context.Second }
}
'Remove-Item2' = @{
Prepare = { New-Pair }
Run = { param ($Context) Remove-Item2 -Path $Context.First, $Context.Second -PassThru -ErrorAction SilentlyContinue }
Untouched = { param ($Context) Test-Path -LiteralPath $Context.Second }
}
'Get-NTFSAudit' = @{
Prepare = {
$context = New-Pair
Add-NTFSAudit -Path $context.First, $context.Second -Account 'S-1-1-0' -AccessRights Delete -AuditFlags Success -ErrorAction Stop
$context
}
Run = { param ($Context) Get-NTFSAudit -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue }
}
'Get-NTFSOrphanedAudit' = @{
Prepare = {
$context = New-Pair
Add-NTFSAudit -Path $context.First, $context.Second -Account $orphan -AccessRights Delete -AuditFlags Success -ErrorAction Stop
$context
}
Run = { param ($Context) Get-NTFSOrphanedAudit -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue }
}
'Add-NTFSAudit' = @{
Prepare = { New-Pair }
Run = { param ($Context) Add-NTFSAudit -Path $Context.First, $Context.Second -Account 'S-1-1-0' -AccessRights Delete -AuditFlags Success -PassThru -ErrorAction SilentlyContinue }
Untouched = { param ($Context) @(Get-NTFSAudit -Path $Context.Second -ErrorAction Stop).Count -eq 0 }
}
'Remove-NTFSAudit' = @{
# The entry of the orphan goes; the one of Everyone stays, so that there is an object to write.
Prepare = {
$context = New-Pair
foreach ($account in $orphan, 'S-1-1-0') {
Add-NTFSAudit -Path $context.First, $context.Second -Account $account -AccessRights Delete -AuditFlags Success -ErrorAction Stop
}
$context
}
Run = { param ($Context) Remove-NTFSAudit -Path $Context.First, $Context.Second -Account $orphan -AccessRights Delete -AuditFlags Success -PassThru -ErrorAction SilentlyContinue }
Untouched = { param ($Context) @(Get-NTFSAudit -Path $Context.Second -Account $orphan -ErrorAction Stop).Count -gt 0 }
}
'Disable-NTFSAuditInheritance' = @{
Prepare = { New-Pair }
Run = { param ($Context) Disable-NTFSAuditInheritance -Path $Context.First, $Context.Second -PassThru -ErrorAction SilentlyContinue }
Untouched = { param ($Context) (Get-NTFSInheritance -Path $Context.Second -ErrorAction Stop).AuditInheritanceEnabled }
}
'Enable-NTFSAuditInheritance' = @{
Prepare = {
$context = New-Pair
Disable-NTFSAuditInheritance -Path $context.First, $context.Second -ErrorAction Stop
$context
}
Run = { param ($Context) Enable-NTFSAuditInheritance -Path $Context.First, $Context.Second -PassThru -ErrorAction SilentlyContinue }
Untouched = { param ($Context) -not (Get-NTFSInheritance -Path $Context.Second -ErrorAction Stop).AuditInheritanceEnabled }
}
}
# The commands that write a verbose or a debug message inside the try of their loop, which the later command takes.
# The first record that reaches Select-Object ends the pipeline there. The preference of the debug stream is set by
# Assert-StreamStop: the Debug switch would ask before every message.
$streamRuns = @{
'Get-FileHash2/verbose' = @{
# The first path is a folder, which the cmdlet skips with a verbose message.
Prepare = {
$context = New-Pair -Directory
$context.File = New-TestSandboxItem -Sandbox $sandbox -Name 'Hashed'
$context
}
Run = { param ($Context) Get-FileHash2 -Path $Context.First, $Context.File -Verbose 4>&1 }
}
'Set-NTFSSecurityDescriptor/verbose' = @{
Prepare = $cases['Set-NTFSSecurityDescriptor'].Prepare
Run = { param ($Context) Set-NTFSSecurityDescriptor -SecurityDescriptor $Context.Descriptors -Verbose 4>&1 }
Untouched = $cases['Set-NTFSSecurityDescriptor'].Untouched
}
'Set-NTFSOwner/debug' = @{
Prepare = { New-Pair }
Run = { param ($Context) Set-NTFSOwner -Path $Context.First, $Context.Second -Account $currentUser 5>&1 }
}
}
# The command writes its first object, and the break or continue of the later command ends the loop around the
# pipeline before the next statement of the loop runs.
function Assert-LoopControl {
param ([string] $Name, [string] $Keyword)
$case = $cases[$Name]
$context = & $case.Prepare
$emitted = 0
$reachedEnd = $false
$Error.Clear()
foreach ($round in 1) {
& $case.Run $context | ForEach-Object -Process {
$emitted++
if ($Keyword -eq 'break') { break } else { continue }
}
$reachedEnd = $true
}
$emitted | Should -Be 1
$reachedEnd | Should -BeFalse
$Error.Count | Should -Be 0
if ($case.Untouched) {
(& $case.Untouched $context) | Should -BeTrue
}
}
function Assert-PipelineStop {
param ([string] $Name)
$case = $cases[$Name]
$context = & $case.Prepare
$Error.Clear()
$result = @(& $case.Run $context | Select-Object -First 1)
$result | Should -HaveCount 1
$Error.Count | Should -Be 0
if ($case.Untouched) {
(& $case.Untouched $context) | Should -BeTrue
}
}
# A later command that fails with a terminating error ends the pipeline for the commands before it. The error is the
# caller's: the cmdlet must neither report it as an error of an item nor go on with the next item. The second style
# raises the type that some catch of the cmdlets handles on its own, for a folder that cannot be read.
function Assert-DownstreamFailure {
param ([string] $Name, [string] $Style)
$case = $cases[$Name]
$context = & $case.Prepare
$emitted = 0
$caught = $null
$Error.Clear()
try {
& $case.Run $context | ForEach-Object -Process {
$emitted++
switch ($Style) {
'throw' { throw 'Downstream failure' }
'throw UnauthorizedAccessException' { throw [System.UnauthorizedAccessException]::new('Downstream failure') }
default { Write-Error -Message 'Downstream failure' -ErrorAction Stop }
}
}
}
catch {
$caught = $_
}
$caught.Exception.Message | Should -BeLike '*Downstream failure*'
$emitted | Should -Be 1
@($Error | Where-Object -FilterScript { $_.Exception.Message -notlike '*Downstream failure*' }) | Should -BeNullOrEmpty
if ($case.Untouched) {
(& $case.Untouched $context) | Should -BeTrue
}
}
# The first verbose or debug record reaches the later command, which ends the pipeline inside the try of the loop:
# Select-Object raises the end of the pipeline, a throw raises an exception of its own. With the privileges enabled,
# the cmdlet writes a message before that, outside the try, so they stay off here.
function Assert-StreamStop {
param ([string] $Name, [string] $Stream, [string] $Style)
$case = $streamRuns["$Name/$Stream"]
$recordType = if ($Stream -eq 'debug') { [System.Management.Automation.DebugRecord] } else { [System.Management.Automation.VerboseRecord] }
$context = & $case.Prepare
$saved = $privateData['EnablePrivileges']
$savedDebugPreference = $DebugPreference
$privateData['EnablePrivileges'] = $false
$DebugPreference = if ($Stream -eq 'debug') { 'Continue' } else { $savedDebugPreference }
$emitted = 0
$caught = $null
$result = @()
$Error.Clear()
try {
if ($Style -eq 'throw') {
try {
& $case.Run $context | ForEach-Object -Process {
$emitted++
throw 'Downstream failure'
}
}
catch {
$caught = $_
}
}
else {
$result = @(& $case.Run $context | Select-Object -First 1)
}
}
finally {
$privateData['EnablePrivileges'] = $saved
$DebugPreference = $savedDebugPreference
}
if ($Style -eq 'throw') {
$caught.Exception.Message | Should -BeLike '*Downstream failure*'
$emitted | Should -Be 1
@($Error | Where-Object -FilterScript { $_.Exception.Message -notlike '*Downstream failure*' }) | Should -BeNullOrEmpty
}
else {
$result | Should -HaveCount 1
$result[0] | Should -BeOfType $recordType
$Error.Count | Should -Be 0
}
if ($case.Untouched) {
(& $case.Untouched $context) | Should -BeTrue
}
}
}
AfterAll {
Pop-Location
Remove-TestSandbox -Sandbox $sandbox
Remove-Module -Name NTFSSecurity -Force -ErrorAction SilentlyContinue
}
Describe 'A later command that ends the pipeline' {
It '<Name> should leave the loop for <Keyword> after its first object and change nothing else' -ForEach $loopCases {
Assert-LoopControl -Name $Name -Keyword $Keyword
}
It '<Name> should stop after the first object for Select-Object -First 1 and change nothing else' -ForEach $stopCases {
Assert-PipelineStop -Name $Name
}
It '<Name> should leave the loop for <Keyword> after its first object and change nothing else' -Skip:(-not $canReadAudit) -ForEach $auditLoopCases {
Assert-LoopControl -Name $Name -Keyword $Keyword
}
It '<Name> should stop after the first object for Select-Object -First 1 and change nothing else' -Skip:(-not $canReadAudit) -ForEach $auditStopCases {
Assert-PipelineStop -Name $Name
}
It '<Name> should stop for a terminating error (<Style>) of the later command and change nothing else' -ForEach $failureCases {
Assert-DownstreamFailure -Name $Name -Style $Style
}
It '<Name> should stop for a terminating error (<Style>) of the later command and change nothing else' -Skip:(-not $canReadAudit) -ForEach $auditFailureCases {
Assert-DownstreamFailure -Name $Name -Style $Style
}
It '<Name> should stop at the <Stream> message for <Style> of the later command and change nothing else' -ForEach $streamCases {
Assert-StreamStop -Name $Name -Stream $Stream -Style $Style
}
}
# A cmdlet also meets the end of the pipeline where it did not write: another call of PowerShell can raise it too. The
# check that every catch makes recognizes the exceptions by their types. PowerShell keeps the exceptions of break and
# continue internal, so they are recognized by the name of their base type, which a stand-in with that name shows.
Describe 'Recognizing the end of a pipeline by the type of the exception' {
BeforeAll {
if (-not ('NtfsSecurityTests.StandInForBreak' -as [type])) {
# The compiler warns that the stand-in has the name of an imported type, and Add-Type treats a warning as an error.
Add-Type -IgnoreWarnings -TypeDefinition @'
namespace System.Management.Automation { public class FlowControlException : System.Exception { } }
namespace NtfsSecurityTests { public class StandInForBreak : System.Management.Automation.FlowControlException { } }
'@
}
$isEnd = [NTFSSecurity.BaseCmdlet].Assembly.GetType('NTFSSecurity.PipelineControl').GetMethod(
'IsEnd', [System.Reflection.BindingFlags] 'NonPublic, Static')
}
It 'Should recognize a PipelineStoppedException' {
$isEnd.Invoke($null, @([System.Management.Automation.PipelineStoppedException]::new())) | Should -BeTrue
}
It 'Should recognize an exception whose base type is the flow control exception of PowerShell' {
$isEnd.Invoke($null, @([NtfsSecurityTests.StandInForBreak]::new())) | Should -BeTrue
}
It 'Should not recognize <Description>' -ForEach @(
@{ Description = 'a failure of an item'; Exception = [System.InvalidOperationException]::new('Failure') }
@{ Description = 'an access denial'; Exception = [System.UnauthorizedAccessException]::new('Denied') }
@{ Description = 'a failure with an inner exception that ends the pipeline'; Exception = [System.InvalidOperationException]::new('Failure', [System.Management.Automation.PipelineStoppedException]::new()) }
) {
$isEnd.Invoke($null, @($Exception)) | Should -BeFalse
}
}