Browse Source

fix: guard remaining object and cmdlet behavior paths

Reproduce and fix public rule paths, simplified audit comparisons and ReadData conversion, and boxed privilege equality. Add behavior guards for descriptor inheritance, unresolved identities, audit capability and recursive denial. Freeze this source for Release matrix measurement; final gate evidence and independent review follow.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Co-authored-by: AI Assistant <ai@example.com>
pull/118/head
Raimund Andree 2 days ago
parent
commit
b14c90b038
  1. 28
      .memory-bank/activeContext.md
  2. 10
      CHANGELOG.md
  3. 18
      Docs/FAQ.md
  4. 2
      ProcessPrivileges/PrivilegeAndAttributes.cs
  5. 1
      Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.cs
  6. 1
      Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.cs
  7. 5
      Security2/FileSystem/SimpleFileSystemAuditRule.cs
  8. 35
      Tests/Access.Tests.ps1
  9. 101
      Tests/Audit.Tests.ps1
  10. 78
      Tests/Inheritance.Tests.ps1
  11. 33
      Tests/ItemCmdlets.Tests.ps1
  12. 263
      Tests/ObjectApis.Tests.ps1

28
.memory-bank/activeContext.md

@ -9,11 +9,15 @@ source: current task evidence
## Current focus ## Current focus
Quality-gate follow-up is implemented and validated locally on Handoff 1 is in progress on `ai/quality-gate-paths`, from reviewed #117
`ai/quality-gate-coverage`, based on `d25647d` of #116. Code/test baseline head `f11ff41`. Both stacked PRs are open and green; rc6 remains the latest
`3442194`, lab regression/acceptance `7594e0c`; final records follow. published candidate and 4.2.6 the stable Gallery version. Public rule-path,
No remote mutation. Architecture/cmdlet-design choices remain deferred; simplified-audit, and boxed privilege-comparison defects were reproduced
Decision 22 is still proposed. Stable 5.0.0 is not ready (Decision 21). and fixed test-first. New descriptor, native-identity, audit-capability and
recursive-denial guards pass focused checks. Frozen full-suite coverage,
complete path explanations and the requested independent review follow.
The shared lab and remotes are unchanged. Decisions 21/22 and stable 5.0.0
remain gated; Decision 22 is proposed, not accepted.
## Evidence ## Evidence
@ -56,10 +60,10 @@ Decision 22 is still proposed. Stable 5.0.0 is not ready (Decision 21).
## Next step ## Next step
1. Maintainer pushes/reviews this follow-up; retain separate commits and 1. Finish Handoff 1: freeze Release source, prove characterization guards,
stacked-PR merge order (15). #116's Decision 22 review remains required. run all four configurations, classify every refreshed gap and review.
2. Integrate and pass CI, then publish/test the next candidate package. 2. Send code fixes and persistent evidence to gate 3 before publication;
3. Close the remaining-path inventory (918 points, 562 for finer review), repeat affected packaged acceptance after integration. No local upload.
decide/provision the OS matrix, obtain or explicitly accept #34 feedback. 3. Retain stacked-PR order (15), obtain Decision 22 review, finish the OS
4. Only then release 5.0.0 through documented CI steps; never claim the matrix and obtain or explicitly accept #34 feedback through other gates.
current coverage percentage alone meets the quality gate. 4. Do not release stable 5.0.0 or equate a percentage with gate closure.

10
CHANGELOG.md

@ -104,6 +104,16 @@ The format is based on
### Fixed ### Fixed
- Retain the supplied path in the public access- and audit-rule constructors
so their `FullName`, `Name`, and simplified audit conversions identify
the item
- Reduce `ReadData` to `Read` in simplified audit entries, and compare them
with audit entries rather than access entries, preserving equality with
themselves and with equivalent simplified audit objects
- Compare boxed privilege output values by their privilege and attributes;
the object overload rejected privilege values and recursively compared
an attributes enum instead
- Fix `Get-Help`, which showed only the syntax: ship the help file - Fix `Get-Help`, which showed only the syntax: ship the help file
`en-US\NTFSSecurity.dll-Help.xml` generated from the cmdlet documentation, `en-US\NTFSSecurity.dll-Help.xml` generated from the cmdlet documentation,
including the links that `Get-Help -Online` opens, instead of the outdated including the links that `Get-Help -Online` opens, instead of the outdated

18
Docs/FAQ.md

@ -93,3 +93,21 @@ Compare-Object -ReferenceObject (Get-NTFSAccess -Path C:\Data\A) -DifferenceObje
The same works for the entries of `Get-NTFSAudit`, with `AuditFlags` in The same works for the entries of `Get-NTFSAudit`, with `AuditFlags` in
place of `AccessControlType`. See place of `AccessControlType`. See
[Get-NTFSAccess](Cmdlets/Get-NTFSAccess.md). [Get-NTFSAccess](Cmdlets/Get-NTFSAccess.md).
## How do the public object APIs compare and convert entries?
The public `FileSystemAccessRule2` and `FileSystemAuditRule2` constructors
that take a .NET rule and a string path retain that path in `FullName` and
its last component in `Name`. They do not read or change the item. This is
useful when an application constructs a rule before replaying it through
the public rule helpers.
`ToSimpleFileSystemAuditRule2()` retains the path and account and reduces
`ReadData` to `Read`, like the simplified access-rule helper. Simplified
audit objects compare only with other simplified audit objects; they are
not equal to access objects. This does not change the reference-based
comparison of the full access and audit entries described above.
The values returned by `Get-Privileges` compare by `Privilege` and
`PrivilegeAttributes`. Typed and boxed .NET comparisons agree, and an
unrelated object is not equal to a privilege value.

2
ProcessPrivileges/PrivilegeAndAttributes.cs

@ -83,7 +83,7 @@ namespace ProcessPrivileges
/// <returns>Value indicating whether this instance and a specified object are equal.</returns> /// <returns>Value indicating whether this instance and a specified object are equal.</returns>
public override bool Equals(object obj) public override bool Equals(object obj)
{ {
return obj is PrivilegeAttributes ? this.Equals((PrivilegeAttributes)obj) : false; return obj is PrivilegeAndAttributes ? this.Equals((PrivilegeAndAttributes)obj) : false;
} }
/// <summary>Indicates whether this instance and another instance are equal.</summary> /// <summary>Indicates whether this instance and another instance are equal.</summary>

1
Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.cs

@ -37,6 +37,7 @@ namespace Security2
public FileSystemAccessRule2(FileSystemAccessRule fileSystemAccessRule, string path) public FileSystemAccessRule2(FileSystemAccessRule fileSystemAccessRule, string path)
{ {
this.fileSystemAccessRule = fileSystemAccessRule; this.fileSystemAccessRule = fileSystemAccessRule;
this.fullName = path;
} }
public static implicit operator FileSystemAccessRule(FileSystemAccessRule2 ace2) public static implicit operator FileSystemAccessRule(FileSystemAccessRule2 ace2)

1
Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.cs

@ -37,6 +37,7 @@ namespace Security2
public FileSystemAuditRule2(FileSystemAuditRule fileSystemAuditRule, string path) public FileSystemAuditRule2(FileSystemAuditRule fileSystemAuditRule, string path)
{ {
this.fileSystemAuditRule = fileSystemAuditRule; this.fileSystemAuditRule = fileSystemAuditRule;
this.fullName = path;
} }
#region Conversion #region Conversion

5
Security2/FileSystem/SimpleFileSystemAuditRule.cs

@ -42,6 +42,9 @@ namespace Security2
if ((accessRights & FileSystemRights2.Read) == FileSystemRights2.Read) if ((accessRights & FileSystemRights2.Read) == FileSystemRights2.Read)
{ result |= SimpleFileSystemAccessRights.Read; } { result |= SimpleFileSystemAccessRights.Read; }
if ((accessRights & FileSystemRights2.ReadData) == FileSystemRights2.ReadData)
{ result |= SimpleFileSystemAccessRights.Read; }
if ((accessRights & FileSystemRights2.CreateFiles) == FileSystemRights2.CreateFiles) if ((accessRights & FileSystemRights2.CreateFiles) == FileSystemRights2.CreateFiles)
{ result |= SimpleFileSystemAccessRights.Write; } { result |= SimpleFileSystemAccessRights.Write; }
@ -109,7 +112,7 @@ namespace Security2
public override bool Equals(object obj) public override bool Equals(object obj)
{ {
var compareObject = obj as SimpleFileSystemAccessRule; var compareObject = obj as SimpleFileSystemAuditRule;
if (compareObject == null) if (compareObject == null)
{ {

35
Tests/Access.Tests.ps1

@ -955,3 +955,38 @@ Describe 'InheritedFrom of access entries' {
} }
} }
} }
Describe 'Get-NTFSEffectiveAccess for an unresolved identity' {
It 'Should report the native identity error for each <Source> and return no access entry' -ForEach @(
@{ Source = 'Path' }
@{ Source = 'SecurityDescriptor' }
) {
$first = New-TestSandboxItem -Sandbox $sandbox -Name 'UnresolvedFirst'
$next = New-TestSandboxItem -Sandbox $sandbox -Name 'UnresolvedNext'
$identity = [Security2.IdentityReference2] 'S-1-5-21-1-2-3-1001'
$identity.AccountName | Should -BeNullOrEmpty
$identity.LastError | Should -Not -BeNullOrEmpty
$before = @((Get-Acl -LiteralPath $first).Sddl, (Get-Acl -LiteralPath $next).Sddl)
$parameters = @{ Account = $identity; WarningAction = 'SilentlyContinue'; ErrorAction = 'SilentlyContinue' }
if ($Source -eq 'Path') {
$parameters.Path = @($first, $next)
}
else {
$parameters.SecurityDescriptor = @(Get-NTFSSecurityDescriptor -Path $first, $next)
}
$result = @(Get-NTFSEffectiveAccess @parameters -ErrorVariable accessErrors)
$result | Should -BeNullOrEmpty
$accessErrors | Should -HaveCount 2
for ($index = 0; $index -lt 2; $index++) {
$accessErrors[$index].FullyQualifiedErrorId | Should -BeLike 'GetEffectiveAccessError,*'
$accessErrors[$index].CategoryInfo.Category | Should -Be 'ReadError'
$accessErrors[$index].TargetObject.FullName | Should -BeExactly @($first, $next)[$index]
$cause = $accessErrors[$index].Exception.GetBaseException()
$cause | Should -BeOfType [System.ComponentModel.Win32Exception]
$cause.NativeErrorCode | Should -Be 1332
}
(Get-Acl -LiteralPath $first).Sddl | Should -BeExactly $before[0]
(Get-Acl -LiteralPath $next).Sddl | Should -BeExactly $before[1]
}
}

101
Tests/Audit.Tests.ps1

@ -512,3 +512,104 @@ Describe 'InheritedFrom of audit entries' {
$inherited[0].InheritedFrom | Should -Be $folder $inherited[0].InheritedFrom | Should -Be $folder
} }
} }
Describe 'Audit changes with the Security privilege disabled' {
BeforeAll {
$holdsSecurityForOperations = Test-PrivilegeHeld -Name 'SeSecurityPrivilege'
}
BeforeEach {
$savedEnablePrivileges = $privateData['EnablePrivileges']
$securityWasEnabled = (Get-Privileges | Where-Object -Property Privilege -EQ -Value 'Security').PrivilegeState -eq 'Enabled'
}
AfterEach {
$privateData['EnablePrivileges'] = $savedEnablePrivileges
if ($securityWasEnabled) {
$null = [ProcessPrivileges.ProcessExtensions]::EnablePrivilege(
[Diagnostics.Process]::GetCurrentProcess(), [ProcessPrivileges.Privilege]::Security
)
}
else {
$null = [ProcessPrivileges.ProcessExtensions]::DisablePrivilege(
[Diagnostics.Process]::GetCurrentProcess(), [ProcessPrivileges.Privilege]::Security
)
}
}
It '<Command> should use a held privilege or report a missing one and continue to the next path' -ForEach @(
@{ Command = 'Add-NTFSAudit'; ErrorId = 'AddAceError'; Parameters = @{ Account = 'S-1-1-0'; AccessRights = 'ReadData'; PassThru = $true } }
@{ Command = 'Remove-NTFSAudit'; ErrorId = 'RemoveAceError'; Parameters = @{ Account = 'S-1-1-0'; AccessRights = 'Delete'; PassThru = $true } }
@{ Command = 'Clear-NTFSAudit'; ErrorId = 'ClearAclError'; Parameters = @{ DisableInheritance = $true } }
@{ Command = 'Enable-NTFSAuditInheritance'; ErrorId = 'ModifySdError'; Parameters = @{ PassThru = $true; RemoveExplicitAuditRules = $true } }
@{ Command = 'Disable-NTFSAuditInheritance'; ErrorId = 'ModifySdError'; Parameters = @{ PassThru = $true; RemoveInheritedAuditRules = $true } }
) {
$path = New-TestSandboxItem -Sandbox $sandbox -Name 'DisabledSecurity'
$missing = Join-Path -Path $sandbox -ChildPath ('MissingAudit-{0}' -f [guid]::NewGuid().ToString('N'))
Assert-TestSandboxPath -Sandbox $sandbox -Path $path, $missing
if ($holdsSecurityForOperations) {
$privateData['EnablePrivileges'] = $true
Add-NTFSAudit -Path $path -Account 'S-1-1-0' -AccessRights Delete -AuditFlags Success -AppliesTo ThisFolderOnly
$saclBefore = (Get-NTFSSecurityDescriptor -Path $path).SecurityDescriptor.GetSecurityDescriptorSddlForm('Audit')
}
$before = (Get-Acl -LiteralPath $path).Sddl
$privateData['EnablePrivileges'] = $false
$null = [ProcessPrivileges.ProcessExtensions]::DisablePrivilege(
[Diagnostics.Process]::GetCurrentProcess(), [ProcessPrivileges.Privilege]::Security
)
(Get-Privileges | Where-Object -Property Privilege -EQ -Value 'Security').PrivilegeState | Should -Not -Be 'Enabled'
$result = @(& $Command -Path $path, $missing @Parameters -ErrorVariable auditErrors -ErrorAction SilentlyContinue)
(Get-Acl -LiteralPath $path).Sddl | Should -BeExactly $before
if ($holdsSecurityForOperations) {
# AlphaFS temporarily enables a held Security privilege for SACL access, even with automatic privileges off.
(Get-Privileges | Where-Object -Property Privilege -EQ -Value 'Security').PrivilegeState | Should -Be 'Disabled'
$auditErrors | Should -HaveCount 1
$auditErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadFileError,*'
$auditErrors[0].CategoryInfo.Category | Should -Be 'OpenError'
$auditErrors[0].TargetObject | Should -BeExactly $missing
$written = Get-NTFSSecurityDescriptor -Path $path
$rules = @($written.SecurityDescriptor.GetAuditRules(
$true, $false, [System.Security.Principal.SecurityIdentifier]
))
switch ($Command) {
'Add-NTFSAudit' {
$result | Should -Not -BeNullOrEmpty
$result | ForEach-Object { $_.FullName | Should -BeExactly $path }
@($rules | Where-Object {
$_.IdentityReference.Value -eq 'S-1-1-0' -and $_.FileSystemRights.HasFlag(
[System.Security.AccessControl.FileSystemRights]::ReadData
)
}).Count | Should -BeGreaterThan 0
}
'Disable-NTFSAuditInheritance' {
$result | Should -HaveCount 1
$result[0].AuditInheritanceEnabled | Should -BeFalse
$rules | Should -HaveCount 1
$rules[0].FileSystemRights | Should -Be ([System.Security.AccessControl.FileSystemRights]::Delete)
}
'Enable-NTFSAuditInheritance' {
$result | Should -HaveCount 1
$result[0].AuditInheritanceEnabled | Should -BeTrue
$rules | Should -BeNullOrEmpty
}
default {
$result | Should -BeNullOrEmpty
$rules | Should -BeNullOrEmpty
}
}
$written.SecurityDescriptor.GetSecurityDescriptorSddlForm('Audit') | Should -Not -BeExactly $saclBefore
}
else {
$result | Should -BeNullOrEmpty
$auditErrors | Should -HaveCount 2
$auditErrors[0].FullyQualifiedErrorId | Should -BeLike "$ErrorId,*"
$auditErrors[0].CategoryInfo.Category | Should -Be 'WriteError'
$auditErrors[0].TargetObject | Should -BeExactly $path
$auditErrors[1].FullyQualifiedErrorId | Should -BeLike 'ReadFileError,*'
$auditErrors[1].CategoryInfo.Category | Should -Be 'OpenError'
$auditErrors[1].TargetObject | Should -BeExactly $missing
}
}
}

78
Tests/Inheritance.Tests.ps1

@ -430,3 +430,81 @@ Describe 'Access inheritance cmdlets' {
} }
} }
} }
Describe 'Set-NTFSInheritance with an in-memory descriptor' {
It 'Should set access inheritance enabled=<Enable> on a <Type> only when the descriptor is written' -ForEach @(
@{ Type = 'file'; Enable = $false }
@{ Type = 'file'; Enable = $true }
@{ Type = 'folder'; Enable = $false }
@{ Type = 'folder'; Enable = $true }
) {
$path = New-TestSandboxItem -Sandbox $sandbox -Name 'DescriptorAccessState' -Directory:($Type -eq 'folder')
Assert-TestSandboxPath -Sandbox $sandbox -Path $path
Set-NTFSInheritance -Path $path -AccessInheritanceEnabled (-not $Enable) -ErrorAction Stop
Add-NTFSAccess -Path $path -Account 'S-1-1-0' -AccessRights ReadData -AppliesTo ThisFolderOnly
$before = (Get-Acl -LiteralPath $path).GetSecurityDescriptorSddlForm('Access')
$ownerBefore = (Get-Acl -LiteralPath $path).Owner
$sd = Get-NTFSSecurityDescriptor -Path $path
$result = @(Set-NTFSInheritance -SecurityDescriptor $sd -AccessInheritanceEnabled $Enable -PassThru -ErrorAction Stop)
$result | Should -HaveCount 1
$result[0].FullName | Should -BeExactly $path
$result[0].Name | Should -BeExactly ([IO.Path]::GetFileName($path))
$result[0].AccessInheritanceEnabled | Should -Be $Enable
$sd.SecurityDescriptor.AreAccessRulesProtected | Should -Be (-not $Enable)
(Get-Acl -LiteralPath $path).GetSecurityDescriptorSddlForm('Access') | Should -BeExactly $before
Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -ErrorAction Stop
(Get-Acl -LiteralPath $path).AreAccessRulesProtected | Should -Be (-not $Enable)
(Get-Acl -LiteralPath $path).Owner | Should -BeExactly $ownerBefore
@(Get-NTFSAccess -Path $path -ExcludeInherited | Where-Object { $_.Account.Sid -eq 'S-1-1-0' }) |
Should -HaveCount 1
}
It 'Should set audit inheritance enabled=<Enable> on a <Type> without changing its DACL or owner' -Skip:(-not $canChangeAudit) -ForEach @(
@{ Type = 'file'; Enable = $false }
@{ Type = 'file'; Enable = $true }
@{ Type = 'folder'; Enable = $false }
@{ Type = 'folder'; Enable = $true }
) {
$path = New-TestSandboxItem -Sandbox $sandbox -Name 'DescriptorAuditState' -Directory:($Type -eq 'folder')
Assert-TestSandboxPath -Sandbox $sandbox -Path $path
Set-NTFSInheritance -Path $path -AuditInheritanceEnabled (-not $Enable) -ErrorAction Stop
Add-NTFSAudit -Path $path -Account 'S-1-1-0' -AccessRights Delete -AuditFlags Success -AppliesTo ThisFolderOnly
$before = (Get-NTFSSecurityDescriptor -Path $path).SecurityDescriptor.GetSecurityDescriptorSddlForm('Audit')
$daclBefore = (Get-Acl -LiteralPath $path).GetSecurityDescriptorSddlForm('Access')
$ownerBefore = (Get-Acl -LiteralPath $path).Owner
$sd = Get-NTFSSecurityDescriptor -Path $path
$result = @(Set-NTFSInheritance -SecurityDescriptor $sd -AuditInheritanceEnabled $Enable -PassThru -ErrorAction Stop)
$result | Should -HaveCount 1
$result[0].AuditInheritanceEnabled | Should -Be $Enable
$sd.SecurityDescriptor.AreAuditRulesProtected | Should -Be (-not $Enable)
(Get-NTFSSecurityDescriptor -Path $path).SecurityDescriptor.GetSecurityDescriptorSddlForm('Audit') |
Should -BeExactly $before
Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -ErrorAction Stop
(Get-NTFSInheritance -Path $path).AuditInheritanceEnabled | Should -Be $Enable
(Get-Acl -LiteralPath $path).GetSecurityDescriptorSddlForm('Access') | Should -BeExactly $daclBefore
(Get-Acl -LiteralPath $path).Owner | Should -BeExactly $ownerBefore
@(Get-NTFSAudit -Path $path -ExcludeInherited | Where-Object { $_.Account.Sid -eq 'S-1-1-0' }) |
Should -HaveCount 1
}
It 'Should keep audit inheritance unknown when requested enabled=<_> on an access-only descriptor' -ForEach @($false, $true) {
$path = New-TestSandboxItem -Sandbox $sandbox -Name 'DescriptorUnknownAudit'
$sd = New-Object -TypeName 'Security2.FileSystemSecurity2' -ArgumentList (
(Get-Item2 -Path $path), [System.Security.AccessControl.AccessControlSections]::Access
)
$before = (Get-Acl -LiteralPath $path).Sddl
$result = @(Set-NTFSInheritance -SecurityDescriptor $sd -AuditInheritanceEnabled $_ -PassThru -ErrorAction Stop)
$result | Should -HaveCount 1
$result[0].AuditInheritanceEnabled | Should -BeNullOrEmpty
$raw = New-Object -TypeName 'System.Security.AccessControl.RawSecurityDescriptor' -ArgumentList (
$sd.SecurityDescriptor.GetSecurityDescriptorBinaryForm(), 0
)
$null -eq $raw.SystemAcl | Should -BeTrue
(Get-Acl -LiteralPath $path).Sddl | Should -BeExactly $before
}
}

33
Tests/ItemCmdlets.Tests.ps1

@ -777,3 +777,36 @@ Describe 'Get-DiskSpace' {
Should -Throw -ErrorId 'ParameterArgumentValidationError,NTFSSecurity.GetDiskSpace' Should -Throw -ErrorId 'ParameterArgumentValidationError,NTFSSecurity.GetDiskSpace'
} }
} }
Describe 'Get-ChildItem2 when recursive enumeration becomes denied' {
It 'Should name the failed recursion in verbose output and continue with the next path' {
$root = New-TestSandboxItem -Sandbox $sandbox -Name 'ChangingReadPermission' -Directory
$child = Join-Path -Path $root -ChildPath 'Child'
$first = Join-Path -Path $root -ChildPath 'First.txt'
$nested = Join-Path -Path $child -ChildPath 'Nested.txt'
$next = New-TestSandboxItem -Sandbox $sandbox -Name 'NextRecursivePath' -Directory
$nextFile = Join-Path -Path $next -ChildPath 'Next.txt'
Assert-TestSandboxPath -Sandbox $sandbox -Path $root, $child, $first, $nested, $nextFile
New-Item -ItemType Directory -Path $child | Out-Null
Set-Content -LiteralPath $first -Value 'First'
Set-Content -LiteralPath $nested -Value 'Nested'
Set-Content -LiteralPath $nextFile -Value 'Next'
$ownerBefore = (Get-Acl -LiteralPath $root).Owner
# The first file is emitted before the separate recursive directory enumeration opens the folder again.
$records = @(Get-ChildItem2 -Path $root, $next -File -Recurse -Verbose -ErrorVariable childErrors -ErrorAction SilentlyContinue 4>&1 |
ForEach-Object {
if ($_ -is [Alphaleonis.Win32.Filesystem.FileInfo] -and $_.FullName -eq $first) {
Add-TestDenyRule -Sandbox $sandbox -Path $root -Rights @{ 'S-1-1-0' = 'ReadData' }
}
$_
})
$childErrors | Should -BeNullOrEmpty
$files = @($records | Where-Object { $_ -is [Alphaleonis.Win32.Filesystem.FileInfo] })
@($files.FullName | Sort-Object) | Should -Be @(@($first, $nextFile) | Sort-Object)
$messages = @($records | Where-Object { $_ -is [System.Management.Automation.VerboseRecord] })
$messages.Message | Should -Contain "Cannot access folder '$root' for recursive operation"
(Get-Acl -LiteralPath $root).Owner | Should -BeExactly $ownerBefore
Get-Content -LiteralPath $nested | Should -BeExactly 'Nested'
}
}

263
Tests/ObjectApis.Tests.ps1

@ -0,0 +1,263 @@
<#
Tests the public object APIs used with cmdlet output, without changing an item's security descriptor.
#>
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
'PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Pester shares variables between blocks.'
)]
param ()
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 <Kind> 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 <Rights> to <Expected>' -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 <Value> 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> 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'
}
}
}
Loading…
Cancel
Save