<# Tests the public object APIs used with cmdlet output, on files and folders in a sandbox folder. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Pester shares variables between blocks.' )] param () BeforeDiscovery { Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force $holdsSecurityPrivilege = Test-PrivilegeHeld -Name 'SeSecurityPrivilege' } 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 'ObjectApis' $objectPath = Join-Path -Path $sandbox -ChildPath 'Rule.txt' $identity = [Security2.IdentityReference2] 'S-1-1-0' $sid = New-Object -TypeName 'System.Security.Principal.SecurityIdentifier' -ArgumentList 'S-1-1-0' } AfterAll { Remove-TestSandbox -Sandbox $sandbox Remove-Module -Name NTFSSecurity -Force -ErrorAction SilentlyContinue } Describe 'Rule constructors with a path' { It 'Should preserve the supplied path and name of an rule' -ForEach @( @{ Kind = 'access' } @{ Kind = 'audit' } ) { if ($Kind -eq 'access') { $raw = New-Object -TypeName 'System.Security.AccessControl.FileSystemAccessRule' -ArgumentList ( $sid, [System.Security.AccessControl.FileSystemRights]::ReadData, [System.Security.AccessControl.AccessControlType]::Allow ) $rule = New-Object -TypeName 'Security2.FileSystemAccessRule2' -ArgumentList $raw, $objectPath } else { $raw = New-Object -TypeName 'System.Security.AccessControl.FileSystemAuditRule' -ArgumentList ( $sid, [System.Security.AccessControl.FileSystemRights]::ReadData, [System.Security.AccessControl.AuditFlags]::Success ) $rule = New-Object -TypeName 'Security2.FileSystemAuditRule2' -ArgumentList $raw, $objectPath } $rule.FullName | Should -BeExactly $objectPath $rule.Name | Should -BeExactly 'Rule.txt' $rule.InheritanceEnabled = $true $rule.InheritedFrom = $sandbox $rule.InheritanceEnabled | Should -BeTrue $rule.InheritedFrom | Should -BeExactly $sandbox $rule.GetHashCode() | Should -Be $raw.GetHashCode() } } Describe 'Simplified audit entries' { It 'Should reduce to ' -ForEach @( @{ Rights = 'None'; Expected = 'None' } @{ Rights = 'ReadData'; Expected = 'Read' } @{ Rights = 'Read'; Expected = 'Read' } @{ Rights = 'CreateFiles'; Expected = 'Write' } @{ Rights = 'AppendData'; Expected = 'Write' } @{ Rights = 'ReadExtendedAttributes'; Expected = 'Read' } @{ Rights = 'WriteExtendedAttributes'; Expected = 'Write' } @{ Rights = 'ExecuteFile'; Expected = 'Read' } @{ Rights = 'DeleteSubdirectoriesAndFiles'; Expected = 'Delete' } @{ Rights = 'ReadAttributes'; Expected = 'Read' } @{ Rights = 'WriteAttributes'; Expected = 'Write' } @{ Rights = 'Delete'; Expected = 'Delete' } @{ Rights = 'ReadPermissions'; Expected = 'Read' } @{ Rights = 'ChangePermissions'; Expected = 'Write' } @{ Rights = 'TakeOwnership'; Expected = 'Write' } @{ Rights = 'Synchronize'; Expected = 'Read' } @{ Rights = 'FullControl'; Expected = 'Read, Write, Delete' } @{ Rights = 'GenericRead'; Expected = 'Read' } @{ Rights = 'GenericWrite'; Expected = 'Write' } @{ Rights = 'GenericExecute'; Expected = 'Read' } @{ Rights = 'GenericAll'; Expected = 'Read, Write, Delete' } ) { $rule = New-Object -TypeName 'Security2.SimpleFileSystemAuditRule' -ArgumentList ( $objectPath, $identity, [Security2.FileSystemRights2] $Rights ) $rule.AccessRights | Should -Be ([Security2.SimpleFileSystemAccessRights] $Expected) $rule.FullName | Should -BeExactly $objectPath $rule.Name | Should -BeExactly 'Rule.txt' $rule.Identity.Sid | Should -BeExactly 'S-1-1-0' } It 'Should compare audit entries reflexively and symmetrically, never as access entries' { $first = New-Object -TypeName 'Security2.SimpleFileSystemAuditRule' -ArgumentList ( $objectPath, $identity, [Security2.FileSystemRights2]::Read ) $second = New-Object -TypeName 'Security2.SimpleFileSystemAuditRule' -ArgumentList ( $objectPath, $identity, [Security2.FileSystemRights2]::Read ) $access = New-Object -TypeName 'Security2.SimpleFileSystemAccessRule' -ArgumentList ( $objectPath, $identity, [Security2.FileSystemRights2]::Read, [System.Security.AccessControl.AccessControlType]::Allow ) $first.Equals($first) | Should -BeTrue $first.Equals($second) | Should -BeTrue $second.Equals($first) | Should -BeTrue $first.GetHashCode() | Should -Be $second.GetHashCode() $first.Equals($access) | Should -BeFalse $access.Equals($first) | Should -BeFalse $first.Equals($null) | Should -BeFalse $first.Equals('Read') | Should -BeFalse $second.AccessControlType = 'Deny' $first.Equals($second) | Should -BeFalse } It 'Should preserve the path, account and ReadData when converting an audit entry' { $raw = New-Object -TypeName 'System.Security.AccessControl.FileSystemAuditRule' -ArgumentList ( $sid, [System.Security.AccessControl.FileSystemRights]::ReadData, [System.Security.AccessControl.AuditFlags]::Success ) $wrapped = New-Object -TypeName 'Security2.FileSystemAuditRule2' -ArgumentList $raw, $objectPath $simple = $wrapped.ToSimpleFileSystemAuditRule2() $simple.FullName | Should -BeExactly $objectPath $simple.Identity.Sid | Should -BeExactly 'S-1-1-0' $simple.AccessRights | Should -Be ([Security2.SimpleFileSystemAccessRights]::Read) $wrapped.ToString() | Should -BeExactly $raw.ToString() } } Describe 'Identity comparisons and conversions' { It 'Should compare with the identity by SID or resolved name' -ForEach @( @{ Value = 'self'; Expected = $true } @{ Value = 'same SID'; Expected = $true } @{ Value = 'different SID'; Expected = $false } @{ Value = 'SecurityIdentifier'; Expected = $true } @{ Value = 'NTAccount'; Expected = $true } @{ Value = 'SID string'; Expected = $true } @{ Value = 'account name'; Expected = $true } @{ Value = 'different string'; Expected = $false } @{ Value = 'null'; Expected = $false } @{ Value = 'other type'; Expected = $false } ) { $other = switch ($Value) { 'self' { $identity } 'same SID' { [Security2.IdentityReference2] 'S-1-1-0' } 'different SID' { [Security2.IdentityReference2] 'S-1-5-32-546' } 'SecurityIdentifier' { $sid } 'NTAccount' { $sid.Translate([System.Security.Principal.NTAccount]) } 'SID string' { 'S-1-1-0' } 'account name' { $identity.AccountName.ToUpperInvariant() } 'different string' { 'NTFSSecurity-not-an-account' } 'null' { $null } 'other type' { 42 } } $identity.Equals($other) | Should -Be $Expected } It 'Should compare null operands and distinct instances through both operators' { $same = [Security2.IdentityReference2] 'S-1-1-0' $different = [Security2.IdentityReference2] 'S-1-5-32-546' [Security2.IdentityReference2]::op_Equality($null, $null) | Should -BeTrue [Security2.IdentityReference2]::op_Equality($identity, $null) | Should -BeFalse [Security2.IdentityReference2]::op_Equality($null, $identity) | Should -BeFalse [Security2.IdentityReference2]::op_Equality($identity, $same) | Should -BeTrue [Security2.IdentityReference2]::op_Inequality($identity, $identity) | Should -BeFalse [Security2.IdentityReference2]::op_Inequality($identity, $null) | Should -BeTrue [Security2.IdentityReference2]::op_Inequality($null, $identity) | Should -BeTrue [Security2.IdentityReference2]::op_Inequality($identity, $different) | Should -BeTrue [Security2.IdentityReference2]::op_Inequality($identity, $same) | Should -BeFalse $identity.GetHashCode() | Should -Be $same.GetHashCode() } It 'Should round-trip native identities and the binary SID without changing the account' { $account = $sid.Translate([System.Security.Principal.NTAccount]) $fromSid = [Security2.IdentityReference2]::op_Explicit($sid) $fromAccount = [Security2.IdentityReference2]::op_Explicit($account) $fromSid.Sid | Should -BeExactly 'S-1-1-0' $fromAccount.Sid | Should -BeExactly $fromSid.Sid ([System.Security.Principal.SecurityIdentifier] $fromSid).Value | Should -BeExactly 'S-1-1-0' ([System.Security.Principal.NTAccount] $fromAccount).Value | Should -BeExactly $account.Value $binarySid = New-Object -TypeName 'System.Security.Principal.SecurityIdentifier' -ArgumentList ( $fromSid.GetBinaryForm(), 0 ) $binarySid.Value | Should -BeExactly 'S-1-1-0' } } Describe 'Unrepresentable inheritance flags' { It 'Should reject propagation flags without inheritance instead of inventing an AppliesTo value' { $failure = $null try { $null = [Security2.FileSystemSecurity2]::ConvertToApplyTo('None', 'InheritOnly') } catch { $failure = $_.Exception.GetBaseException() } $failure | Should -BeOfType [Security2.RightsConverionException] $failure.Message | Should -BeExactly 'The combination of InheritanceFlags and PropagationFlags could not be translated' } } Describe 'Privilege output comparisons and formatting' { It 'Should compare boxed and typed privilege values consistently without accepting an attributes enum' { $values = @(Get-Privileges) $values.Count | Should -BeGreaterThan 0 $first = $values[0].PSObject.BaseObject $copy = $values[0].PSObject.BaseObject # PowerShell prefers the typed overload; reflection selects the public boxed-object contract explicitly. $equalsObject = [ProcessPrivileges.PrivilegeAndAttributes].GetMethod('Equals', [type[]] @([object])) $equalsObject.Invoke($first, [object[]] @($copy)) | Should -BeTrue $first.Equals($copy) | Should -BeTrue [ProcessPrivileges.PrivilegeAndAttributes]::op_Equality($first, $copy) | Should -BeTrue [ProcessPrivileges.PrivilegeAndAttributes]::op_Inequality($first, $copy) | Should -BeFalse $first.GetHashCode() | Should -Be $copy.GetHashCode() $equalsObject.Invoke($first, [object[]] @($null)) | Should -BeFalse $equalsObject.Invoke($first, [object[]] @('Backup')) | Should -BeFalse $equalsObject.Invoke($first, [object[]] @([ProcessPrivileges.PrivilegeAttributes]::Disabled)) | Should -BeFalse } It 'Should format the collection with one aligned privilege and attributes row per value' { $control = New-Object -TypeName 'Security2.PrivilegeControl' $values = $control.GetPrivileges() $expectedWidth = ($values | ForEach-Object { $_.Privilege.ToString().Length } | Measure-Object -Maximum).Maximum $text = $values.ToString() $rows = @($text.TrimEnd("`r", "`n") -split '\r?\n') $rows | Should -HaveCount $values.Count for ($index = 0; $index -lt $values.Count; $index++) { $value = $values[$index] $rows[$index] | Should -BeExactly ('{0} => {1}' -f $value.Privilege.ToString().PadRight($expectedWidth), $value.PrivilegeAttributes) } } } Describe 'Legacy effective-permission output objects' { It 'Should retain a mask and report the supplied path and identity without a native access check' -ForEach @( @{ Mask = 0; ObjectName = 'Effective.txt' } @{ Mask = 1; ObjectName = 'Effective.txt' } @{ Mask = 3; ObjectName = $null } ) { $path = if ($ObjectName) { Join-Path -Path $sandbox -ChildPath $ObjectName } else { $null } $entry = New-Object -TypeName 'Security2.FileSystemEffectivePermissionEntry' -ArgumentList ( $identity, [uint32] $Mask, $path ) $entry.Account.Sid | Should -BeExactly 'S-1-1-0' $entry.AccessMask | Should -Be $Mask [int] $entry.AccessRights | Should -Be $Mask $entry.FullName | Should -BeExactly ([string] $path) $entry.Name | Should -BeExactly $ObjectName $entry.AccessAsString | Should -Not -BeNullOrEmpty if ($Mask -eq 0) { $entry.AccessAsString | Should -Be @('None') } else { $entry.AccessAsString | Should -Not -Contain 'None' } } } Describe 'Simplified entry comparison branches' { It 'Should distinguish identities, rights and types in entries and keep equal hashes consistent' -ForEach @( @{ Kind = 'access' } @{ Kind = 'audit' } ) { $typeName = if ($Kind -eq 'access') { 'Security2.SimpleFileSystemAccessRule' } else { 'Security2.SimpleFileSystemAuditRule' } $arguments = @($objectPath, $identity, [Security2.FileSystemRights2]::Read) if ($Kind -eq 'access') { $arguments += [System.Security.AccessControl.AccessControlType]::Allow } $first = New-Object -TypeName $typeName -ArgumentList $arguments $equal = New-Object -TypeName $typeName -ArgumentList $arguments $arguments[1] = [Security2.IdentityReference2] 'S-1-5-32-546' $differentIdentity = New-Object -TypeName $typeName -ArgumentList $arguments $arguments[1] = $identity $arguments[2] = [Security2.FileSystemRights2]::Delete $differentRights = New-Object -TypeName $typeName -ArgumentList $arguments $first.Equals($equal) | Should -BeTrue $first.GetHashCode() | Should -Be $equal.GetHashCode() $first.Equals($differentIdentity) | Should -BeFalse $first.Equals($differentRights) | Should -BeFalse $first.Equals($null) | Should -BeFalse $equal.AccessControlType = 'Deny' $first.Equals($equal) | Should -BeFalse $first.Name | Should -BeExactly 'Rule.txt' } It 'Should reduce the generic mask in access entries' -ForEach @( @{ Rights = 'GenericRead'; Expected = 'Read' } @{ Rights = 'GenericWrite'; Expected = 'Write' } @{ Rights = 'GenericExecute'; Expected = 'Read' } @{ Rights = 'GenericAll'; Expected = 'Read, Write, Delete' } ) { $entry = New-Object -TypeName 'Security2.SimpleFileSystemAccessRule' -ArgumentList ( $objectPath, $identity, [Security2.FileSystemRights2] $Rights, [System.Security.AccessControl.AccessControlType]::Allow ) $entry.AccessRights | Should -Be ([Security2.SimpleFileSystemAccessRights] $Expected) } It 'Should convert an identity implicitly to its resolved display name and reject an unresolved name' { $conversion = [Security2.IdentityReference2].GetMethods([Reflection.BindingFlags] 'Public, Static') | Where-Object { $_.Name -eq 'op_Implicit' -and $_.ReturnType -eq [string] } $conversion.Invoke($null, [object[]] @($identity.PSObject.BaseObject)) | Should -BeExactly $identity.ToString() $unresolved = [Security2.IdentityReference2] 'S-1-5-21-1-2-3-1001' $unresolved.Equals('Not-resolved') | Should -BeFalse $identity.Equals($identity.AccountName) | Should -BeTrue } It 'Should compare different privilege values as unequal' { $constructor = [ProcessPrivileges.PrivilegeAndAttributes].GetConstructor( [Reflection.BindingFlags] 'NonPublic, Instance', $null, [type[]] @([ProcessPrivileges.Privilege], [ProcessPrivileges.PrivilegeAttributes]), $null ) $first = $constructor.Invoke(@([ProcessPrivileges.Privilege]::Backup, [ProcessPrivileges.PrivilegeAttributes]::Disabled)) $different = $constructor.Invoke(@([ProcessPrivileges.Privilege]::Restore, [ProcessPrivileges.PrivilegeAttributes]::Disabled)) $first.Equals($different) | Should -BeFalse [ProcessPrivileges.PrivilegeAndAttributes]::op_Inequality($first, $different) | Should -BeTrue } } Describe 'Access rule helpers that take a path' { BeforeAll { $allow = [System.Security.AccessControl.AccessControlType]::Allow $noInheritance = [System.Security.AccessControl.InheritanceFlags]::None $noPropagation = [System.Security.AccessControl.PropagationFlags]::None $users = [Security2.IdentityReference2] 'S-1-5-32-545' function Get-ExplicitEntries { [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSUseSingularNouns', '', Justification = 'The helper returns the explicit entries of an item.' )] param ([string] $Path, [string] $Account = 'S-1-1-0') $acl = Get-Acl -LiteralPath $Path @($acl.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) | Where-Object -FilterScript { $_.IdentityReference.Value -eq $Account }) } function New-AccountList { [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Test helper that only creates a list.' )] param () $accounts = New-Object -TypeName 'System.Collections.Generic.List[Security2.IdentityReference2]' $accounts.Add($identity) $accounts.Add($users) , $accounts } } It 'Should add an allow entry with Synchronize to a by its path' -ForEach @( @{ Kind = 'file'; Directory = $false } @{ Kind = 'folder'; Directory = $true } ) { $path = New-TestSandboxItem -Sandbox $sandbox -Name 'AddByPath' -Directory:$Directory $rule = [Security2.FileSystemAccessRule2]::AddFileSystemAccessRule( $path, $identity, [Security2.FileSystemRights2]::ReadData, $allow, $noInheritance, $noPropagation ) $rule.Account.Sid | Should -BeExactly 'S-1-1-0' $entries = @(Get-ExplicitEntries -Path $path) $entries | Should -HaveCount 1 $entries[0].AccessControlType | Should -Be 'Allow' $entries[0].FileSystemRights | Should -Be ([System.Security.AccessControl.FileSystemRights] 'ReadData, Synchronize') } # The overload that takes a path returns an iterator, so the caller must enumerate the result to write the entries. # The overloads that take an item write them at once; this test doesn't pin the difference. It 'Should add the entries of several accounts to a by its path when the result is enumerated' -ForEach @( @{ Kind = 'file'; Directory = $false } @{ Kind = 'folder'; Directory = $true } ) { $path = New-TestSandboxItem -Sandbox $sandbox -Name 'AddListByPath' -Directory:$Directory $accounts = New-AccountList $pending = [Security2.FileSystemAccessRule2]::AddFileSystemAccessRule( $path, $accounts, [Security2.FileSystemRights2]::ReadData, $allow, $noInheritance, $noPropagation ) @($pending) | Should -HaveCount 2 @(Get-ExplicitEntries -Path $path) | Should -HaveCount 1 @(Get-ExplicitEntries -Path $path -Account 'S-1-5-32-545') | Should -HaveCount 1 } It 'Should add the deny entries of several accounts without Synchronize when the result is enumerated' { $path = New-TestSandboxItem -Sandbox $sandbox -Name 'DenyListByPath' $accounts = New-AccountList $deny = [System.Security.AccessControl.AccessControlType]::Deny @([Security2.FileSystemAccessRule2]::AddFileSystemAccessRule( $path, $accounts, [Security2.FileSystemRights2]::ReadData, $deny, $noInheritance, $noPropagation )) | Should -HaveCount 2 foreach ($account in 'S-1-1-0', 'S-1-5-32-545') { $entries = @(Get-ExplicitEntries -Path $path -Account $account) $entries | Should -HaveCount 1 $entries[0].AccessControlType | Should -Be 'Deny' $entries[0].FileSystemRights | Should -Be ([System.Security.AccessControl.FileSystemRights]::ReadData) } } It 'Should add and remove a deny entry by its path without adding Synchronize' { $path = New-TestSandboxItem -Sandbox $sandbox -Name 'DenyByPath' $deny = [System.Security.AccessControl.AccessControlType]::Deny [void] [Security2.FileSystemAccessRule2]::AddFileSystemAccessRule( $path, $identity, [Security2.FileSystemRights2]::ReadData, $deny, $noInheritance, $noPropagation ) $entries = @(Get-ExplicitEntries -Path $path) $entries | Should -HaveCount 1 $entries[0].AccessControlType | Should -Be 'Deny' $entries[0].FileSystemRights | Should -Be ([System.Security.AccessControl.FileSystemRights]::ReadData) [Security2.FileSystemAccessRule2]::RemoveFileSystemAccessRule( $path, $identity, [Security2.FileSystemRights2]::ReadData, $deny, $noInheritance, $noPropagation ) @(Get-ExplicitEntries -Path $path) | Should -BeNullOrEmpty } It 'Should return no entries for an empty DACL when the sources of inherited entries are requested' { $path = New-TestSandboxItem -Sandbox $sandbox -Name 'EmptyDacl' Clear-NTFSAccess -Path $path -DisableInheritance -ErrorAction Stop $rules = @([Security2.FileSystemAccessRule2]::GetFileSystemAccessRules($path, $true, $true, $true)) $rules | Should -BeNullOrEmpty } It 'Should add the entry of a rule that carries its path' { $path = New-TestSandboxItem -Sandbox $sandbox -Name 'AddRule' $raw = New-Object -TypeName 'System.Security.AccessControl.FileSystemAccessRule' -ArgumentList ( $sid, [System.Security.AccessControl.FileSystemRights]::ReadData, $allow ) $rule = New-Object -TypeName 'Security2.FileSystemAccessRule2' -ArgumentList $raw, $path [Security2.FileSystemAccessRule2]::AddFileSystemAccessRule($rule) @(Get-ExplicitEntries -Path $path) | Should -HaveCount 1 } # RemoveSpecific removes only an entry that matches exactly; without it, Windows removes the named rights from the # matching entry. It 'Should remove only an exactly matching entry of a with removeSpecific and the named rights without it' -ForEach @( @{ Kind = 'file'; Directory = $false } @{ Kind = 'folder'; Directory = $true } ) { $path = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveByPath' -Directory:$Directory [void] [Security2.FileSystemAccessRule2]::AddFileSystemAccessRule( $path, $identity, [Security2.FileSystemRights2] 'ReadData, WriteData', $allow, $noInheritance, $noPropagation ) [Security2.FileSystemAccessRule2]::RemoveFileSystemAccessRule( $path, $identity, [Security2.FileSystemRights2]::ReadData, $allow, $noInheritance, $noPropagation, $true ) @(Get-ExplicitEntries -Path $path)[0].FileSystemRights | Should -Be ([System.Security.AccessControl.FileSystemRights] 'ReadData, WriteData, Synchronize') [Security2.FileSystemAccessRule2]::RemoveFileSystemAccessRule( $path, $identity, [Security2.FileSystemRights2]::ReadData, $allow, $noInheritance, $noPropagation, $false ) @(Get-ExplicitEntries -Path $path)[0].FileSystemRights | Should -Be ([System.Security.AccessControl.FileSystemRights] 'WriteData, Synchronize') [Security2.FileSystemAccessRule2]::RemoveFileSystemAccessRule( $path, $identity, [Security2.FileSystemRights2]::WriteData, $allow, $noInheritance, $noPropagation, $true ) @(Get-ExplicitEntries -Path $path) | Should -BeNullOrEmpty } It 'Should remove the entries of several accounts of a by path' -ForEach @( @{ Kind = 'file'; Directory = $false } @{ Kind = 'folder'; Directory = $true } ) { $path = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveListByPath' -Directory:$Directory $accounts = New-AccountList @([Security2.FileSystemAccessRule2]::AddFileSystemAccessRule( $path, $accounts, [Security2.FileSystemRights2]::ReadData, $allow, $noInheritance, $noPropagation )) | Should -HaveCount 2 [Security2.FileSystemAccessRule2]::RemoveFileSystemAccessRule( $path, $accounts, [Security2.FileSystemRights2]::ReadData, $allow, $noInheritance, $noPropagation, $false ) @(Get-ExplicitEntries -Path $path) | Should -BeNullOrEmpty @(Get-ExplicitEntries -Path $path -Account 'S-1-5-32-545') | Should -BeNullOrEmpty } It 'Should remove the entry that a rule object describes from an item' { $path = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveRuleObject' [void] [Security2.FileSystemAccessRule2]::AddFileSystemAccessRule( $path, $identity, [Security2.FileSystemRights2]::ReadData, $allow, $noInheritance, $noPropagation ) $raw = New-Object -TypeName 'System.Security.AccessControl.FileSystemAccessRule' -ArgumentList ( $sid, [System.Security.AccessControl.FileSystemRights]::ReadData, $allow ) $item = New-Object -TypeName 'Alphaleonis.Win32.Filesystem.FileInfo' -ArgumentList $path [Security2.FileSystemAccessRule2]::RemoveFileSystemAccessRule($item, $raw, $false) @(Get-ExplicitEntries -Path $path) | Should -BeNullOrEmpty } It 'Should return the explicit entries of a folder, and its inherited ones when asked, by its path' { $path = New-TestSandboxItem -Sandbox $sandbox -Name 'GetByPath' -Directory [void] [Security2.FileSystemAccessRule2]::AddFileSystemAccessRule( $path, $identity, [Security2.FileSystemRights2]::ReadData, $allow, $noInheritance, $noPropagation ) $explicit = @([Security2.FileSystemAccessRule2]::GetFileSystemAccessRules($path, $true, $false, $false)) $all = @([Security2.FileSystemAccessRule2]::GetFileSystemAccessRules($path, $true, $true, $true)) $explicit | Should -HaveCount 1 $explicit[0].Account.Sid | Should -BeExactly 'S-1-1-0' $all.Count | Should -BeGreaterThan 1 @($all | Where-Object -FilterScript { $_.Account.Sid -eq 'S-1-1-0' }) | Should -HaveCount 1 } } Describe 'Audit rule helpers that take a path' -Skip:(-not $holdsSecurityPrivilege) { BeforeAll { $success = [System.Security.AccessControl.AuditFlags]::Success $noInheritance = [System.Security.AccessControl.InheritanceFlags]::None $noPropagation = [System.Security.AccessControl.PropagationFlags]::None $users = [Security2.IdentityReference2] 'S-1-5-32-545' function Get-AuditEntries { [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSUseSingularNouns', '', Justification = 'The helper returns the audit entries of an item.' )] param ([string] $Path, [string] $Account = 'S-1-1-0') $descriptor = Get-NTFSSecurityDescriptor -Path $Path @($descriptor.SecurityDescriptor.GetAuditRules($true, $false, [System.Security.Principal.SecurityIdentifier]) | Where-Object -FilterScript { $_.IdentityReference.Value -eq $Account }) } } It 'Should add an audit entry to a by its path' -ForEach @( @{ Kind = 'file'; Directory = $false } @{ Kind = 'folder'; Directory = $true } ) { $path = New-TestSandboxItem -Sandbox $sandbox -Name 'AuditByPath' -Directory:$Directory $rule = [Security2.FileSystemAuditRule2]::AddFileSystemAuditRule( $path, $identity, [Security2.FileSystemRights2]::Delete, $success, $noInheritance, $noPropagation ) $rule.Account.Sid | Should -BeExactly 'S-1-1-0' $entries = @(Get-AuditEntries -Path $path) $entries | Should -HaveCount 1 $entries[0].AuditFlags | Should -Be 'Success' $entries[0].FileSystemRights | Should -Be ([System.Security.AccessControl.FileSystemRights]::Delete) $found = @([Security2.FileSystemAuditRule2]::GetFileSystemAuditRules($path, $true, $false)) $found | Should -HaveCount 1 $found[0].Account.Sid | Should -BeExactly 'S-1-1-0' $found[0].FullName | Should -BeExactly $path } It 'Should add the entries of several accounts to a by its path when the result is enumerated, and remove them again' -ForEach @( @{ Kind = 'file'; Directory = $false } @{ Kind = 'folder'; Directory = $true } ) { $path = New-TestSandboxItem -Sandbox $sandbox -Name 'AuditListByPath' -Directory:$Directory $accounts = New-Object -TypeName 'System.Collections.Generic.List[Security2.IdentityReference2]' $accounts.Add($identity) $accounts.Add($users) $pending = [Security2.FileSystemAuditRule2]::AddFileSystemAuditRule( $path, $accounts, [Security2.FileSystemRights2]::Delete, $success, $noInheritance, $noPropagation ) @($pending) | Should -HaveCount 2 @(Get-AuditEntries -Path $path) | Should -HaveCount 1 @(Get-AuditEntries -Path $path -Account 'S-1-5-32-545') | Should -HaveCount 1 [Security2.FileSystemAuditRule2]::RemoveFileSystemAuditRule( $path, $identity, [Security2.FileSystemRights2]::Delete, $success, $noInheritance, $noPropagation, $true ) @(Get-AuditEntries -Path $path) | Should -BeNullOrEmpty @(Get-AuditEntries -Path $path -Account 'S-1-5-32-545') | Should -HaveCount 1 [Security2.FileSystemAuditRule2]::RemoveFileSystemAuditRule( $path, $users, [Security2.FileSystemRights2]::Delete, $success, $noInheritance, $noPropagation, $false ) @(Get-AuditEntries -Path $path -Account 'S-1-5-32-545') | Should -BeNullOrEmpty } It 'Should name the item of a rule, replay it, read it by path, and remove it by its rule object' { $path = New-TestSandboxItem -Sandbox $sandbox -Name 'AuditReplay' $raw = New-Object -TypeName 'System.Security.AccessControl.FileSystemAuditRule' -ArgumentList ( $sid, [System.Security.AccessControl.FileSystemRights]::Delete, $success ) $item = New-Object -TypeName 'Alphaleonis.Win32.Filesystem.FileInfo' -ArgumentList $path $rule = New-Object -TypeName 'Security2.FileSystemAuditRule2' -ArgumentList $raw, $item $rule.FullName | Should -BeExactly $path $rule.Name | Should -BeExactly (Split-Path -Path $path -Leaf) [Security2.FileSystemAuditRule2]::AddFileSystemAuditRule($rule) @(Get-AuditEntries -Path $path) | Should -HaveCount 1 $found = @([Security2.FileSystemAuditRule2]::GetFileSystemAuditRules($path, $true, $true)) $found | Should -HaveCount 1 $found[0].Account.Sid | Should -BeExactly 'S-1-1-0' [Security2.FileSystemAuditRule2]::RemoveFileSystemAuditRule($item, $raw) @(Get-AuditEntries -Path $path) | Should -BeNullOrEmpty } It 'Should remove a rule object from an item without audit entries and change nothing' { $path = New-TestSandboxItem -Sandbox $sandbox -Name 'AuditNothing' $raw = New-Object -TypeName 'System.Security.AccessControl.FileSystemAuditRule' -ArgumentList ( $sid, [System.Security.AccessControl.FileSystemRights]::Delete, $success ) $item = New-Object -TypeName 'Alphaleonis.Win32.Filesystem.FileInfo' -ArgumentList $path $before = (Get-Acl -LiteralPath $path).Sddl [Security2.FileSystemAuditRule2]::RemoveFileSystemAuditRule($item, $raw) (Get-Acl -LiteralPath $path).Sddl | Should -BeExactly $before @(Get-AuditEntries -Path $path) | Should -BeNullOrEmpty } } Describe 'Inheritance helpers that take a path' { It 'Should block and restore the access inheritance of a by its path' -ForEach @( @{ Kind = 'file'; Directory = $false; Remove = $false } @{ Kind = 'folder'; Directory = $true; Remove = $true } ) { $path = New-TestSandboxItem -Sandbox $sandbox -Name 'InheritanceByPath' -Directory:$Directory $inherited = @((Get-Acl -LiteralPath $path).GetAccessRules($false, $true, [System.Security.Principal.SecurityIdentifier])).Count $inherited | Should -BeGreaterThan 0 [Security2.FileSystemInheritanceInfo]::DisableAccessInheritance($path, $Remove) $acl = Get-Acl -LiteralPath $path $acl.AreAccessRulesProtected | Should -BeTrue @($acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])).Count | Should -Be $(if ($Remove) { 0 } else { $inherited }) [Security2.FileSystemInheritanceInfo]::EnableAccessInheritance($path, $Remove) $acl = Get-Acl -LiteralPath $path $acl.AreAccessRulesProtected | Should -BeFalse @($acl.GetAccessRules($false, $true, [System.Security.Principal.SecurityIdentifier])).Count | Should -Be $inherited } It 'Should read the access inheritance of a file by its path and keep what the caller sets on the result' { $path = New-TestSandboxItem -Sandbox $sandbox -Name 'InheritanceInfo' $info = [Security2.FileSystemInheritanceInfo]::GetFileSystemInheritanceInfo($path) $info.AccessInheritanceEnabled | Should -BeTrue $info.Item.FullName | Should -BeExactly $path $info.AccessInheritanceEnabled = $false $info.AuditInheritanceEnabled = $true $info.Item = New-Object -TypeName 'Alphaleonis.Win32.Filesystem.FileInfo' -ArgumentList $path $info.AccessInheritanceEnabled | Should -BeFalse $info.AuditInheritanceEnabled | Should -BeTrue (Get-Acl -LiteralPath $path).AreAccessRulesProtected | Should -BeFalse } # The overloads that take a path do nothing for a path that is neither a file nor a folder. It ' should change nothing for a path that does not exist' -ForEach @( @{ Method = 'EnableAccessInheritance' } @{ Method = 'DisableAccessInheritance' } @{ Method = 'EnableAuditInheritance' } @{ Method = 'DisableAuditInheritance' } ) { $missing = Join-Path -Path $sandbox -ChildPath ('Missing-{0}' -f [guid]::NewGuid().ToString('N')) Assert-TestSandboxPath -Sandbox $sandbox -Path $missing { [Security2.FileSystemInheritanceInfo]::$Method($missing, $true) } | Should -Not -Throw Test-Path -LiteralPath $missing | Should -BeFalse } It 'Should block and restore the audit inheritance of a by its path' -Skip:(-not $holdsSecurityPrivilege) -ForEach @( @{ Kind = 'file'; Directory = $false } @{ Kind = 'folder'; Directory = $true } ) { $path = New-TestSandboxItem -Sandbox $sandbox -Name 'AuditInheritanceByPath' -Directory:$Directory (Get-NTFSInheritance -Path $path).AuditInheritanceEnabled | Should -BeTrue [Security2.FileSystemInheritanceInfo]::DisableAuditInheritance($path, $false) (Get-NTFSInheritance -Path $path).AuditInheritanceEnabled | Should -BeFalse [Security2.FileSystemInheritanceInfo]::EnableAuditInheritance($path, $false) (Get-NTFSInheritance -Path $path).AuditInheritanceEnabled | Should -BeTrue } } Describe 'Owner and descriptor objects' { It 'Should name the item and the account of an owner object' { $path = New-TestSandboxItem -Sandbox $sandbox -Name 'OwnerObject' $owner = Get-NTFSOwner -Path $path $owner.Item.FullName | Should -BeExactly $path $owner.FullName | Should -BeExactly $path $owner.Account.Sid | Should -BeExactly $owner.Owner.Sid } It 'Should read the owner of a drive root also for a lowercase drive letter' { $root = [IO.Path]::GetPathRoot($sandbox) $expected = (Get-NTFSOwner -Path $root).Owner.Sid $owner = Get-NTFSOwner -Path $root.ToLowerInvariant() $owner.Owner.Sid | Should -BeExactly $expected } It 'Should name the item of a descriptor and write it to a folder by its path' { $source = New-TestSandboxItem -Sandbox $sandbox -Name 'DescriptorSource' -Directory $target = New-TestSandboxItem -Sandbox $sandbox -Name 'DescriptorTarget' -Directory Add-NTFSAccess -Path $source -Account 'S-1-1-0' -AccessRights ReadData -AppliesTo ThisFolderOnly $descriptor = Get-NTFSSecurityDescriptor -Path $source $descriptor.Name | Should -BeExactly (Split-Path -Path $source -Leaf) $descriptor.Write([string] $target) @((Get-Acl -LiteralPath $target).GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) | Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-1-0' }) | Should -HaveCount 1 } # The item decides where Write puts the sections that the descriptor was read with, and Name and FullName follow it. It 'Should write a descriptor to the item that the caller assigns' { $source = New-TestSandboxItem -Sandbox $sandbox -Name 'RetargetSource' $target = New-TestSandboxItem -Sandbox $sandbox -Name 'RetargetTarget' Add-NTFSAccess -Path $source -Account 'S-1-1-0' -AccessRights ReadData $descriptor = Get-NTFSSecurityDescriptor -Path $source $descriptor.Item = Get-Item2 -Path $target $descriptor.FullName | Should -BeExactly $target $descriptor.Name | Should -BeExactly (Split-Path -Path $target -Leaf) $descriptor.Write() foreach ($path in $source, $target) { @((Get-Acl -LiteralPath $path).GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) | Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-1-0' }) | Should -HaveCount 1 } } It 'Should name the missing path when it writes a descriptor to an item that does not exist' { $source = New-TestSandboxItem -Sandbox $sandbox -Name 'DescriptorMissingSource' $missing = Join-Path -Path $sandbox -ChildPath ('Missing-{0}' -f [guid]::NewGuid().ToString('N')) Assert-TestSandboxPath -Sandbox $sandbox -Path $missing $descriptor = Get-NTFSSecurityDescriptor -Path $source $failure = { $descriptor.Write($missing) } | Should -Throw -PassThru $failure.Exception.GetBaseException() | Should -BeOfType [System.IO.FileNotFoundException] $failure.Exception.GetBaseException().FileName | Should -BeExactly $missing } It 'Should leave both flags unset for an AppliesTo value that no case names' { $inheritance = [System.Security.AccessControl.InheritanceFlags]::ContainerInherit $propagation = [System.Security.AccessControl.PropagationFlags]::InheritOnly [Security2.FileSystemSecurity2]::ConvertToFileSystemFlags( [Enum]::ToObject([Security2.ApplyTo], 99), [ref] $inheritance, [ref] $propagation ) $inheritance | Should -Be 'None' $propagation | Should -Be 'None' } } Describe 'Generic access rights and identity errors' { It 'Should map the generic mask to the file system rights ' -ForEach @( @{ Mask = '80000000'; Expected = '00120089' } @{ Mask = '40000000'; Expected = '00120116' } @{ Mask = '20000000'; Expected = '001200A0' } @{ Mask = '10000000'; Expected = '001F01FF' } @{ Mask = 'C0000000'; Expected = '0012019F' } @{ Mask = '80010000'; Expected = '00130089' } @{ Mask = '001F01FF'; Expected = '001F01FF' } @{ Mask = '00120089'; Expected = '00120089' } @{ Mask = '02000000'; Expected = '02000000' } @{ Mask = '82000000'; Expected = '02120089' } @{ Mask = '00000000'; Expected = '00000000' } ) { $rights = [Security2.FileSystemSecurity2]::MapGenericRightsToFileSystemRights([Convert]::ToUInt32($Mask, 16)) [int] $rights | Should -Be ([Convert]::ToInt32($Expected, 16)) } It 'Should reject when it creates an identity' -ForEach @( @{ Case = 'an empty value'; Value = ''; Expected = [System.ArgumentException] } @{ Case = 'a SID with too many sub authorities'; Value = ('S-1-' + (('1-' * 20) + '1')); Expected = [System.InvalidCastException] } @{ Case = 'an account that does not exist'; Value = 'NTFSSecurityNoSuchAccount'; Expected = [System.Security.Principal.IdentityNotMappedException] } ) { $failure = { [Security2.IdentityReference2]::new($Value) } | Should -Throw -PassThru # PowerShell wraps the exception of a constructor, which here wraps the cause of an invalid SID in turn. $failure.Exception.InnerException | Should -BeOfType $Expected } } # The helpers of the cmdlets are public extension methods, so a script can call them. The cmdlets pass what the guards # of ForEach refuse and a parent that is a folder, never a file; the generic method is invoked through reflection, # because Windows PowerShell can't name the type argument of a call. Describe 'The extension methods of the cmdlets' { BeforeAll { $forEachMethod = [NTFSSecurity.Extensions].GetMethod('ForEach').MakeGenericMethod([string]) function New-ItemObject { [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Test helper that only creates an object.' )] param ([string] $Library, [string] $Path) if ($Library -eq 'AlphaFS') { [Alphaleonis.Win32.Filesystem.FileInfo]::new($Path) } else { [System.IO.FileInfo]::new($Path) } } } It 'ForEach should reject ' -ForEach @( @{ Case = 'a source that is null'; NullSource = $true } @{ Case = 'an action that is null'; NullSource = $false } ) { $arguments = [object[]]::new(2) if ($NullSource) { $arguments[1] = [System.Action[string]] { param ($Element) $null = $Element } } else { $arguments[0] = [string[]] @('One') } $failure = { $forEachMethod.Invoke($null, $arguments) } | Should -Throw -PassThru # Reflection wraps the exception of the method, and PowerShell wraps that in turn. $failure.Exception.GetBaseException() | Should -BeOfType [System.ArgumentException] } It 'ForEach should run the action for each element in order' { $seen = New-Object -TypeName 'System.Collections.Generic.List[string]' $arguments = [object[]]::new(2) $arguments[0] = [string[]] @('One', 'Two', 'Three') $arguments[1] = [System.Action[string]] { param ($Element) $seen.Add($Element) } $forEachMethod.Invoke($null, $arguments) | Out-Null $seen -join ',' | Should -BeExactly 'One,Two,Three' } Context 'GetParent' { BeforeAll { $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'ParentFolder' -Directory $file = New-TestSandboxItem -Sandbox $sandbox -Name 'ParentFile' $inFolder = Join-Path -Path $folder -ChildPath 'Child.txt' $belowFile = Join-Path -Path $file -ChildPath 'Child.txt' $belowMissing = Join-Path -Path $sandbox -ChildPath 'Missing\Child.txt' } It 'Should return a folder as a DirectoryInfo of ' -ForEach @( @{ Library = 'AlphaFS'; TypeName = 'Alphaleonis.Win32.Filesystem.DirectoryInfo' } @{ Library = 'System.IO'; TypeName = 'System.IO.DirectoryInfo' } ) { $parent = [NTFSSecurity.Extensions]::GetParent((New-ItemObject -Library $Library -Path $inFolder)) $parent.GetType().FullName | Should -BeExactly $TypeName $parent.FullName | Should -BeExactly $folder } # A FileInfo can name a path below a file, which no file system holds, so the parent path names a file. It 'Should return a parent path that names a file as a FileInfo of ' -ForEach @( @{ Library = 'AlphaFS'; TypeName = 'Alphaleonis.Win32.Filesystem.FileInfo' } @{ Library = 'System.IO'; TypeName = 'System.IO.FileInfo' } ) { $parent = [NTFSSecurity.Extensions]::GetParent((New-ItemObject -Library $Library -Path $belowFile)) $parent.GetType().FullName | Should -BeExactly $TypeName $parent.FullName | Should -BeExactly $file } It 'Should throw a FileNotFoundException for a parent that does not exist, for ' -ForEach @( @{ Library = 'AlphaFS' } @{ Library = 'System.IO' } ) { $failure = { [NTFSSecurity.Extensions]::GetParent((New-ItemObject -Library $Library -Path $belowMissing)) } | Should -Throw -PassThru $failure.Exception.GetBaseException() | Should -BeOfType [System.IO.FileNotFoundException] } } }