diff --git a/.memory-bank/activeContext.md b/.memory-bank/activeContext.md
index 52c5dab..ce702e4 100644
--- a/.memory-bank/activeContext.md
+++ b/.memory-bank/activeContext.md
@@ -9,11 +9,15 @@ source: current task evidence
## Current focus
-Quality-gate follow-up is implemented and validated locally on
-`ai/quality-gate-coverage`, based on `d25647d` of #116. Code/test baseline
-`3442194`, lab regression/acceptance `7594e0c`; final records follow.
-No remote mutation. Architecture/cmdlet-design choices remain deferred;
-Decision 22 is still proposed. Stable 5.0.0 is not ready (Decision 21).
+Handoff 1 is in progress on `ai/quality-gate-paths`, from reviewed #117
+head `f11ff41`. Both stacked PRs are open and green; rc6 remains the latest
+published candidate and 4.2.6 the stable Gallery version. Public rule-path,
+simplified-audit, and boxed privilege-comparison defects were reproduced
+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
@@ -56,10 +60,10 @@ Decision 22 is still proposed. Stable 5.0.0 is not ready (Decision 21).
## Next step
-1. Maintainer pushes/reviews this follow-up; retain separate commits and
- stacked-PR merge order (15). #116's Decision 22 review remains required.
-2. Integrate and pass CI, then publish/test the next candidate package.
-3. Close the remaining-path inventory (918 points, 562 for finer review),
- decide/provision the OS matrix, obtain or explicitly accept #34 feedback.
-4. Only then release 5.0.0 through documented CI steps; never claim the
- current coverage percentage alone meets the quality gate.
+1. Finish Handoff 1: freeze Release source, prove characterization guards,
+ run all four configurations, classify every refreshed gap and review.
+2. Send code fixes and persistent evidence to gate 3 before publication;
+ repeat affected packaged acceptance after integration. No local upload.
+3. Retain stacked-PR order (15), obtain Decision 22 review, finish the OS
+ matrix and obtain or explicitly accept #34 feedback through other gates.
+4. Do not release stable 5.0.0 or equate a percentage with gate closure.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0b9045b..3c79c5c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -104,6 +104,16 @@ The format is based on
### 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
`en-US\NTFSSecurity.dll-Help.xml` generated from the cmdlet documentation,
including the links that `Get-Help -Online` opens, instead of the outdated
diff --git a/Docs/FAQ.md b/Docs/FAQ.md
index 1232611..6ebfeb4 100644
--- a/Docs/FAQ.md
+++ b/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
place of `AccessControlType`. See
[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.
diff --git a/ProcessPrivileges/PrivilegeAndAttributes.cs b/ProcessPrivileges/PrivilegeAndAttributes.cs
index 065ade9..6ed6684 100644
--- a/ProcessPrivileges/PrivilegeAndAttributes.cs
+++ b/ProcessPrivileges/PrivilegeAndAttributes.cs
@@ -83,7 +83,7 @@ namespace ProcessPrivileges
/// Value indicating whether this instance and a specified object are equal.
public override bool Equals(object obj)
{
- return obj is PrivilegeAttributes ? this.Equals((PrivilegeAttributes)obj) : false;
+ return obj is PrivilegeAndAttributes ? this.Equals((PrivilegeAndAttributes)obj) : false;
}
/// Indicates whether this instance and another instance are equal.
diff --git a/Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.cs b/Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.cs
index bcba319..4b1a200 100644
--- a/Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.cs
+++ b/Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.cs
@@ -37,6 +37,7 @@ namespace Security2
public FileSystemAccessRule2(FileSystemAccessRule fileSystemAccessRule, string path)
{
this.fileSystemAccessRule = fileSystemAccessRule;
+ this.fullName = path;
}
public static implicit operator FileSystemAccessRule(FileSystemAccessRule2 ace2)
diff --git a/Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.cs b/Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.cs
index f8c729f..8699568 100644
--- a/Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.cs
+++ b/Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.cs
@@ -37,6 +37,7 @@ namespace Security2
public FileSystemAuditRule2(FileSystemAuditRule fileSystemAuditRule, string path)
{
this.fileSystemAuditRule = fileSystemAuditRule;
+ this.fullName = path;
}
#region Conversion
diff --git a/Security2/FileSystem/SimpleFileSystemAuditRule.cs b/Security2/FileSystem/SimpleFileSystemAuditRule.cs
index fbf2a58..d399186 100644
--- a/Security2/FileSystem/SimpleFileSystemAuditRule.cs
+++ b/Security2/FileSystem/SimpleFileSystemAuditRule.cs
@@ -42,6 +42,9 @@ namespace Security2
if ((accessRights & FileSystemRights2.Read) == FileSystemRights2.Read)
{ result |= SimpleFileSystemAccessRights.Read; }
+ if ((accessRights & FileSystemRights2.ReadData) == FileSystemRights2.ReadData)
+ { result |= SimpleFileSystemAccessRights.Read; }
+
if ((accessRights & FileSystemRights2.CreateFiles) == FileSystemRights2.CreateFiles)
{ result |= SimpleFileSystemAccessRights.Write; }
@@ -109,7 +112,7 @@ namespace Security2
public override bool Equals(object obj)
{
- var compareObject = obj as SimpleFileSystemAccessRule;
+ var compareObject = obj as SimpleFileSystemAuditRule;
if (compareObject == null)
{
diff --git a/Tests/Access.Tests.ps1 b/Tests/Access.Tests.ps1
index 32d0fa5..0c3efdc 100644
--- a/Tests/Access.Tests.ps1
+++ b/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 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]
+ }
+}
diff --git a/Tests/Audit.Tests.ps1 b/Tests/Audit.Tests.ps1
index f9fa2a8..e521adf 100644
--- a/Tests/Audit.Tests.ps1
+++ b/Tests/Audit.Tests.ps1
@@ -512,3 +512,104 @@ Describe 'InheritedFrom of audit entries' {
$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 ' 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
+ }
+ }
+}
diff --git a/Tests/Inheritance.Tests.ps1 b/Tests/Inheritance.Tests.ps1
index a4e41fe..bf473cc 100644
--- a/Tests/Inheritance.Tests.ps1
+++ b/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= on a 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= on a 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
+ }
+}
diff --git a/Tests/ItemCmdlets.Tests.ps1 b/Tests/ItemCmdlets.Tests.ps1
index d21eea2..324098d 100644
--- a/Tests/ItemCmdlets.Tests.ps1
+++ b/Tests/ItemCmdlets.Tests.ps1
@@ -777,3 +777,36 @@ Describe 'Get-DiskSpace' {
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'
+ }
+}
diff --git a/Tests/ObjectApis.Tests.ps1 b/Tests/ObjectApis.Tests.ps1
new file mode 100644
index 0000000..d5ea1dc
--- /dev/null
+++ b/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 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'
+ }
+ }
+}