From a97e46fa80078613c16e9b5dec8c5fac1c527f2c Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Fri, 9 Oct 2026 07:39:49 +0000 Subject: [PATCH 1/8] test: guard folder deletion and ownership recovery failures Cover recursive, read-only, locked, long-path, and junction deletion in sandbox folders. Exercise denied ownership retries and both successful and failed restoration after an operation fails, without inconclusive results or a success-shaped hash. The tests fail when folder deletion and owner restoration are omitted: 16 expected failures in the controlled mutation run. Restored code passes all 66 focused tests where applicable in both editions and privilege configurations. No production behavior changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- Tests/FileHash.Tests.ps1 | 34 ++++++-- Tests/PathErrors.Tests.ps1 | 63 ++++++++++++++ Tests/Remove-Item2.Tests.ps1 | 154 ++++++++++++++++++++++++++++++++++- 3 files changed, 245 insertions(+), 6 deletions(-) diff --git a/Tests/FileHash.Tests.ps1 b/Tests/FileHash.Tests.ps1 index 221c7b8..821ed2f 100644 --- a/Tests/FileHash.Tests.ps1 +++ b/Tests/FileHash.Tests.ps1 @@ -98,8 +98,18 @@ Describe 'Get-FileHash2' { } Context 'When the file cannot be read after taking ownership' { - # Before 5.0.0, the account that ran the cmdlet stayed the owner when the second attempt failed. Only an - # elevated process can make another account the owner first, so the test runs in CI. + BeforeAll { + $privateData = (Get-Module -Name NTFSSecurity).PrivateData + $enablePrivileges = $privateData['EnablePrivileges'] + $privateData['EnablePrivileges'] = $false + } + + AfterAll { + $privateData['EnablePrivileges'] = $enablePrivileges + } + + # Disable automatic privileges so that the deny entry reaches the ownership retry even in an elevated process. + # Administrators is an assignable owner for that process, unlike TrustedInstaller. It 'Should restore the previous owner' -Skip:(-not $isElevated) { $denied = New-TestSandboxItem -Sandbox $sandbox -Name 'Denied' Assert-TestSandboxPath -Sandbox $sandbox -Path $denied @@ -107,14 +117,28 @@ Describe 'Get-FileHash2' { Add-NTFSAccess -Path $denied -Account 'S-1-1-0' -AccessRights ReadData -AccessType Deny $results = @(Get-FileHash2 -Path $denied -ErrorVariable hashErrors -ErrorAction SilentlyContinue) - if (-not $hashErrors) { - Set-ItResult -Inconclusive -Because 'the elevated process could read the file despite the deny entry' - } $hashErrors | Should -HaveCount 1 $hashErrors[0].FullyQualifiedErrorId | Should -BeLike 'GetHashError,*' $results | Should -BeNullOrEmpty (Get-NTFSOwner -Path $denied).Owner.Sid | Should -Be 'S-1-5-32-544' } + + It 'Should report both the failed read and the failed owner restoration without returning a hash' -Skip:(-not $isElevated) { + $denied = New-TestSandboxItem -Sandbox $sandbox -Name 'RestoreDenied' + Add-TestDenyRule -Sandbox $sandbox -Path $denied -Rights @{ 'S-1-1-0' = 'ReadData' } + $originalOwner = 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464' + Set-TestOwner -Sandbox $sandbox -Path $denied -Sid $originalOwner + (Get-Privileges | Where-Object -Property Privilege -EQ -Value 'Restore').PrivilegeState | Should -Be 'Disabled' + + $result = @(Get-FileHash2 -Path $denied -ErrorVariable hashErrors -ErrorAction SilentlyContinue) + + $result | Should -BeNullOrEmpty + $hashErrors | Should -HaveCount 2 + $hashErrors[0].FullyQualifiedErrorId | Should -BeLike 'RestoreOwnerError,*' + $hashErrors[1].FullyQualifiedErrorId | Should -BeLike 'GetHashError,*' + $hashErrors | ForEach-Object -Process { $_.TargetObject | Should -Be $denied } + (Get-NTFSOwner -Path $denied).Owner.Sid | Should -Be ([Security.Principal.WindowsIdentity]::GetCurrent().User.Value) + } } } diff --git a/Tests/PathErrors.Tests.ps1 b/Tests/PathErrors.Tests.ps1 index 3202c8e..eb613bd 100644 --- a/Tests/PathErrors.Tests.ps1 +++ b/Tests/PathErrors.Tests.ps1 @@ -13,6 +13,7 @@ param () BeforeDiscovery { Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force $holdsSecurityPrivilege = Test-PrivilegeHeld -Name 'SeSecurityPrivilege' + $holdsRestorePrivilege = Test-PrivilegeHeld -Name 'SeRestorePrivilege' $currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value $readEntry = @{ Account = 'S-1-1-0'; AccessRights = 'ReadData' } # An audit entry on a file has no inheritance flags. @@ -151,6 +152,68 @@ Describe 'An item whose owner may not read its permissions' { } } +Describe 'A denied write and a denied ownership retry' { + It ' should keep the denied item unchanged, report , and process the next item' -ForEach @( + @{ Command = 'Add-NTFSAccess'; Parameters = @{ Account = 'S-1-1-0'; AccessRights = 'ReadData' }; ErrorId = 'AddAceError'; Operation = 'Add' } + @{ Command = 'Remove-NTFSAccess'; Parameters = @{ Account = 'S-1-1-0'; AccessRights = 'ReadData' }; ErrorId = 'RemoveAceError'; Operation = 'Remove' } + @{ Command = 'Clear-NTFSAccess'; Parameters = @{}; ErrorId = 'ClearAclError'; Operation = 'Clear' } + @{ Command = 'Disable-NTFSAccessInheritance'; Parameters = @{}; ErrorId = 'ModifySdError'; Operation = 'Disable' } + @{ Command = 'Enable-NTFSAccessInheritance'; Parameters = @{}; ErrorId = 'ModifySdError'; Operation = 'Enable' } + @{ Command = 'Set-NTFSInheritance'; Parameters = @{ AccessInheritanceEnabled = $false }; ErrorId = 'ModifySdError'; Operation = 'Disable' } + ) { + $blocked = New-TestSandboxItem -Sandbox $sandbox -Name 'RetryBlocked' + $next = New-TestSandboxItem -Sandbox $sandbox -Name 'RetryNext' + foreach ($path in $blocked, $next) { + if ($Operation -ne 'Add') { + Add-NTFSAccess -Path $path -Account 'S-1-1-0' -AccessRights ReadData -ErrorAction Stop + } + if ($Operation -eq 'Enable') { + Disable-NTFSAccessInheritance -Path $path -ErrorAction Stop + } + } + Block-TestWritePermission -Sandbox $sandbox -Path $blocked + $before = (Get-TestAcl -Path $blocked).Sddl + + & $Command -Path $blocked, $next @Parameters -ErrorVariable changeErrors -ErrorAction SilentlyContinue + + $changeErrors | Should -HaveCount 1 + $changeErrors[0].FullyQualifiedErrorId | Should -BeLike "$ErrorId,*" + $changeErrors[0].TargetObject | Should -Be $blocked + (Get-TestAcl -Path $blocked).Sddl | Should -BeExactly $before + $acl = Get-TestAcl -Path $next + $everyone = @($acl.GetAccessRules($true, $false, $sidType) | Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-1-0' }) + switch ($Operation) { + 'Add' { $everyone | Should -HaveCount 1 } + 'Remove' { $everyone | Should -BeNullOrEmpty } + 'Clear' { @($acl.GetAccessRules($true, $false, $sidType)) | Should -BeNullOrEmpty } + 'Disable' { $acl.AreAccessRulesProtected | Should -BeTrue } + 'Enable' { $acl.AreAccessRulesProtected | Should -BeFalse } + } + } +} + +Describe 'An owner that the process cannot restore without the Restore privilege' { + It 'Should report RestoreOwnerError after a successful ownership retry and continue with the next path' -Skip:(-not $holdsRestorePrivilege) { + $blocked = New-TestSandboxItem -Sandbox $sandbox -Name 'UnassignableOwner' + $next = New-TestSandboxItem -Sandbox $sandbox -Name 'NextOwner' + $user = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value + Add-TestDenyRule -Sandbox $sandbox -Path $blocked -Rights @{ $user = 'ChangePermissions' } + $originalOwner = 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464' + Set-TestOwner -Sandbox $sandbox -Path $blocked -Sid $originalOwner + (Get-Privileges | Where-Object -Property Privilege -EQ -Value 'Restore').PrivilegeState | Should -Be 'Disabled' + + Add-NTFSAccess -Path $blocked, $next -Account 'S-1-1-0' -AccessRights ReadData -ErrorVariable changeErrors -ErrorAction SilentlyContinue + + $changeErrors | Should -HaveCount 1 + $changeErrors[0].FullyQualifiedErrorId | Should -BeLike 'RestoreOwnerError,*' + $changeErrors[0].CategoryInfo.Category | Should -Be 'WriteError' + $changeErrors[0].TargetObject | Should -Be $blocked + (Get-TestAcl -Path $blocked).GetOwner($sidType).Value | Should -Be $user + foreach ($path in $blocked, $next) { + @((Get-TestAcl -Path $path).GetAccessRules($true, $false, $sidType) | Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-1-0' }) | Should -HaveCount 1 + } + } +} Describe 'An item whose owner may not change its permissions' { # A deny entry for OWNER RIGHTS replaces the right of the owner to change the DACL. The cmdlets take ownership, # which Windows answers by removing the OWNER RIGHTS entries, write the DACL, and set the previous owner back. diff --git a/Tests/Remove-Item2.Tests.ps1 b/Tests/Remove-Item2.Tests.ps1 index 853c545..a64c420 100644 --- a/Tests/Remove-Item2.Tests.ps1 +++ b/Tests/Remove-Item2.Tests.ps1 @@ -8,17 +8,169 @@ param () Describe 'Remove-Item2' { 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 'RemoveItem' + Push-Location -LiteralPath $sandbox } AfterAll { + Pop-Location + Remove-TestSandbox -Sandbox $sandbox Remove-Module -Name NTFSSecurity -Force -ErrorAction SilentlyContinue } + Context 'Folders and their contents' { + It 'Should remove an empty folder and return its folder object with -PassThru' { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'Empty' -Directory + + $result = @(Remove-Item2 -Path $folder -PassThru -ErrorAction Stop) + + $folder | Should -Not -Exist + $result | Should -HaveCount 1 + $result[0] | Should -BeOfType [Alphaleonis.Win32.Filesystem.DirectoryInfo] + $result[0].FullName | Should -Be $folder + } + + It 'Should report DeleteError for a non-empty folder without -Recurse and continue with the next path' { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'NonEmpty' -Directory + $content = Join-Path -Path $folder -ChildPath 'Keep.txt' + $next = New-TestSandboxItem -Sandbox $sandbox -Name 'Next' + Assert-TestSandboxPath -Sandbox $sandbox -Path $content + Set-Content -LiteralPath $content -Value 'Keep' + + $result = @(Remove-Item2 -Path $folder, $next -PassThru -ErrorVariable removeErrors -ErrorAction SilentlyContinue) + + $removeErrors | Should -HaveCount 1 + $removeErrors[0].FullyQualifiedErrorId | Should -BeLike 'DeleteError,*' + $removeErrors[0].CategoryInfo.Category | Should -Be 'InvalidData' + $removeErrors[0].TargetObject | Should -Be $folder + Get-Content -LiteralPath $content | Should -Be 'Keep' + $next | Should -Not -Exist + $result | Should -HaveCount 1 + $result[0].FullName | Should -Be $next + } + + It 'Should remove a folder tree with -Recurse without touching its sibling' { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'Tree' -Directory + $nested = Join-Path -Path $folder -ChildPath 'Child\Grandchild' + $content = Join-Path -Path $nested -ChildPath 'Delete.txt' + $sibling = New-TestSandboxItem -Sandbox $sandbox -Name 'Sibling' + Assert-TestSandboxPath -Sandbox $sandbox -Path $nested, $content + New-Item -ItemType Directory -Path $nested -Force | Out-Null + Set-Content -LiteralPath $content -Value 'Delete' + + Remove-Item2 -Path $folder -Recurse -ErrorAction Stop + + $folder | Should -Not -Exist + Get-Content -LiteralPath $sibling | Should -Be 'Sibling' + } + + It 'Should leave a folder tree unchanged with -Recurse -Force -WhatIf and write nothing with -PassThru' { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'Preview' -Directory + $content = Join-Path -Path $folder -ChildPath 'Keep.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $content + Set-Content -LiteralPath $content -Value 'Keep' + [IO.File]::SetAttributes($content, [IO.FileAttributes]::ReadOnly) + + $result = @(Remove-Item2 -Path $folder -Recurse -Force -WhatIf -PassThru -ErrorAction Stop) + + $result | Should -BeNullOrEmpty + Get-Content -LiteralPath $content | Should -Be 'Keep' + ([IO.File]::GetAttributes($content) -band [IO.FileAttributes]::ReadOnly) | Should -Not -Be 0 + } + + It 'Should remove a folder tree containing read-only files with -Recurse -Force' { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'ReadOnlyTree' -Directory + $content = Join-Path -Path $folder -ChildPath 'Child\ReadOnly.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $content + New-Item -ItemType Directory -Path (Split-Path -Path $content -Parent) | Out-Null + Set-Content -LiteralPath $content -Value 'ReadOnly' + [IO.File]::SetAttributes($content, [IO.FileAttributes]::ReadOnly) + + Remove-Item2 -Path $folder -Recurse -Force -ErrorAction Stop + + $folder | Should -Not -Exist + } + + It 'Should write DeleteError when a descendant is open without delete sharing, not a successful -PassThru result' { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'LockedTree' -Directory + $content = Join-Path -Path $folder -ChildPath 'Locked.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $content + Set-Content -LiteralPath $content -Value 'Locked' + $stream = [IO.File]::Open($content, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::None) + try { + $result = @(Remove-Item2 -Path $folder -Recurse -Force -PassThru -ErrorVariable removeErrors -ErrorAction SilentlyContinue) + } + finally { + $stream.Dispose() + } + + $removeErrors | Should -HaveCount 1 + $removeErrors[0].FullyQualifiedErrorId | Should -BeLike 'DeleteError,*' + $removeErrors[0].TargetObject | Should -Be $folder + $result | Should -BeNullOrEmpty + Get-Content -LiteralPath $content | Should -Be 'Locked' + } + + It 'Should delete a junction with -Recurse without deleting or changing its target' { + $target = New-TestSandboxItem -Sandbox $sandbox -Name 'JunctionTarget' -Directory + $content = Join-Path -Path $target -ChildPath 'Keep.txt' + $link = Join-Path -Path $sandbox -ChildPath 'Junction' + Assert-TestSandboxPath -Sandbox $sandbox -Path $content, $link + Set-Content -LiteralPath $content -Value 'Keep' + $before = (Get-Acl -LiteralPath $target).Sddl + New-Item -ItemType Junction -Path $link -Value $target | Out-Null + + Remove-Item2 -Path $link -Recurse -Force -ErrorAction Stop + + $link | Should -Not -Exist + Get-Content -LiteralPath $content | Should -Be 'Keep' + (Get-Acl -LiteralPath $target).Sddl | Should -BeExactly $before + } + + It 'Should delete a folder tree whose path exceeds 260 characters' { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'LongTree' -Directory + $long = Join-Path -Path $folder -ChildPath (('A' * 100), ('B' * 100), ('C' * 100) -join '\') + Assert-TestSandboxPath -Sandbox $sandbox -Path $long + [IO.Directory]::CreateDirectory('\\?\' + $long) | Out-Null + [IO.File]::WriteAllText(('\\?\' + $long + '\Delete.txt'), 'Long') + $long.Length | Should -BeGreaterThan 260 + + Remove-Item2 -Path $folder -Recurse -Force -ErrorAction Stop + + $folder | Should -Not -Exist + } + } + + Context 'Read-only files' { + It 'Should refuse a read-only file without -Force, keep its contents and attribute, and return nothing' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'ReadOnly' + [IO.File]::SetAttributes($file, [IO.FileAttributes]::ReadOnly) + + $result = @(Remove-Item2 -Path $file -PassThru -ErrorVariable removeErrors -ErrorAction SilentlyContinue) + + $removeErrors | Should -HaveCount 1 + $removeErrors[0].FullyQualifiedErrorId | Should -BeLike 'DeleteError,*' + $result | Should -BeNullOrEmpty + Get-Content -LiteralPath $file | Should -Be 'ReadOnly' + ([IO.File]::GetAttributes($file) -band [IO.FileAttributes]::ReadOnly) | Should -Not -Be 0 + } + + It 'Should remove a read-only file with -Force' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'Forced' + [IO.File]::SetAttributes($file, [IO.FileAttributes]::ReadOnly) + + Remove-Item2 -Path $file -Force -ErrorAction Stop + + $file | Should -Not -Exist + } + } Context 'When called with -PassThur, the parameter name in 4.2.6 and earlier' { BeforeAll { - $path = Join-Path -Path $TestDrive -ChildPath 'PassThur.txt' + $path = Join-Path -Path $sandbox -ChildPath 'PassThur.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $path Set-Content -LiteralPath $path -Value 'Remove-Item2 test' $removedItem = Remove-Item2 -Path $path -PassThur From e7ee203ea651a4c49e473bc9aaea1213b7354193 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Fri, 9 Oct 2026 07:50:18 +0000 Subject: [PATCH 2/8] test: cover permission scopes and folder inheritance transitions Exercise all 13 scopes using named scopes and explicit Windows flags, through disk paths and in-memory descriptors, for access and audit entries. Verify removal keeps another account, and check actual inheritance through children and grandchildren, including OneLevel. Cover keep/remove switches and successful PassThru for both files and folders, including Set-NTFSInheritance enabling protection states. Controlled mutations lose OneLevel and keep folders protected: 12 tests fail as expected. Restored implementations pass the 170-test focused suite in all four configurations where privileges permit. No cmdlet contract changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- Tests/Inheritance.Tests.ps1 | 126 +++++++++++++++++++++++ Tests/PermissionScopes.Tests.ps1 | 165 +++++++++++++++++++++++++++++++ 2 files changed, 291 insertions(+) create mode 100644 Tests/PermissionScopes.Tests.ps1 diff --git a/Tests/Inheritance.Tests.ps1 b/Tests/Inheritance.Tests.ps1 index 50d3fce..a4e41fe 100644 --- a/Tests/Inheritance.Tests.ps1 +++ b/Tests/Inheritance.Tests.ps1 @@ -12,6 +12,13 @@ BeforeDiscovery { $canChangeAudit = Test-PrivilegeHeld -Name 'SeSecurityPrivilege' # Assigning an owner other than the user or one of its groups needs the Restore privilege. $canAssignAnyOwner = Test-PrivilegeHeld -Name 'SeRestorePrivilege' + $inheritanceCases = @(foreach ($type in 'file', 'folder') { + foreach ($enable in $false, $true) { + foreach ($remove in $false, $true) { + @{ Type = $type; Enable = $enable; Remove = $remove } + } + } + }) } BeforeAll { @@ -276,6 +283,125 @@ Describe 'Audit inheritance switches' { } } +Describe 'Access inheritance transitions on files and folders' { + It 'Should set enabled= on a , remove requested entries=, and report the written state' -ForEach $inheritanceCases { + $parent = New-TestSandboxItem -Sandbox $sandbox -Name 'AccessParent' -Directory + $path = Join-Path -Path $parent -ChildPath 'Child' + $parentAccount = 'S-1-5-21-1-2-3-4901' + $childAccount = 'S-1-5-21-1-2-3-4902' + Add-NTFSAccess -Path $parent -Account $parentAccount -AccessRights ReadData -ErrorAction Stop + Assert-TestSandboxPath -Sandbox $sandbox -Path $path + if ($Type -eq 'folder') { New-Item -ItemType Directory -Path $path | Out-Null } else { Set-Content -LiteralPath $path -Value 'Child' } + Add-NTFSAccess -Path $path -Account $childAccount -AccessRights Delete -AppliesTo ThisFolderOnly -ErrorAction Stop + @(Get-NTFSAccess -Path $path -Account $parentAccount -ExcludeExplicit -ErrorAction Stop) | Should -HaveCount 1 + $owner = (Get-NTFSOwner -Path $path -ErrorAction Stop).Owner.Sid + if ($Enable) { + Disable-NTFSAccessInheritance -Path $path -RemoveInheritedAccessRules -ErrorAction Stop + $parameters = @{ RemoveExplicitAccessRules = $Remove } + $command = 'Enable-NTFSAccessInheritance' + } + else { + $parameters = @{ RemoveInheritedAccessRules = $Remove } + $command = 'Disable-NTFSAccessInheritance' + } + + $result = @(& $command -Path $path @parameters -PassThru -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0] | Should -BeOfType [Security2.FileSystemInheritanceInfo] + $result[0].FullName | Should -Be $path + $result[0].AccessInheritanceEnabled | Should -Be $Enable + (Get-NTFSInheritance -Path $path).AccessInheritanceEnabled | Should -Be $Enable + (Get-NTFSOwner -Path $path).Owner.Sid | Should -Be $owner + $parentRules = @(Get-NTFSAccess -Path $path -Account $parentAccount) + if ($Enable) { + $parentRules | Should -HaveCount 1 + $parentRules[0].IsInherited | Should -BeTrue + } + elseif ($Remove) { + $parentRules | Should -BeNullOrEmpty + } + else { + $parentRules | Should -HaveCount 1 + $parentRules[0].IsInherited | Should -BeFalse + } + $childRules = @(Get-NTFSAccess -Path $path -Account $childAccount) + if ($Enable -and $Remove) { $childRules | Should -BeNullOrEmpty } else { $childRules | Should -HaveCount 1 } + } + + It 'Set-NTFSInheritance should re-enable access inheritance on a <_> and keep its explicit entry' -ForEach @('file', 'folder') { + $path = New-TestSandboxItem -Sandbox $sandbox -Name 'AccessEnable' -Directory:($_ -eq 'folder') + Add-NTFSAccess -Path $path -Account 'S-1-5-21-1-2-3-4902' -AccessRights Delete -AppliesTo ThisFolderOnly -ErrorAction Stop + Disable-NTFSAccessInheritance -Path $path -RemoveInheritedAccessRules -ErrorAction Stop + + $result = @(Set-NTFSInheritance -Path $path -AccessInheritanceEnabled $true -PassThru -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0].AccessInheritanceEnabled | Should -BeTrue + @(Get-NTFSAccess -Path $path -ExcludeExplicit) | Should -Not -BeNullOrEmpty + @(Get-NTFSAccess -Path $path -Account 'S-1-5-21-1-2-3-4902' -ExcludeInherited) | Should -HaveCount 1 + } +} + +Describe 'Audit inheritance transitions on files and folders' -Skip:(-not $canChangeAudit) { + It 'Should set enabled= on a , remove requested audit entries=, and leave the DACL unchanged' -ForEach $inheritanceCases { + $parent = New-TestSandboxItem -Sandbox $sandbox -Name 'AuditParent' -Directory + $path = Join-Path -Path $parent -ChildPath 'Child' + $parentAccount = 'S-1-5-21-1-2-3-4911' + $childAccount = 'S-1-5-21-1-2-3-4912' + Add-NTFSAudit -Path $parent -Account $parentAccount -AccessRights ReadData -AuditFlags Success -ErrorAction Stop + Assert-TestSandboxPath -Sandbox $sandbox -Path $path + if ($Type -eq 'folder') { New-Item -ItemType Directory -Path $path | Out-Null } else { Set-Content -LiteralPath $path -Value 'Child' } + Add-NTFSAudit -Path $path -Account $childAccount -AccessRights Delete -AuditFlags Failure -AppliesTo ThisFolderOnly -ErrorAction Stop + @(Get-NTFSAudit -Path $path -Account $parentAccount -ExcludeExplicit -ErrorAction Stop) | Should -HaveCount 1 + $before = (Get-Acl -LiteralPath $path).GetSecurityDescriptorSddlForm('Access') + if ($Enable) { + Disable-NTFSAuditInheritance -Path $path -RemoveInheritedAuditRules -ErrorAction Stop + $parameters = @{ RemoveExplicitAuditRules = $Remove } + $command = 'Enable-NTFSAuditInheritance' + } + else { + $parameters = @{ RemoveInheritedAuditRules = $Remove } + $command = 'Disable-NTFSAuditInheritance' + } + + $result = @(& $command -Path $path @parameters -PassThru -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0].FullName | Should -Be $path + $result[0].AuditInheritanceEnabled | Should -Be $Enable + (Get-NTFSInheritance -Path $path).AuditInheritanceEnabled | Should -Be $Enable + (Get-Acl -LiteralPath $path).GetSecurityDescriptorSddlForm('Access') | Should -BeExactly $before + $parentRules = @(Get-NTFSAudit -Path $path -Account $parentAccount) + if ($Enable) { + $parentRules | Should -HaveCount 1 + $parentRules[0].IsInherited | Should -BeTrue + } + elseif ($Remove) { + $parentRules | Should -BeNullOrEmpty + } + else { + $parentRules | Should -HaveCount 1 + $parentRules[0].IsInherited | Should -BeFalse + } + $childRules = @(Get-NTFSAudit -Path $path -Account $childAccount) + if ($Enable -and $Remove) { $childRules | Should -BeNullOrEmpty } else { $childRules | Should -HaveCount 1 } + } + + It 'Set-NTFSInheritance should re-enable audit inheritance on a <_> and keep its explicit audit entry' -ForEach @('file', 'folder') { + $path = New-TestSandboxItem -Sandbox $sandbox -Name 'AuditEnable' -Directory:($_ -eq 'folder') + Add-NTFSAudit -Path $path -Account 'S-1-5-21-1-2-3-4912' -AccessRights Delete -AuditFlags Failure -AppliesTo ThisFolderOnly -ErrorAction Stop + Disable-NTFSAuditInheritance -Path $path -RemoveInheritedAuditRules -ErrorAction Stop + $before = (Get-Acl -LiteralPath $path).GetSecurityDescriptorSddlForm('Access') + + $result = @(Set-NTFSInheritance -Path $path -AuditInheritanceEnabled $true -PassThru -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0].AuditInheritanceEnabled | Should -BeTrue + @(Get-NTFSAudit -Path $path -Account 'S-1-5-21-1-2-3-4912' -ExcludeInherited) | Should -HaveCount 1 + (Get-Acl -LiteralPath $path).GetSecurityDescriptorSddlForm('Access') | Should -BeExactly $before + } +} Describe 'Access inheritance cmdlets' { Context 'When the item has an owner that the user cannot assign' { BeforeAll { diff --git a/Tests/PermissionScopes.Tests.ps1 b/Tests/PermissionScopes.Tests.ps1 new file mode 100644 index 0000000..16ff8d1 --- /dev/null +++ b/Tests/PermissionScopes.Tests.ps1 @@ -0,0 +1,165 @@ +<# + Tests all permission scopes through the access and audit cmdlets, both parameter forms and both storage modes. + Expected flags are the Windows ACE flags, independent of the module's scope converter. +#> +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Pester shares variables between blocks.' +)] +param () + +BeforeDiscovery { + Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force + $canReadAudit = Test-PrivilegeHeld -Name 'SeSecurityPrivilege' + $scopes = @( + @{ Name = 'ThisFolderOnly'; Inheritance = 'None'; Propagation = 'None' } + @{ Name = 'ThisFolderSubfoldersAndFiles'; Inheritance = 'ContainerInherit, ObjectInherit'; Propagation = 'None' } + @{ Name = 'ThisFolderAndSubfolders'; Inheritance = 'ContainerInherit'; Propagation = 'None' } + @{ Name = 'ThisFolderAndFiles'; Inheritance = 'ObjectInherit'; Propagation = 'None' } + @{ Name = 'SubfoldersAndFilesOnly'; Inheritance = 'ContainerInherit, ObjectInherit'; Propagation = 'InheritOnly' } + @{ Name = 'SubfoldersOnly'; Inheritance = 'ContainerInherit'; Propagation = 'InheritOnly' } + @{ Name = 'FilesOnly'; Inheritance = 'ObjectInherit'; Propagation = 'InheritOnly' } + @{ Name = 'ThisFolderSubfoldersAndFilesOneLevel'; Inheritance = 'ContainerInherit, ObjectInherit'; Propagation = 'NoPropagateInherit' } + @{ Name = 'ThisFolderAndSubfoldersOneLevel'; Inheritance = 'ContainerInherit'; Propagation = 'NoPropagateInherit' } + @{ Name = 'ThisFolderAndFilesOneLevel'; Inheritance = 'ObjectInherit'; Propagation = 'NoPropagateInherit' } + @{ Name = 'SubfoldersAndFilesOnlyOneLevel'; Inheritance = 'ContainerInherit, ObjectInherit'; Propagation = 'InheritOnly, NoPropagateInherit' } + @{ Name = 'SubfoldersOnlyOneLevel'; Inheritance = 'ContainerInherit'; Propagation = 'InheritOnly, NoPropagateInherit' } + @{ Name = 'FilesOnlyOneLevel'; Inheritance = 'ObjectInherit'; Propagation = 'InheritOnly, NoPropagateInherit' } + ) + $activeTargets = @{ + ThisFolderOnly = @('Root') + ThisFolderSubfoldersAndFiles = @('Root', 'File', 'Child', 'ChildFile', 'Grandchild', 'GrandchildFile') + ThisFolderAndSubfolders = @('Root', 'Child', 'Grandchild') + ThisFolderAndFiles = @('Root', 'File', 'ChildFile', 'GrandchildFile') + SubfoldersAndFilesOnly = @('File', 'Child', 'ChildFile', 'Grandchild', 'GrandchildFile') + SubfoldersOnly = @('Child', 'Grandchild') + FilesOnly = @('File', 'ChildFile', 'GrandchildFile') + ThisFolderSubfoldersAndFilesOneLevel = @('Root', 'File', 'Child') + ThisFolderAndSubfoldersOneLevel = @('Root', 'Child') + ThisFolderAndFilesOneLevel = @('Root', 'File') + SubfoldersAndFilesOnlyOneLevel = @('File', 'Child') + SubfoldersOnlyOneLevel = @('Child') + FilesOnlyOneLevel = @('File') + } + $propagationCases = @($scopes | ForEach-Object { @{ Name = $_.Name; ActiveTargets = $activeTargets[$_.Name] } }) + $scopeNames = @($scopes.Name) + $scopeCases = @(foreach ($scope in $scopes) { + foreach ($source in 'Path', 'SecurityDescriptor') { + foreach ($form in 'AppliesTo', 'Flags') { + @{ Name = $scope.Name; Inheritance = $scope.Inheritance; Propagation = $scope.Propagation; Source = $source; Form = $form } + } + } + }) +} + +BeforeAll { + Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force + Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath '..\NTFSSecurity\bin\Release\NTFSSecurity.psd1') -Force -ErrorAction Stop + $sandbox = New-TestSandbox -Name 'PermissionScopes' + Push-Location -LiteralPath $sandbox + $account = 'S-1-5-21-1-2-3-4801' + $keeper = 'S-1-5-21-1-2-3-4802' +} + +AfterAll { + Pop-Location + Remove-TestSandbox -Sandbox $sandbox + Remove-Module -Name NTFSSecurity -Force -ErrorAction SilentlyContinue +} + +Describe 'Permission scope inventory' { + It 'Should cover every named -AppliesTo value' -ForEach @(@{ ScopeNames = $scopeNames }) { + (@([Enum]::GetNames([Security2.ApplyTo])) | Sort-Object) -join ',' | + Should -Be (($scopeNames | Sort-Object) -join ',') + } +} + +Describe 'Access rule scopes' { + It 'Should add and remove using
on , preserving the other account' -ForEach $scopeCases { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'AccessScope' -Directory + $before = (Get-Acl -LiteralPath $folder).GetSecurityDescriptorSddlForm('Access') + $location = if ($Source -eq 'Path') { @{ Path = $folder } } else { @{ SecurityDescriptor = Get-NTFSSecurityDescriptor -Path $folder -ErrorAction Stop } } + $flags = @{ InheritanceFlags = $Inheritance; PropagationFlags = $Propagation } + $addScope = if ($Form -eq 'AppliesTo') { @{ AppliesTo = $Name } } else { $flags } + $removeScope = if ($Form -eq 'AppliesTo') { $flags } else { @{ AppliesTo = $Name } } + Add-NTFSAccess @location -Account $keeper -AccessRights Delete -AppliesTo ThisFolderOnly -ErrorAction Stop + + $added = @(Add-NTFSAccess @location @addScope -Account $account -AccessRights ReadData -PassThru -ErrorAction Stop | + Where-Object -FilterScript { $_.Account.Sid -eq $account }) + + $added | Should -HaveCount 1 + $added[0] | Should -BeOfType [Security2.FileSystemAccessRule2] + $added[0].InheritanceFlags | Should -Be ([Security.AccessControl.InheritanceFlags] $Inheritance) + $added[0].PropagationFlags | Should -Be ([Security.AccessControl.PropagationFlags] $Propagation) + $added[0].AccessRights.HasFlag([Security2.FileSystemRights2]::ReadData) | Should -BeTrue + [Security2.FileSystemSecurity2]::ConvertToApplyTo($added[0].InheritanceFlags, $added[0].PropagationFlags).ToString() | Should -Be $Name + if ($Source -eq 'SecurityDescriptor') { + (Get-Acl -LiteralPath $folder).GetSecurityDescriptorSddlForm('Access') | Should -BeExactly $before + } + + $remaining = @(Remove-NTFSAccess @location @removeScope -Account $account -AccessRights ReadData -RemoveSpecific -PassThru -ErrorAction Stop) + + @($remaining | Where-Object -FilterScript { $_.Account.Sid -eq $account }) | Should -BeNullOrEmpty + @($remaining | Where-Object -FilterScript { $_.Account.Sid -eq $keeper }) | Should -HaveCount 1 + @(Get-NTFSAccess @location -Account $account -ErrorAction Stop) | Should -BeNullOrEmpty + } +} + +Describe 'Access scopes on descendants' { + It 'Should apply only to its intended descendants, including the OneLevel boundary' -ForEach $propagationCases { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'Propagation' -Directory + Add-NTFSAccess -Path $folder -Account $account -AccessRights ReadData -AppliesTo $Name -ErrorAction Stop + $paths = [ordered]@{ + Root = $folder + File = Join-Path -Path $folder -ChildPath 'File.txt' + Child = Join-Path -Path $folder -ChildPath 'Child' + ChildFile = Join-Path -Path $folder -ChildPath 'Child\File.txt' + Grandchild = Join-Path -Path $folder -ChildPath 'Child\Grandchild' + GrandchildFile = Join-Path -Path $folder -ChildPath 'Child\Grandchild\File.txt' + } + Assert-TestSandboxPath -Sandbox $sandbox -Path @($paths.Values) + New-Item -ItemType Directory -Path $paths.Grandchild -Force | Out-Null + foreach ($key in 'File', 'ChildFile', 'GrandchildFile') { + Set-Content -LiteralPath $paths[$key] -Value $key + } + + $actual = @(foreach ($key in $paths.Keys) { + $active = @(Get-NTFSAccess -Path $paths[$key] -Account $account -ErrorAction Stop | Where-Object -FilterScript { + -not $_.PropagationFlags.HasFlag([Security.AccessControl.PropagationFlags]::InheritOnly) -and + $_.AccessRights.HasFlag([Security2.FileSystemRights2]::ReadData) + }) + if ($active.Count -gt 0) { $key } + }) + + ($actual | Sort-Object) -join ',' | Should -Be (($ActiveTargets | Sort-Object) -join ',') + } +} +Describe 'Audit rule scopes' -Skip:(-not $canReadAudit) { + It 'Should add and remove using on , preserving the other account' -ForEach $scopeCases { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'AuditScope' -Directory + $before = (Get-Acl -LiteralPath $folder -Audit).GetSecurityDescriptorSddlForm('Audit') + $location = if ($Source -eq 'Path') { @{ Path = $folder } } else { @{ SecurityDescriptor = Get-NTFSSecurityDescriptor -Path $folder -ErrorAction Stop } } + $flags = @{ InheritanceFlags = $Inheritance; PropagationFlags = $Propagation } + $addScope = if ($Form -eq 'AppliesTo') { @{ AppliesTo = $Name } } else { $flags } + $removeScope = if ($Form -eq 'AppliesTo') { $flags } else { @{ AppliesTo = $Name } } + Add-NTFSAudit @location -Account $keeper -AccessRights Delete -AuditFlags Failure -AppliesTo ThisFolderOnly -ErrorAction Stop + + $added = @(Add-NTFSAudit @location @addScope -Account $account -AccessRights ReadData -AuditFlags 'Success, Failure' -PassThru -ErrorAction Stop | + Where-Object -FilterScript { $_.Account.Sid -eq $account }) + + $added | Should -HaveCount 1 + $added[0] | Should -BeOfType [Security2.FileSystemAuditRule2] + $added[0].InheritanceFlags | Should -Be ([Security.AccessControl.InheritanceFlags] $Inheritance) + $added[0].PropagationFlags | Should -Be ([Security.AccessControl.PropagationFlags] $Propagation) + $added[0].AuditFlags | Should -Be ([Security.AccessControl.AuditFlags] 'Success, Failure') + [Security2.FileSystemSecurity2]::ConvertToApplyTo($added[0].InheritanceFlags, $added[0].PropagationFlags).ToString() | Should -Be $Name + if ($Source -eq 'SecurityDescriptor') { + (Get-Acl -LiteralPath $folder -Audit).GetSecurityDescriptorSddlForm('Audit') | Should -BeExactly $before + } + + $remaining = @(Remove-NTFSAudit @location @removeScope -Account $account -AccessRights ReadData -AuditFlags 'Success, Failure' -RemoveSpecific -PassThru -ErrorAction Stop) + + @($remaining | Where-Object -FilterScript { $_.Account.Sid -eq $account }) | Should -BeNullOrEmpty + @($remaining | Where-Object -FilterScript { $_.Account.Sid -eq $keeper }) | Should -HaveCount 1 + @(Get-NTFSAudit @location -Account $account -ErrorAction Stop) | Should -BeNullOrEmpty + } +} From 51dec86ae311d219ff67a5de5a0a37c45658f37b Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Fri, 9 Oct 2026 08:01:38 +0000 Subject: [PATCH 3/8] fix(ci): preserve absolute basic-user result paths Join-Path appended an absolute ResultPath to the repository path, producing an invalid path and preventing the restricted-token test run. Use Path.Combine to keep rooted paths intact while retaining repository- relative paths. Clarify the script parameter help. The offline process-boundary tests fail with the original expression in both editions and pass in all four configurations with the fix. They do not launch a process or modify privileges. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- .github/scripts/Invoke-TestsAsBasicUser.ps1 | 4 +- Tests/BasicUserRunner.Tests.ps1 | 81 +++++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 Tests/BasicUserRunner.Tests.ps1 diff --git a/.github/scripts/Invoke-TestsAsBasicUser.ps1 b/.github/scripts/Invoke-TestsAsBasicUser.ps1 index 60d33c6..89b6645 100644 --- a/.github/scripts/Invoke-TestsAsBasicUser.ps1 +++ b/.github/scripts/Invoke-TestsAsBasicUser.ps1 @@ -11,7 +11,7 @@ results through a file of this account, which the restricted token can write. .PARAMETER ResultPath - Specifies the path of the result file in the NUnit format. + Specifies an absolute path, or a path relative to the repository, for the result file in the NUnit format. .PARAMETER Title Specifies the heading of the test results in the job summary. It can't contain a double quote, a percent sign, or a @@ -167,7 +167,7 @@ public static class NTFSSecurityBasicUserProcess '@ $repositoryPath = (Resolve-Path -LiteralPath (Join-Path -Path $PSScriptRoot -ChildPath '..\..')).ProviderPath -$resultFullPath = [IO.Path]::GetFullPath((Join-Path -Path $repositoryPath -ChildPath $ResultPath)) +$resultFullPath = [IO.Path]::GetFullPath([IO.Path]::Combine($repositoryPath, $ResultPath)) $resultFolder = Split-Path -Path $resultFullPath -Parent if (-not (Test-Path -LiteralPath $resultFolder)) { New-Item -ItemType Directory -Path $resultFolder | Out-Null diff --git a/Tests/BasicUserRunner.Tests.ps1 b/Tests/BasicUserRunner.Tests.ps1 new file mode 100644 index 0000000..1ac46bb --- /dev/null +++ b/Tests/BasicUserRunner.Tests.ps1 @@ -0,0 +1,81 @@ +<# + Tests the basic-user CI wrapper without creating a process or changing privileges. A fake native process writes + the same result-file boundary as the child; only the Add-Type call of the copied wrapper is mocked. +#> +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Pester shares variables between blocks.' +)] +param () + +BeforeAll { + Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force + $sandbox = New-TestSandbox -Name 'BasicUserWrapper' + $repository = Join-Path -Path $sandbox -ChildPath 'Repository' + $scripts = Join-Path -Path $repository -ChildPath '.github\scripts' + Assert-TestSandboxPath -Sandbox $sandbox -Path $scripts + New-Item -ItemType Directory -Path $scripts -Force | Out-Null + $wrapper = Join-Path -Path $scripts -ChildPath 'Invoke-TestsAsBasicUser.ps1' + Copy-Item -LiteralPath (Join-Path -Path $PSScriptRoot -ChildPath '..\.github\scripts\Invoke-TestsAsBasicUser.ps1') -Destination $wrapper + Add-Type -TypeDefinition @" +using System; +using System.IO; +using System.Text.RegularExpressions; + +public static class NTFSSecurityBasicUserProcess +{ + public static int Calls; + public static string WorkingDirectory; + + public static int Run(string applicationName, string commandLine, string currentDirectory) + { + Calls++; + WorkingDirectory = currentDirectory; + var match = Regex.Match(commandLine, "-ResultPath \"([^\"]+)\""); + if (!match.Success) + throw new InvalidOperationException("The child command has no result path."); + File.WriteAllText(match.Groups[1].Value, ""); + return 0; + } +} +"@ +} + +AfterAll { + Remove-TestSandbox -Sandbox $sandbox +} + +Describe 'Invoke-TestsAsBasicUser.ps1 result paths' { + BeforeEach { + [NTFSSecurityBasicUserProcess]::Calls = 0 + [NTFSSecurityBasicUserProcess]::WorkingDirectory = $null + Mock -CommandName Add-Type -ParameterFilter { $TypeDefinition -like '*class NTFSSecurityBasicUserProcess*' } + } + + It 'Should copy the result to an absolute path, also when that path contains spaces' { + $result = Join-Path -Path $sandbox -ChildPath 'Absolute results\Result.xml' + Assert-TestSandboxPath -Sandbox $sandbox -Path $result + + & $wrapper -ResultPath $result -Title 'Absolute result path' | Out-Null + + Get-Content -LiteralPath $result -Raw | Should -BeExactly '' + [NTFSSecurityBasicUserProcess]::Calls | Should -Be 1 + [NTFSSecurityBasicUserProcess]::WorkingDirectory | Should -Be $repository + Should -Invoke -CommandName Add-Type -Times 1 -Exactly + } + + It 'Should resolve a relative path against the repository, not the caller location' { + $result = Join-Path -Path $repository -ChildPath 'Relative results\Result.xml' + Assert-TestSandboxPath -Sandbox $sandbox -Path $result + Push-Location -LiteralPath $sandbox + try { + & $wrapper -ResultPath 'Relative results\Result.xml' -Title 'Relative result path' | Out-Null + } + finally { + Pop-Location + } + + Get-Content -LiteralPath $result -Raw | Should -BeExactly '' + [NTFSSecurityBasicUserProcess]::Calls | Should -Be 1 + [NTFSSecurityBasicUserProcess]::WorkingDirectory | Should -Be $repository + } +} From 95b827e911b7c6a2662a2d1c70bf86ec02a4aec2 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Fri, 9 Oct 2026 08:22:22 +0000 Subject: [PATCH 4/8] fix(ci): verify package identity before publication recovery A Gallery upload can succeed while PSResourceGet reports a timeout or a 409 from its retry. Recover that uncertain outcome only after the published SHA512 matches the exact build artifact. Verify the same hash before skipping an existing version, and preserve the original upload error when the version is absent, different, or unverifiable. Keep API keys in the existing environment secret. Unexpected discovery errors fail instead of silently proceeding. Offline tests reproduce legacy false failures and blind skips; all 14 tests pass in each edition and privilege configuration. No real package was published by tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- .github/scripts/Publish-ModulePackage.ps1 | 105 ++++++++++++ .github/workflows/ci.yml | 14 +- Docs/Contributing/05-Releasing.md | 13 +- Tests/Publish-ModulePackage.Tests.ps1 | 188 ++++++++++++++++++++++ 4 files changed, 307 insertions(+), 13 deletions(-) create mode 100644 .github/scripts/Publish-ModulePackage.ps1 create mode 100644 Tests/Publish-ModulePackage.Tests.ps1 diff --git a/.github/scripts/Publish-ModulePackage.ps1 b/.github/scripts/Publish-ModulePackage.ps1 new file mode 100644 index 0000000..ccd614e --- /dev/null +++ b/.github/scripts/Publish-ModulePackage.ps1 @@ -0,0 +1,105 @@ +<# +.SYNOPSIS + Publishes the already built NTFSSecurity package to the PowerShell Gallery. +.DESCRIPTION + Uses the PSGALLERY_API_KEY environment secret. A published version is skipped only when its Gallery SHA512 + matches the exact local package. An uncertain upload is recovered only after that same verification; other + errors remain failures. The release workflow checks the tag and version first. +.PARAMETER NupkgPath + The package that the build job produced. +.PARAMETER Version + The version that the release workflow verified. +.EXAMPLE + .\.github\scripts\Publish-ModulePackage.ps1 -NupkgPath .\out\NTFSSecurity.5.0.0-rc7.nupkg -Version 5.0.0-rc7 + + Publishes the package using the environment secret, without logging or passing the key on a process command line. +#> +[CmdletBinding()] +param ( + [Parameter(Mandatory)] + [ValidateScript({ Test-Path -LiteralPath $_ -PathType Leaf })] + [string] $NupkgPath, + + [Parameter(Mandatory)] + [ValidatePattern('\A\d+\.\d+\.\d+(?:-[A-Za-z][0-9A-Za-z-]*)?\z')] + [string] $Version +) + +$ErrorActionPreference = 'Stop' +if (-not $env:PSGALLERY_API_KEY) { + throw 'The secret PSGALLERY_API_KEY of the environment powershell-gallery is not set.' +} +$packagePath = (Resolve-Path -LiteralPath $NupkgPath).ProviderPath + +function Find-PublishedPackage { + [CmdletBinding()] + [OutputType([psobject])] + param () + + $lookupErrors = @() + $found = @(Find-PSResource -Name NTFSSecurity -Version $Version -Prerelease -Repository PSGallery -ErrorAction SilentlyContinue -ErrorVariable lookupErrors) + foreach ($lookupError in $lookupErrors) { + if (($lookupError.FullyQualifiedErrorId -split ',')[0] -ne 'PackageNotFound') { + throw $lookupError + } + } + if ($found.Count -gt 1) { + throw "The PowerShell Gallery returned more than one package for NTFSSecurity $Version." + } + if ($found.Count -eq 1) { + return $found[0] + } + Write-Verbose "NTFSSecurity $Version is not listed in the PowerShell Gallery." +} + +function Assert-PublishedPackage { + [CmdletBinding()] + param () + + $uri = "https://www.powershellgallery.com/api/v2/Packages(Id='NTFSSecurity',Version='$Version')" + $entry = Invoke-RestMethod -Uri $uri -ErrorAction Stop + $expectedHash = [string] $entry.entry.properties.PackageHash + if ($entry.entry.properties.PackageHashAlgorithm -ne 'SHA512' -or -not $expectedHash) { + throw "The PowerShell Gallery has no usable SHA512 hash for NTFSSecurity $Version." + } + $stream = [IO.File]::OpenRead($packagePath) + $sha512 = [Security.Cryptography.SHA512]::Create() + try { + $actualHash = [Convert]::ToBase64String($sha512.ComputeHash($stream)) + } + finally { + $sha512.Dispose() + $stream.Dispose() + } + if ($actualHash -cne $expectedHash) { + throw "NTFSSecurity $Version in the PowerShell Gallery contains a different package; publication cannot continue." + } +} + +if (Find-PublishedPackage) { + Assert-PublishedPackage + "NTFSSecurity $Version is already in the PowerShell Gallery and matches the exact local package." + return +} + +try { + Publish-PSResource -NupkgPath $packagePath -Repository PSGallery -ApiKey $env:PSGALLERY_API_KEY -ErrorAction Stop +} +catch { + $publishError = $_ + $verified = $false + try { + if (Find-PublishedPackage) { + Assert-PublishedPackage + $verified = $true + } + } + catch { + Write-Warning ("The upload outcome could not be verified for NTFSSecurity {0}: {1}" -f $Version, $_.Exception.Message) + } + if ($verified) { + Write-Warning "Publish-PSResource reported an error, but the Gallery SHA512 verified the exact package for NTFSSecurity $Version." + return + } + throw $publishError +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2c63d5..96511e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -295,8 +295,8 @@ jobs: "notes=$notes" ) - # Publishes the package that the build job built and tested. A rerun skips - # a version that the PowerShell Gallery already has. + # Publish the tested package; recovery and reruns verify the Gallery SHA512 + # so a different package with the same version cannot count as success. - name: Publish to the PowerShell Gallery shell: pwsh env: @@ -304,15 +304,7 @@ jobs: RELEASE_VERSION: ${{ steps.release.outputs.version }} RELEASE_PACKAGE: ${{ steps.release.outputs.package }} run: | - if (-not $env:PSGALLERY_API_KEY) { - throw 'The secret PSGALLERY_API_KEY of the environment powershell-gallery is not set.' - } - $published = Find-PSResource -Name NTFSSecurity -Version $env:RELEASE_VERSION -Prerelease -Repository PSGallery -ErrorAction SilentlyContinue - if ($published) { - "NTFSSecurity $env:RELEASE_VERSION is already in the PowerShell Gallery." - exit 0 - } - Publish-PSResource -NupkgPath $env:RELEASE_PACKAGE -Repository PSGallery -ApiKey $env:PSGALLERY_API_KEY + & ./.github/scripts/Publish-ModulePackage.ps1 -NupkgPath $env:RELEASE_PACKAGE -Version $env:RELEASE_VERSION - name: Create the GitHub release shell: pwsh diff --git a/Docs/Contributing/05-Releasing.md b/Docs/Contributing/05-Releasing.md index 0ed71ba..7ac8365 100644 --- a/Docs/Contributing/05-Releasing.md +++ b/Docs/Contributing/05-Releasing.md @@ -99,11 +99,20 @@ describes, with `-Version` instead of `-ModulePath`. ## If a release fails -The **Release** job skips what's already done: a version that the PowerShell -Gallery already has, and a GitHub release that already exists. If the +The **Release** job skips a version that the PowerShell Gallery already has +only after its published SHA-512 matches the exact package from the build +artifact. It also skips a GitHub release that already exists. If the failure doesn't need a change in the repository, fix the cause and rerun the failed job. +An upload can report an error even after the Gallery accepted it, for +example a timeout followed by HTTP 409 (version already exists). The +publication script checks the Gallery once more and recovers only if the +published SHA-512 verifies the exact local package. A missing version, +unavailable metadata, or a different package remains a failure; an existing +version alone is not proof of success. The API key stays in the +`powershell-gallery` environment secret. + If the fix needs a change in the repository and the PowerShell Gallery doesn't have the version yet, delete the tag, merge the fix, and tag the new commit: diff --git a/Tests/Publish-ModulePackage.Tests.ps1 b/Tests/Publish-ModulePackage.Tests.ps1 new file mode 100644 index 0000000..c91d5dd --- /dev/null +++ b/Tests/Publish-ModulePackage.Tests.ps1 @@ -0,0 +1,188 @@ +<# + Tests Gallery publication recovery offline. Every network and publication command is mocked; the fake API key + exists only in the test process and is restored afterwards. Package hashes come from a sandbox file. +#> +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Pester shares variables between blocks.' +)] +param () + +BeforeAll { + Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force + $sandbox = New-TestSandbox -Name 'PublishPackage' + $package = Join-Path -Path $sandbox -ChildPath 'NTFSSecurity.5.0.0-rc7.nupkg' + Assert-TestSandboxPath -Sandbox $sandbox -Path $package + [IO.File]::WriteAllBytes($package, [Text.Encoding]::UTF8.GetBytes('The package that CI built.')) + $sha512 = [Security.Cryptography.SHA512]::Create() + try { $hash = [Convert]::ToBase64String($sha512.ComputeHash([IO.File]::ReadAllBytes($package))) } + finally { $sha512.Dispose() } + $metadata = [pscustomobject]@{ entry = [pscustomobject]@{ properties = [pscustomobject]@{ + Id = 'NTFSSecurity'; Version = '5.0.0-rc7'; PackageHashAlgorithm = 'SHA512'; PackageHash = $hash + } } } + $published = [pscustomobject]@{ Name = 'NTFSSecurity'; Version = [version]'5.0.0'; Prerelease = 'rc7' } + $scriptPath = Join-Path -Path $PSScriptRoot -ChildPath '..\.github\scripts\Publish-ModulePackage.ps1' + $originalKey = $env:PSGALLERY_API_KEY + + # Stubs keep these tests available in Windows PowerShell, where PSResourceGet might not be installed. + function Find-PSResource { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', '', Justification = 'Command stub supplies parameter metadata for Pester mocks.' + )] + [CmdletBinding()] + param ([string] $Name, [string] $Version, [switch] $Prerelease, [string] $Repository) + throw 'Find-PSResource must be mocked in this test.' + } + function Publish-PSResource { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', '', Justification = 'Command stub supplies parameter metadata for Pester mocks.' + )] + [CmdletBinding()] + param ([string] $NupkgPath, [string] $Repository, [string] $ApiKey) + throw 'Publish-PSResource must be mocked in this test.' + } +} + +AfterAll { + $env:PSGALLERY_API_KEY = $originalKey + Remove-TestSandbox -Sandbox $sandbox +} + +Describe 'Publish-ModulePackage.ps1' { + BeforeEach { + $env:PSGALLERY_API_KEY = 'test-only-api-key' + Mock -CommandName Find-PSResource + Mock -CommandName Publish-PSResource + Mock -CommandName Invoke-RestMethod -MockWith { $metadata } + } + + It 'Should publish a new version using the environment key and the verified package path' { + & $scriptPath -NupkgPath $package -Version '5.0.0-rc7' + + Should -Invoke -CommandName Publish-PSResource -Times 1 -Exactly -ParameterFilter { + $NupkgPath -eq $package -and $Repository -eq 'PSGallery' -and $ApiKey -eq 'test-only-api-key' + } + } + + It 'Should skip publication only after checking the existing package hash' { + Mock -CommandName Find-PSResource -MockWith { $published } + + & $scriptPath -NupkgPath $package -Version '5.0.0-rc7' + + Should -Invoke -CommandName Publish-PSResource -Times 0 -Exactly + Should -Invoke -CommandName Invoke-RestMethod -Times 1 -Exactly -ParameterFilter { + $Uri -eq "https://www.powershellgallery.com/api/v2/Packages(Id='NTFSSecurity',Version='5.0.0-rc7')" + } + } + + It 'Should refuse an existing version containing a different package' { + Mock -CommandName Find-PSResource -MockWith { $published } + Mock -CommandName Invoke-RestMethod -MockWith { + [pscustomobject]@{ entry = [pscustomobject]@{ properties = [pscustomobject]@{ PackageHashAlgorithm = 'SHA512'; PackageHash = 'different-package' } } } + } + + { & $scriptPath -NupkgPath $package -Version '5.0.0-rc7' } | Should -Throw -ExpectedMessage '*different package*' + Should -Invoke -CommandName Publish-PSResource -Times 0 -Exactly + } + + It 'Should refuse metadata without a usable SHA512 package hash: ' -ForEach @( + @{ Case = 'missing hash'; Algorithm = 'SHA512'; PackageHash = '' } + @{ Case = 'wrong algorithm'; Algorithm = 'SHA256'; PackageHash = 'not-sha512' } + ) { + Mock -CommandName Find-PSResource -MockWith { $published } + Mock -CommandName Invoke-RestMethod -MockWith { + [pscustomobject]@{ entry = [pscustomobject]@{ properties = [pscustomobject]@{ PackageHashAlgorithm = $Algorithm; PackageHash = $PackageHash } } } + } + + { & $scriptPath -NupkgPath $package -Version '5.0.0-rc7' } | Should -Throw -ExpectedMessage '*SHA512*' + Should -Invoke -CommandName Publish-PSResource -Times 0 -Exactly + } + + It 'Should recover an uncertain upload only when the exact package appears in the Gallery' { + $script:lookups = 0 + Mock -CommandName Find-PSResource -MockWith { $script:lookups++; if ($script:lookups -gt 1) { $published } } + Mock -CommandName Publish-PSResource -MockWith { throw '409: a package with this version already exists.' } + + & $scriptPath -NupkgPath $package -Version '5.0.0-rc7' -WarningVariable uploadWarnings -WarningAction SilentlyContinue + + Should -Invoke -CommandName Publish-PSResource -Times 1 -Exactly + Should -Invoke -CommandName Find-PSResource -Times 2 -Exactly + Should -Invoke -CommandName Invoke-RestMethod -Times 1 -Exactly + $uploadWarnings | Should -HaveCount 1 + $uploadWarnings[0].Message | Should -BeLike '*verified*exact package*' + } + + It 'Should preserve the upload error when the version remains absent' { + Mock -CommandName Publish-PSResource -MockWith { throw 'Upload failed: the server is unavailable.' } + + { & $scriptPath -NupkgPath $package -Version '5.0.0-rc7' } | Should -Throw -ExpectedMessage '*Upload failed: the server is unavailable*' + Should -Invoke -CommandName Publish-PSResource -Times 1 -Exactly + Should -Invoke -CommandName Find-PSResource -Times 2 -Exactly + } + + It 'Should preserve the upload error when verification finds a different package' { + $script:lookups = 0 + Mock -CommandName Find-PSResource -MockWith { $script:lookups++; if ($script:lookups -gt 1) { $published } } + Mock -CommandName Publish-PSResource -MockWith { throw 'Upload failed: version collision.' } + Mock -CommandName Invoke-RestMethod -MockWith { + [pscustomobject]@{ entry = [pscustomobject]@{ properties = [pscustomobject]@{ PackageHashAlgorithm = 'SHA512'; PackageHash = 'different-package' } } } + } + + { & $scriptPath -NupkgPath $package -Version '5.0.0-rc7' -WarningAction SilentlyContinue } | Should -Throw -ExpectedMessage '*Upload failed: version collision*' + Should -Invoke -CommandName Invoke-RestMethod -Times 1 -Exactly + } + + It 'Should not publish after a lookup fails for a reason other than a missing version' { + Mock -CommandName Find-PSResource -MockWith { Write-Error -Message 'Lookup failed.' -ErrorId RepositoryUnavailable } + + { & $scriptPath -NupkgPath $package -Version '5.0.0-rc7' } | Should -Throw -ExpectedMessage '*Lookup failed*' + Should -Invoke -CommandName Publish-PSResource -Times 0 -Exactly + } + + It 'Should compare Base64 hashes case-sensitively' { + Mock -CommandName Find-PSResource -MockWith { $published } + $differentCase = $hash.ToLowerInvariant() + ($hash -ceq $differentCase) | Should -BeFalse + Mock -CommandName Invoke-RestMethod -MockWith { + [pscustomobject]@{ entry = [pscustomobject]@{ properties = [pscustomobject]@{ PackageHashAlgorithm = 'SHA512'; PackageHash = $differentCase } } } + } + + { & $scriptPath -NupkgPath $package -Version '5.0.0-rc7' } | Should -Throw -ExpectedMessage '*different package*' + Should -Invoke -CommandName Publish-PSResource -Times 0 -Exactly + } + + It 'Should preserve the upload error when post-upload metadata is unavailable' { + $script:lookups = 0 + Mock -CommandName Find-PSResource -MockWith { $script:lookups++; if ($script:lookups -gt 1) { $published } } + Mock -CommandName Publish-PSResource -MockWith { throw 'Upload failed: original error.' } + Mock -CommandName Invoke-RestMethod -MockWith { throw 'Metadata is unavailable.' } + + { & $scriptPath -NupkgPath $package -Version '5.0.0-rc7' -WarningAction SilentlyContinue } | Should -Throw -ExpectedMessage '*Upload failed: original error*' + Should -Invoke -CommandName Invoke-RestMethod -Times 1 -Exactly + } + + It 'Should preserve the upload error when the post-upload lookup fails' { + $script:lookups = 0 + Mock -CommandName Find-PSResource -MockWith { + $script:lookups++ + if ($script:lookups -gt 1) { Write-Error -Message 'Post-upload lookup failed.' -ErrorId RepositoryUnavailable } + } + Mock -CommandName Publish-PSResource -MockWith { throw 'Upload failed: original error.' } + + { & $scriptPath -NupkgPath $package -Version '5.0.0-rc7' -WarningAction SilentlyContinue } | Should -Throw -ExpectedMessage '*Upload failed: original error*' + Should -Invoke -CommandName Find-PSResource -Times 2 -Exactly + } + It 'Should allow the expected PackageNotFound probe result before publishing' { + Mock -CommandName Find-PSResource -MockWith { Write-Error -Message 'Not published yet.' -ErrorId PackageNotFound } + + { & $scriptPath -NupkgPath $package -Version '5.0.0-rc7' } | Should -Not -Throw + Should -Invoke -CommandName Publish-PSResource -Times 1 -Exactly + } + + It 'Should reject a missing API key before contacting the Gallery' { + $env:PSGALLERY_API_KEY = $null + + { & $scriptPath -NupkgPath $package -Version '5.0.0-rc7' } | Should -Throw -ExpectedMessage '*PSGALLERY_API_KEY*not set*' + Should -Invoke -CommandName Find-PSResource -Times 0 -Exactly + Should -Invoke -CommandName Publish-PSResource -Times 0 -Exactly + } +} From d610372e694cf2ba78d77e845820f32c3ceb685c Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Fri, 9 Oct 2026 08:38:32 +0000 Subject: [PATCH 5/8] fix: include the first hidden item in Get-ChildItem2 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- CHANGELOG.md | 3 + Docs/Cmdlets/Get-ChildItem2.md | 2 +- NTFSSecurity/ItemCmdlets/GetChildItem2.cs | 5 +- NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml | 2 +- Tests/ItemCmdlets.Tests.ps1 | 184 +++++++++++++++++++ 5 files changed, 190 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5db9b63..0b9045b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -368,4 +368,7 @@ The format is based on `localhost` in lowercase, such as `.`, `LOCALHOST`, or the computer name, where the computer doesn't offer the remote access check +- Fix [Get-ChildItem2](Docs/Cmdlets/Get-ChildItem2.md) with `-Hidden`, + which omitted the first hidden item unless `-Force` was also supplied + [Unreleased]: https://github.com/raandree/NTFSSecurity/compare/4.2.6...HEAD diff --git a/Docs/Cmdlets/Get-ChildItem2.md b/Docs/Cmdlets/Get-ChildItem2.md index af729af..b2e3519 100644 --- a/Docs/Cmdlets/Get-ChildItem2.md +++ b/Docs/Cmdlets/Get-ChildItem2.md @@ -307,7 +307,7 @@ The `PrivateData` section of the module manifest `NTFSSecurity.psd1` contains tw A folder that cannot be read produces a non-terminating error with the ID `DirUnauthorizedAccessError` for an access denial or `DirUnspecifiedError` for any other failure, and a path that does not exist produces the error `FileNotFound`. In each case the cmdlet continues with the next path. Failures that occur while `-Recurse` collects the subfolders of a folder are reported as verbose messages only, not as errors. -Before 5.0.0, a `-Path` value that points to a file stopped the cmdlet with an `InvalidCastException`, `-Attributes` returned only the items that had all the listed attributes, and an empty `-Attributes` value returned every item, also the hidden ones. +Before 5.0.0, a `-Path` value that points to a file stopped the cmdlet with an `InvalidCastException`, `-Attributes` returned only the items that had all the listed attributes, and an empty `-Attributes` value returned every item, also the hidden ones. Earlier builds, including the 5.0.0 prereleases, could also omit the first hidden item with `-Hidden` unless `-Force` was explicitly supplied. ## RELATED LINKS diff --git a/NTFSSecurity/ItemCmdlets/GetChildItem2.cs b/NTFSSecurity/ItemCmdlets/GetChildItem2.cs index bb908de..49d915a 100644 --- a/NTFSSecurity/ItemCmdlets/GetChildItem2.cs +++ b/NTFSSecurity/ItemCmdlets/GetChildItem2.cs @@ -279,7 +279,7 @@ namespace NTFSSecurity continue; } - var writeItem = force.ToBool(); + var writeItem = force.ToBool() || hidden.ToBool(); if (MyInvocation.BoundParameters.ContainsKey("Attributes")) { @@ -291,9 +291,6 @@ namespace NTFSSecurity } else { - if (hidden) - force = true; - if ((current.Attributes & global::System.IO.FileAttributes.Hidden) != global::System.IO.FileAttributes.Hidden) writeItem = true; diff --git a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml index fe60c28..251b553 100644 --- a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml +++ b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml @@ -3865,7 +3865,7 @@ PS C:\> Disable-Privileges The default table view shows the `Mode`, `Inherits`, `LastWriteTime`, `Size(M)`, and `Name` columns. `Inherits` is `False` for an item whose access inheritance is disabled. Reading that value costs one access to the ACL of each displayed item, which slows down the display of large listings; to avoid it, select the properties you need, for example with `Format-Table -Property Mode, LastWriteTime, Length, Name`. Objects that you pipe to another command are not affected. Before 5.0.0, the column showed `True` for every item. The `PrivateData` section of the module manifest `NTFSSecurity.psd1` contains two settings that this cmdlet reads when it starts. `GetFileSystemModeProperty` adds the calculated `Mode` property to every item. `IdentifyHardLinks` adds the `HardLinkCount` property to every file, which requires an extra call into the file system for each file and therefore slows down large listings noticeably. Set either value to `$false` in the manifest and import the module again if you prefer the faster enumeration over the additional properties. A folder that cannot be read produces a non-terminating error with the ID `DirUnauthorizedAccessError` for an access denial or `DirUnspecifiedError` for any other failure, and a path that does not exist produces the error `FileNotFound`. In each case the cmdlet continues with the next path. Failures that occur while `-Recurse` collects the subfolders of a folder are reported as verbose messages only, not as errors. - Before 5.0.0, a `-Path` value that points to a file stopped the cmdlet with an `InvalidCastException`, `-Attributes` returned only the items that had all the listed attributes, and an empty `-Attributes` value returned every item, also the hidden ones. + Before 5.0.0, a `-Path` value that points to a file stopped the cmdlet with an `InvalidCastException`, `-Attributes` returned only the items that had all the listed attributes, and an empty `-Attributes` value returned every item, also the hidden ones. Earlier builds, including the 5.0.0 prereleases, could also omit the first hidden item with `-Hidden` unless `-Force` was explicitly supplied. diff --git a/Tests/ItemCmdlets.Tests.ps1 b/Tests/ItemCmdlets.Tests.ps1 index 760d83b..3b565f2 100644 --- a/Tests/ItemCmdlets.Tests.ps1 +++ b/Tests/ItemCmdlets.Tests.ps1 @@ -11,6 +11,7 @@ BeforeDiscovery { # A path on the administrative share of the drive of the sandboxes is another volume for Windows, like a share of a # file server. $canUseAdminShare = Test-AdminShareAvailable + $canCreateSymbolicLinks = Test-PrivilegeHeld -Name 'SeCreateSymbolicLinkPrivilege' } BeforeAll { @@ -62,6 +63,189 @@ Describe 'Get-ChildItem2' { } } + Context 'Attribute switches' { + BeforeAll { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'Switches' -Directory + $attributeCases = @{ + 'Plain.txt' = 'Normal' + 'Hidden.txt' = 'Hidden' + 'System.txt' = 'System' + 'ReadOnly.txt' = 'ReadOnly' + 'HiddenSystem.txt' = 'Hidden, System' + 'HiddenReadOnly.txt' = 'Hidden, ReadOnly' + 'All.txt' = 'Hidden, ReadOnly, System' + } + foreach ($name in $attributeCases.Keys) { + $path = Join-Path -Path $folder -ChildPath $name + Assert-TestSandboxPath -Sandbox $sandbox -Path $path + Set-Content -LiteralPath $path -Value $name + [IO.File]::SetAttributes($path, [IO.FileAttributes] $attributeCases[$name]) + } + } + + It 'Should apply without broadening the other attribute filters' -ForEach @( + @{ Case = 'default'; Parameters = @{}; Expected = @('Plain.txt', 'System.txt', 'ReadOnly.txt') } + @{ Case = 'Force'; Parameters = @{ Force = $true }; Expected = @('Plain.txt', 'Hidden.txt', 'System.txt', 'ReadOnly.txt', 'HiddenSystem.txt', 'HiddenReadOnly.txt', 'All.txt') } + @{ Case = 'Hidden'; Parameters = @{ Hidden = $true }; Expected = @('Hidden.txt', 'HiddenSystem.txt', 'HiddenReadOnly.txt', 'All.txt') } + @{ Case = 'System'; Parameters = @{ System = $true }; Expected = @('System.txt') } + @{ Case = 'System with Force'; Parameters = @{ System = $true; Force = $true }; Expected = @('System.txt', 'HiddenSystem.txt', 'All.txt') } + @{ Case = 'ReadOnly'; Parameters = @{ ReadOnly = $true }; Expected = @('ReadOnly.txt') } + @{ Case = 'ReadOnly with Force'; Parameters = @{ ReadOnly = $true; Force = $true }; Expected = @('ReadOnly.txt', 'HiddenReadOnly.txt', 'All.txt') } + @{ Case = 'Hidden and System'; Parameters = @{ Hidden = $true; System = $true }; Expected = @('HiddenSystem.txt', 'All.txt') } + @{ Case = 'Hidden and ReadOnly'; Parameters = @{ Hidden = $true; ReadOnly = $true }; Expected = @('HiddenReadOnly.txt', 'All.txt') } + @{ Case = 'System and ReadOnly with Force'; Parameters = @{ System = $true; ReadOnly = $true; Force = $true }; Expected = @('All.txt') } + ) { + $result = @(Get-ChildItem2 -Path $folder @Parameters -ErrorAction Stop) + + ($result.Name | Sort-Object) -join ',' | Should -Be (($Expected | Sort-Object) -join ',') + } + } + + It 'Should include the first hidden item without requiring explicit -Force' { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'FirstHidden' -Directory + $file = Join-Path -Path $folder -ChildPath 'Only.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $file + Set-Content -LiteralPath $file -Value 'Hidden' + [IO.File]::SetAttributes($file, [IO.FileAttributes]::Hidden) + + $result = @(Get-ChildItem2 -Path $folder -Hidden -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0].FullName | Should -Be $file + } + + Context 'Recursion, type filters, and depth' { + BeforeAll { + $tree = New-TestSandboxItem -Sandbox $sandbox -Name 'EnumerationTree' -Directory + $grandchild = Join-Path -Path $tree -ChildPath 'Child\Grandchild' + $paths = @('Root.txt', 'Child\Child.log', 'Child\Grandchild\Grand.TXT') | ForEach-Object { Join-Path -Path $tree -ChildPath $_ } + Assert-TestSandboxPath -Sandbox $sandbox -Path (@($grandchild) + @($paths)) + New-Item -ItemType Directory -Path $grandchild -Force | Out-Null + foreach ($path in $paths) { Set-Content -LiteralPath $path -Value 'Tree' } + } + + It 'Should return the exact tree for ' -ForEach @( + @{ Case = 'immediate children'; Parameters = @{}; Expected = @('Child', 'Root.txt') } + @{ Case = 'all descendants'; Parameters = @{ Recurse = $true }; Expected = @('Child', 'Root.txt', 'Child\Grandchild', 'Child\Child.log', 'Child\Grandchild\Grand.TXT') } + @{ Case = 'depth zero'; Parameters = @{ Recurse = $true; Depth = 0 }; Expected = @('Child', 'Root.txt') } + @{ Case = 'depth one'; Parameters = @{ Recurse = $true; Depth = 1 }; Expected = @('Child', 'Root.txt', 'Child\Grandchild', 'Child\Child.log') } + @{ Case = 'depth two'; Parameters = @{ Recurse = $true; Depth = 2 }; Expected = @('Child', 'Root.txt', 'Child\Grandchild', 'Child\Child.log', 'Child\Grandchild\Grand.TXT') } + @{ Case = 'directories'; Parameters = @{ Recurse = $true; Directory = $true }; Expected = @('Child', 'Child\Grandchild') } + @{ Case = 'files'; Parameters = @{ Recurse = $true; File = $true }; Expected = @('Root.txt', 'Child\Child.log', 'Child\Grandchild\Grand.TXT') } + @{ Case = 'case-insensitive file filter'; Parameters = @{ Recurse = $true; File = $true; Filter = '*.txt' }; Expected = @('Root.txt', 'Child\Grandchild\Grand.TXT') } + ) { + $result = @(Get-ChildItem2 -Path $tree @Parameters -ErrorAction Stop) + $relative = @($result | ForEach-Object { $_.FullName.Substring($tree.Length + 1) }) + + ($relative | Sort-Object) -join ',' | Should -Be (($Expected | Sort-Object) -join ',') + } + + It 'Should stop a recursive pipeline without recording an enumeration error' { + $result = @(Get-ChildItem2 -Path $tree -Recurse -ErrorVariable childErrors -ErrorAction SilentlyContinue | Select-Object -First 1) + + $result | Should -HaveCount 1 + $childErrors | Should -BeNullOrEmpty + } + } + + Context 'Unreadable directories' { + It 'Should report the denied folder and continue with the next path' { + $blocked = New-TestSandboxItem -Sandbox $sandbox -Name 'CannotList' -Directory + $next = New-TestSandboxItem -Sandbox $sandbox -Name 'CanList' -Directory + $file = Join-Path -Path $next -ChildPath 'Next.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $file + Set-Content -LiteralPath $file -Value 'Next' + Add-TestDenyRule -Sandbox $sandbox -Path $blocked -Rights @{ 'S-1-1-0' = 'ReadData' } + + $result = @(Get-ChildItem2 -Path $blocked, $next -ErrorVariable childErrors -ErrorAction SilentlyContinue) + + $childErrors | Should -HaveCount 1 + $childErrors[0].FullyQualifiedErrorId | Should -BeLike 'DirUnauthorizedAccessError,*' + $childErrors[0].CategoryInfo.Category | Should -Be 'PermissionDenied' + $childErrors[0].TargetObject | Should -Be $blocked + $result | Should -HaveCount 1 + $result[0].FullName | Should -Be $file + } + } + + Context 'Link traversal' { + It 'Should return a junction itself but skip its contents with -SkipMountPoints' { + $root = New-TestSandboxItem -Sandbox $sandbox -Name 'JunctionListing' -Directory + $target = New-TestSandboxItem -Sandbox $sandbox -Name 'JunctionTarget' -Directory + $file = Join-Path -Path $target -ChildPath 'Target.txt' + $link = Join-Path -Path $root -ChildPath 'Link' + Assert-TestSandboxPath -Sandbox $sandbox -Path $file, $link + Set-Content -LiteralPath $file -Value 'Target' + New-Item -ItemType Junction -Path $link -Value $target | Out-Null + + $result = @(Get-ChildItem2 -Path $root -Recurse -SkipMountPoints -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0].FullName | Should -Be $link + Get-Content -LiteralPath $file | Should -Be 'Target' + } + + It 'Should return a symbolic link itself but skip its contents with -SkipSymbolicLinks' -Skip:(-not $canCreateSymbolicLinks) { + $root = New-TestSandboxItem -Sandbox $sandbox -Name 'SymbolicListing' -Directory + $target = New-TestSandboxItem -Sandbox $sandbox -Name 'SymbolicTarget' -Directory + $file = Join-Path -Path $target -ChildPath 'Target.txt' + $link = Join-Path -Path $root -ChildPath 'Link' + Assert-TestSandboxPath -Sandbox $sandbox -Path $file, $link + Set-Content -LiteralPath $file -Value 'Target' + New-NTFSSymbolicLink -Path $link -Target $target -ErrorAction Stop + + $result = @(Get-ChildItem2 -Path $root -Recurse -SkipSymbolicLinks -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0].FullName | Should -Be $link + Get-Content -LiteralPath $file | Should -Be 'Target' + } + } + + Context 'Optional object properties' { + BeforeEach { + $settings = (Get-Module -Name NTFSSecurity).PrivateData + $savedMode = $settings['GetFileSystemModeProperty'] + $savedHardLinks = $settings['IdentifyHardLinks'] + } + + AfterEach { + $settings['GetFileSystemModeProperty'] = $savedMode + $settings['IdentifyHardLinks'] = $savedHardLinks + } + + It 'Should honor Mode and HardLinkCount enabled=<_>' -ForEach @($true, $false) { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'ObjectProperties' -Directory + $file = Join-Path -Path $folder -ChildPath 'File.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $file + Set-Content -LiteralPath $file -Value 'Properties' + [IO.File]::SetAttributes($file, [IO.FileAttributes]::Archive) + $settings['GetFileSystemModeProperty'] = $_ + $settings['IdentifyHardLinks'] = $_ + + $item = Get-ChildItem2 -Path $file -ErrorAction Stop + + if ($_) { + $item.Mode | Should -BeExactly '-a---' + $item.HardLinkCount | Should -Be 1 + } + else { + $item.PSObject.Properties['Mode'] | Should -BeNullOrEmpty + $item.PSObject.Properties['HardLinkCount'] | Should -BeNullOrEmpty + } + } + + It 'Should render read-only, hidden, and system bits in the Mode property' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'ModeBits' + [IO.File]::SetAttributes($file, [IO.FileAttributes] 'ReadOnly, Hidden, System') + $settings['GetFileSystemModeProperty'] = $true + + $item = Get-ChildItem2 -Path $file -Force -ErrorAction Stop + + $item.Mode | Should -BeExactly '--rhs' + } + } + Context 'Default table view' { BeforeAll { $viewFolder = New-TestSandboxItem -Sandbox $sandbox -Name 'View' -Directory From 344219441b4c50dc91e2ace0634cf636b800f2c7 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Fri, 9 Oct 2026 09:01:50 +0000 Subject: [PATCH 6/8] test: guard forced moves and descriptor write failures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- Tests/ItemCmdlets.Tests.ps1 | 19 +++++++++++ Tests/SecurityDescriptor.Tests.ps1 | 51 ++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/Tests/ItemCmdlets.Tests.ps1 b/Tests/ItemCmdlets.Tests.ps1 index 3b565f2..d21eea2 100644 --- a/Tests/ItemCmdlets.Tests.ps1 +++ b/Tests/ItemCmdlets.Tests.ps1 @@ -425,6 +425,25 @@ Describe 'Copy-Item2, Move-Item2, and Remove-Item2 with several paths' { Join-Path -Path $sourceFolder -ChildPath 'A.txt' | Should -Exist } + It 'Move-Item2 -Force should replace an existing file with PassThru=<_>' -ForEach @($false, $true) { + $target = Join-Path -Path $destination -ChildPath 'First.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $target + Set-Content -LiteralPath $target -Value 'Previous' + $expected = Get-Content -LiteralPath $first -Raw + + $result = @(Move-Item2 -Path $first -Destination $destination -Force -PassThru $_ -ErrorAction Stop) + + $first | Should -Not -Exist + Get-Content -LiteralPath $target -Raw | Should -BeExactly $expected + if ($_) { + $result | Should -HaveCount 1 + $result[0].FullName | Should -Be $target + } + else { + $result | Should -BeNullOrEmpty + } + } + It 'Copy-Item2 -Force should copy a folder into an existing folder of the same name and replace the files in both' { $sourceFolder = Join-Path -Path $folder -ChildPath 'Merge' $existingFolder = Join-Path -Path $destination -ChildPath 'Merge' diff --git a/Tests/SecurityDescriptor.Tests.ps1 b/Tests/SecurityDescriptor.Tests.ps1 index 6be3eeb..d60cb25 100644 --- a/Tests/SecurityDescriptor.Tests.ps1 +++ b/Tests/SecurityDescriptor.Tests.ps1 @@ -227,6 +227,57 @@ Describe 'Set-NTFSSecurityDescriptor' { } } + Context 'A descriptor that cannot be written' { + BeforeEach { + $savedPrivileges = $privateData['EnablePrivileges'] + $privateData['EnablePrivileges'] = $false + } + + AfterEach { + $privateData['EnablePrivileges'] = $savedPrivileges + } + + It 'Should report a denied write, return no failed item, and write the next descriptor' { + $blocked = New-TestSandboxItem -Sandbox $sandbox -Name 'DescriptorWriteDenied' + $next = New-TestSandboxItem -Sandbox $sandbox -Name 'DescriptorWriteNext' + Block-TestWritePermission -Sandbox $sandbox -Path $blocked + $before = (Get-Acl -LiteralPath $blocked).Sddl + $descriptors = @(Get-NTFSSecurityDescriptor -Path $blocked, $next) + $descriptors | Should -HaveCount 2 + Add-NTFSAccess -SecurityDescriptor $descriptors -Account 'S-1-1-0' -AccessRights ReadData -ErrorAction Stop + + $result = @($descriptors | Set-NTFSSecurityDescriptor -PassThru -ErrorVariable setErrors -ErrorAction SilentlyContinue) + + $setErrors | Should -HaveCount 1 + $setErrors[0].FullyQualifiedErrorId | Should -BeLike 'WriteSdError,*' + $setErrors[0].CategoryInfo.Category | Should -Be 'WriteError' + $setErrors[0].TargetObject.FullName | Should -Be $blocked + (Get-Acl -LiteralPath $blocked).Sddl | Should -BeExactly $before + $result | Should -HaveCount 1 + $result[0].FullName | Should -Be $next + @(Get-EveryoneRule -Path $next) | Should -HaveCount 1 + } + + It 'Should report a deleted target and still write the next descriptor' { + $deleted = New-TestSandboxItem -Sandbox $sandbox -Name 'DescriptorDeleted' + $next = New-TestSandboxItem -Sandbox $sandbox -Name 'DescriptorAfterDeleted' + $descriptors = @(Get-NTFSSecurityDescriptor -Path $deleted, $next) + Add-NTFSAccess -SecurityDescriptor $descriptors -Account 'S-1-1-0' -AccessRights ReadData -ErrorAction Stop + Assert-TestSandboxPath -Sandbox $sandbox -Path $deleted + Remove-Item -LiteralPath $deleted + + $result = @(Set-NTFSSecurityDescriptor -SecurityDescriptor $descriptors -PassThru -ErrorVariable setErrors -ErrorAction SilentlyContinue) + + $setErrors | Should -HaveCount 1 + $setErrors[0].FullyQualifiedErrorId | Should -BeLike 'WriteSdError,*' + $setErrors[0].TargetObject.FullName | Should -Be $deleted + $deleted | Should -Not -Exist + $result | Should -HaveCount 1 + $result[0].FullName | Should -Be $next + @(Get-EveryoneRule -Path $next) | Should -HaveCount 1 + } + } + Context 'When the written descriptor denies reading it again' { BeforeAll { $privateData['EnablePrivileges'] = $false From 7594e0ca62447313dba23cf1e3840464cc5196a5 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Fri, 9 Oct 2026 10:05:24 +0000 Subject: [PATCH 7/8] test(lab): guard first-hidden-item enumeration over SMB Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- .../Lab/Acceptance-2026-10-09-quality-gate.md | 156 ++++++++++++++++++ Tests/Lab/Invoke-NTFSSecurityLabTest.ps1 | 5 + Tests/Lab/NTFSSecurity.Live.Tests.ps1 | 19 +++ Tests/Lab/README.md | 5 +- 4 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 Tests/Lab/Acceptance-2026-10-09-quality-gate.md diff --git a/Tests/Lab/Acceptance-2026-10-09-quality-gate.md b/Tests/Lab/Acceptance-2026-10-09-quality-gate.md new file mode 100644 index 0000000..29b7ba5 --- /dev/null +++ b/Tests/Lab/Acceptance-2026-10-09-quality-gate.md @@ -0,0 +1,156 @@ +# Quality-gate follow-up acceptance, 2026-10-09 + +Further validation of NTFSSecurity before 5.0.0, on +`ai/quality-gate-coverage`, based on rc7 PR #116 (`d25647d`). This is a +local candidate, not a published release or a claim that the quality gate +is complete. Architecture and cmdlet-design choices remain with the +maintainer (Decisions 16, 21, and 22). + +## Candidate and artifact identity + +- Module code/test baseline: `3442194`; first-hidden-item fix: `d610372`. +- Build: Release, .NET Framework 4.5.2; manifest label `5.0.0-rc7`. + The label has not been advanced or published by this work. +- Packages produced by `.github/scripts/New-ModulePackage.ps1`. + All 11 files in the live-tested packaged module folder match the + extracted `NTFSSecurity.zip` byte-for-byte by SHA-256. +- Package SHA-256 values: + +| Artifact | SHA-256 | +| --- | --- | +| `NTFSSecurity.5.0.0-rc7.nupkg` | `E76B80CA8CBCD4E46EB5B4C61E53BD0BFCB461881593753A69F7F90385533768` | +| `NTFSSecurity.zip` | `ACA302594BF0B84EAF6F4E45476DC4AA096F5F9105E51B1F255FB061D57E99EC` | +| `NTFSSecurity.dll` | `4587F1B2FCF2683B02D1895CEE17D0ABF4060DA758264E09AEC0CF62536E7CAC` | + +## Local validation + +Final uninstrumented suite, 09:31 to 09:34 UTC; separate processes with the +real CI elevated/basic-user wrappers. Every configuration discovered 914 +cases (202 above rc7); no test failed. Every skipped test template has an +executed counterpart in the four-run matrix. NUnit skipped data names were +normalized, not compared positionally or as literal expanded names. + +| Configuration | Passed | Failed | Skipped | Total | +| --- | ---: | ---: | ---: | ---: | +| Windows PowerShell 5.1, elevated | 890 | 0 | 24 | 914 | +| Windows PowerShell 5.1, basic user | 749 | 0 | 165 | 914 | +| PowerShell 7, elevated | 860 | 0 | 54 | 914 | +| PowerShell 7, basic user | 719 | 0 | 195 | 914 | + +AST parse and PSScriptAnalyzer: zero issues in all 13 changed PowerShell +files. Release build, actionlint, Markdown lint, help generation/round trip, +and relative documentation links passed. Existing compiler warnings were +retained, not suppressed to obtain a green result. + +New guards cover folder deletion, read-only/locked/junction/long-path +cases, owner restoration/retry failures, all 13 permission scopes in both +forms and storage modes, descendant propagation, file/folder inheritance, +enumeration/filter/depth/link skipping, forced replacement, and descriptor +write failure/continuation. Controlled mutations made the relevant tests +fail; source was restored exactly and Release rebuilt before validation. + +Reproduced product defect: `Get-ChildItem2 -Hidden` omitted the first +hidden item because implied Force was set after deciding whether to emit +it. The regression failed before the fix in all four configurations. +CI result paths were also fixed test-first. Publication recovery has 14 +offline tests; no real upload occurred. + +## Coverage method and remaining inventory + +AltCover 9.0.145 OpenCover report of frozen `3442194`, 09:24 to 09:28 UTC: +all four configurations sequentially, no `--save`, copied Release PDBs, +AlphaFS and System.Management.Automation excluded. Percentages are visited +sequence/branch points divided by their respective totals, not unique +source-line coverage. + +| Assembly | Sequence points | Sequence coverage | Branch points | Branch coverage | +| --- | ---: | ---: | ---: | ---: | +| NTFSSecurity | 1,769/2,099 | 84.28% | 605/1,051 | 57.56% | +| Security2 | 747/1,219 | 61.28% | 330/740 | 44.59% | +| ProcessPrivileges | 113/219 | 51.60% | 35/125 | 28.00% | +| PrivilegeControl | 12/22 | 54.55% | 4/17 | 23.53% | +| Aggregate | 2,641/3,559 | 74.21% | 974/1,933 | 50.39% | + +rc6 aggregate was 68.14% sequence/44.32% branch coverage. Its production +code differs, so the denominators differ. The 918 unvisited points remain +visible: 244 in classes unused by cmdlets, 112 parameter-getter points, +and 562 for finer classification/testing (unused overloads, defensive, +environment-specific, and reachable paths). This is not "everything tested +or explained" yet. For example, code intelligence finds only the definition +of `MapGenericRightsToFileSystemRights`, not a caller; do not test/remove an +unused helper merely to improve a percentage. + +## Lab and rollback evidence + +`WindowsAccessControlLab`: F1ADC1, F1BDC1, F2DC1, F3DC1, F1AFile1 (client), +and F1AFile2 (file server), all Windows Server 2025. Before the run, +authenticated WinRM, LDAP RootDSE, Kerberos tickets, member secure channels, +and clocks passed. No VM topology or operating system was changed. + +Six checkpoints named `ntfs-qg-3442194-before-acceptance` exist. Hyper-V +reports them as Standard. A temporary ProductionOnly request on F1AFile1 +also succeeded but reported Standard; its original policy was restored. +Production classification remains unverified. No checkpoint was restored; +these are not verified Production rollback evidence. + +## Live results + +Controller ran from 09:20 to 09:51 UTC against hash-checked published rc6 +and the candidate, each version/edition in new processes. The new fixture +contains exactly one hidden file: the Delegate lists it over SMB with +`-Hidden` alone, and the Server verifies its content/attribute independently. + +| Version | Edition | Role | Passed | Failed | Skipped | +| --- | --- | --- | ---: | ---: | ---: | +| Candidate | Desktop | Delegate | 39 | 0 | 0 | +| Candidate | Desktop | ServerAdmin | 13 | 0 | 0 | +| Candidate | Desktop | Admin | 40 | 0 | 0 | +| Candidate | Desktop | Server | 73 | 0 | 1 | +| Candidate | Core | Delegate | 39 | 0 | 0 | +| Candidate | Core | ServerAdmin | 13 | 0 | 0 | +| Candidate | Core | Admin | 40 | 0 | 0 | +| Candidate | Core | Server | 73 | 0 | 1 | +| Published rc6 | Each edition | Delegate | 38 | 1 | 0 | +| Published rc6 | Each edition | ServerAdmin | 13 | 0 | 0 | +| Published rc6 | Each edition | Admin | 39 | 1 | 0 | +| Published rc6 | Each edition | Server | 73 | 0 | 1 | + +Candidate aggregate: 330 passed, zero failed, two expected skips (Server +does not import the module). rc6 aggregate: 326 passed, four expected +failures, two skips. The only rc6 failures are Hidden and the already-known +rc7 effective-access warning-text expectation, once each per edition. + +The temporary host verifier initially failed because Desktop wrapped the +JSON array and failure names included Describe prefixes. A corrected +verifier flattened the array, checked all 16 unique version/edition/role +results and exact failure names, and passed in both editions on the +unchanged results. Original raw logs/exit markers were preserved. + +## Cleanup and review + +RemoveFixture ran at 09:57 UTC. An overly broad host Error.Count check gave +its wrapper a failing marker despite the controller completing. Independent +read-only probes verified on all six machines: zero test OUs/users/groups, +no share or fixture folders, no test local-group memberships, no test +profiles. Cleanup is proved by end state, not that wrapper marker. + +One independent read-only code review approved the diff with high +confidence and no significant issues or confirmed exploitable vulnerability. +The custom security-reviewer could not start because its configured model +was unavailable; the built-in code-review agent performed the one review. +No source changed after that pass; result-verifier repairs were temporary +host tooling only. + +## Evidence and remaining release gates + +Raw coverage XML, eligibility/inventory CSVs, final suite XML/logs, +mutation logs, module/package hashes, full live role results, original host +logs, corrected verification, and independent cleanup evidence are retained +in the session artifact `quality-gate-3442194-20261009`. + +Remaining: finer uncovered-path inventory, Decision 22 review, integration +and CI of this branch, publication and published-package acceptance, wider +OS matrix, and non-Windows #34 feedback. All 13 deployed lab VMs remain +Server 2025. Windows 11/Server 2019/2022 ISO media exists, but detected +editions/OS cache and matrix scope are not yet verified. #34 has no reply +since 2026-10-06. Do not release stable 5.0.0 on the strength of this record. diff --git a/Tests/Lab/Invoke-NTFSSecurityLabTest.ps1 b/Tests/Lab/Invoke-NTFSSecurityLabTest.ps1 index e5a0eae..5ba6514 100644 --- a/Tests/Lab/Invoke-NTFSSecurityLabTest.ps1 +++ b/Tests/Lab/Invoke-NTFSSecurityLabTest.ps1 @@ -698,6 +698,11 @@ $fixtureScript = { $null = New-Item -ItemType Directory -Path (Join-Path -Path $itemsPath -ChildPath 'Folder') Set-Content -LiteralPath (Join-Path -Path $itemsPath -ChildPath 'Folder\File.txt') -Value 'File' -NoNewline + $hiddenPath = New-FixtureFolder -RelativePath 'Case7\Hidden' -AccessRule $delegatesFullControl + $hiddenFile = Join-Path -Path $hiddenPath -ChildPath 'Only.txt' + Set-Content -LiteralPath $hiddenFile -Value 'Hidden' -NoNewline + [System.IO.File]::SetAttributes($hiddenFile, [System.IO.FileAttributes]::Hidden) + # Case 8: the link cmdlets. $linksPath = New-FixtureFolder -RelativePath 'Case8\Links' -AccessRule $delegatesFullControl Set-Content -LiteralPath (Join-Path -Path $linksPath -ChildPath 'Target.txt') -Value 'Target' -NoNewline diff --git a/Tests/Lab/NTFSSecurity.Live.Tests.ps1 b/Tests/Lab/NTFSSecurity.Live.Tests.ps1 index 5459f9f..8ba8939 100644 --- a/Tests/Lab/NTFSSecurity.Live.Tests.ps1 +++ b/Tests/Lab/NTFSSecurity.Live.Tests.ps1 @@ -706,6 +706,18 @@ Describe 'Item cmdlets on a share folder' -Tag 'Delegate' -Skip:(-not $configure Test-Path -LiteralPath $removing | Should -BeFalse } + It 'Get-ChildItem2 -Hidden should include the first hidden file without explicit -Force over SMB' { + $hiddenFolder = Get-LabPath -RelativePath 'Case7\Hidden' + $hiddenFile = Join-Path -Path $hiddenFolder -ChildPath 'Only.txt' + + $result = @(Get-ChildItem2 -Path $hiddenFolder -Hidden -ErrorVariable operationErrors -ErrorAction SilentlyContinue) + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + $result | Should -HaveCount 1 + $result[0].FullName | Should -Be $hiddenFile + [System.IO.File]::GetAttributes($hiddenFile).HasFlag([System.IO.FileAttributes]::Hidden) | Should -BeTrue + } + It 'Get-ChildItem2 should list the file and the folder that the tests leave in place' { $names = @(Get-ChildItem2 -Path $folder -ErrorVariable operationErrors -ErrorAction SilentlyContinue).Name @@ -871,6 +883,13 @@ Describe 'Security descriptors on the file server after the runs on the client' } } + It 'Should retain the hidden file and its attribute after the client listing' { + $hiddenFile = Get-LabPath -RelativePath 'Case7\Hidden\Only.txt' + + Get-Content -LiteralPath $hiddenFile -Raw | Should -BeExactly 'Hidden' + [System.IO.File]::GetAttributes($hiddenFile).HasFlag([System.IO.FileAttributes]::Hidden) | Should -BeTrue + } + It 'Should have the links that the link cmdlets created' { $folder = Get-LabPath -RelativePath 'Case8\Links' diff --git a/Tests/Lab/README.md b/Tests/Lab/README.md index de8fbb2..c38805d 100644 --- a/Tests/Lab/README.md +++ b/Tests/Lab/README.md @@ -17,7 +17,7 @@ without a lab they skip every test. | 4 | Admin | `Get-NTFSOrphanedAccess` returns the entry of a deleted domain account with its SID, on the folder and as inherited entry on a file in it; `Get-NTFSOrphanedAudit` returns the audit entry of that account. | | 5 | Admin, ServerAdmin, Delegate | `Get-NTFSOwner` and `Set-NTFSOwner` on share folders that Administrators own. Every role makes itself the owner; only the administrators of the file server, which hold the Restore privilege there, assign another account. The delegated account gets a `SetOwnerError`, and the owner stays. | | 6 | Admin, ServerAdmin, Delegate | `Disable-NTFSAuditInheritance`, `Enable-NTFSAuditInheritance`, `Clear-NTFSAudit`, and `Get-NTFSInheritance` on share folders that inherit an audit entry. The administrators of the file server change the audit entries; the delegated account gets the errors that the cmdlet pages describe, and `Get-NTFSInheritance` reports no audit state for it. | -| 7 | Delegate | `Get-Item2`, `Test-Path2`, `Get-FileHash2`, `Copy-Item2`, `Move-Item2`, `Remove-Item2`, and `Get-ChildItem2` in a share folder. | +| 7 | Delegate | `Get-Item2`, `Test-Path2`, `Get-FileHash2`, `Copy-Item2`, `Move-Item2`, `Remove-Item2`, and `Get-ChildItem2` in a share folder, including the first hidden file with `-Hidden` and without explicit `-Force`. | | 8 | Admin | `New-NTFSHardLink`, `Get-NTFSHardLink`, and `New-NTFSSymbolicLink` in a share folder. Windows can't list the names of a file on a share, so `Get-NTFSHardLink` and `New-NTFSHardLink -PassThru` write the `GetHardLinkError` that their pages describe. | | 9 | Delegate, Admin | `Get-NTFSSimpleAccess` compares a share folder with its parent. For the accounts of another domain and of other forests, `Get-NTFSAccess` returns their names, `Get-NTFSOrphanedAccess` doesn't report them, `Add-NTFSAccess` and `Remove-NTFSAccess` find them by name, and `Get-NTFSEffectiveAccess -ServerName` returns the rights that the file server's own token of each account gets. | | Long paths | Admin | `Get-ChildItem2` and `Get-NTFSAccess` with a share path longer than 260 characters. | @@ -142,7 +142,8 @@ and record the evidence in this folder: share, folders, group memberships, and profiles are gone. Records: [5.0.0-rc6](Acceptance-2026-10-08-5.0.0-rc6.md), -[5.0.0-rc7](Acceptance-2026-10-08-5.0.0-rc7.md). +[5.0.0-rc7](Acceptance-2026-10-08-5.0.0-rc7.md), and +[quality-gate follow-up](Acceptance-2026-10-09-quality-gate.md). ## Files From f11ff412947b35d682878ac4a8121c949868fcb2 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Fri, 9 Oct 2026 10:09:33 +0000 Subject: [PATCH 8/8] chore(memory-bank): record quality-gate evidence and release status Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- .memory-bank/activeContext.md | 103 +++++---- .memory-bank/progress.md | 288 +++++++++---------------- .memory-bank/systemPatterns.md | 148 +++++-------- .memory-bank/techContext.md | 375 ++++++++++++--------------------- 4 files changed, 341 insertions(+), 573 deletions(-) diff --git a/.memory-bank/activeContext.md b/.memory-bank/activeContext.md index 2ad5297..52c5dab 100644 --- a/.memory-bank/activeContext.md +++ b/.memory-bank/activeContext.md @@ -1,6 +1,6 @@ --- status: current -last-verified: 2026-10-08 +last-verified: 2026-10-09 owner: active-agent source: current task evidence --- @@ -9,62 +9,57 @@ source: current task evidence ## Current focus -5.0.0-rc6 is on the PowerShell Gallery (tag `5.0.0-rc6` on `b51d970`, the -merge of #115); its GitHub release waits for a rerun of the failed Release -job. #116 (`ai/release-5.0.0-rc7`, base `master`) holds the behavior -changes that Phase 2 found, decided as assumptions for the maintainer's -review (Decision 22), and waits for that review. Then 5.0.0-rc7, Phase 3, -and 5.0.0; after 5.0.0 the repository is archived in favor of -WindowsAccessControl (Decision 18). +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). ## Evidence -- 2026-10-08, 5.0.0-rc6: the Release job of the tag (run `37839669028`) - published the package at 20:40 UTC and failed after it, because - `Publish-PSResource` gave up waiting after 100 seconds and its retry got - 409 (`progress.md`, open work 8). The live tests of rc7 ran with - `-Version 5.0.0-rc6`, which checks the hash of the Gallery, in both - editions, 20:43 to 21:00 UTC: all passed except the warning text that - rc7 changed, which matches the live tests of rc6. The fixture was - removed at 21:03 UTC and its removal checked. -- 2026-10-08, #116, 11 commits on `be04cb7` (`3899228` to `1063b29`) and - commits of records: - - Decision 22: items 1, 2, 5, 6, 7, and 8 changed, 7 and 8 breaking (the - link cmdlets require `-Path` and `-Target` and write non-terminating - errors); items 3, 4, 9, and 10 kept, 9 with an FAQ entry. New defects, - fixed with a regression test that failed first: `Move-Item2` deleted - an empty folder that it moved to another volume (AlphaFS emulated the - move); the link cmdlets failed with `GetDefaultValueFailed` for every - piped object; `Get-NTFSSimpleAccess` failed for a folder that came - after its parent folder a second time. - - One `security-reviewer` pass over `be04cb7..4ee01e5`: no Blocker or - Major. Minor 1 to 5 and Nits 7 to 9 fixed test-first in `7936d9f` to - `1063b29`; Nit 7, the warning of `Get-NTFSEffectiveAccess` for names - of this computer, was reproduced first. Nit 6 declined (Decision 22). - - Suite of `1063b29`: 712 tests. Elevated: 688 passed and 24 skipped in - Windows PowerShell 5.1, 658 and 54 in PowerShell 7. As a basic user: - 612 and 100, 582 and 130. No failure, none skipped in all four. - - Lab acceptance of `dc6e9f5` after the checkpoint - `ntfs-rc7-dc6e9f5-before-acceptance`, 16:24 to 16:40 UTC: 326 tests in - both editions, none failed, 2 skipped as in rc6 - (`Tests/Lab/Acceptance-2026-10-08-5.0.0-rc7.md`). The code of - `4ee01e5` and a first run of `dc6e9f5` without the checkpoint had the - same counts. The fixture was removed at 16:21 and 16:44 UTC, and its - removal checked each time. -- #115 passed CI in all four configurations on `be04cb7`, with the first - runs of `Invoke-TestsAsBasicUser.ps1` on GitHub runners; #116 passed CI - on `ebe91fe` against the rc6 branch. -- #34: no reply from the tester since 2026-10-06. +- rc6 Release run `37839669028`, attempt 2, succeeded; GitHub prerelease + with zip appeared 2026-10-09 07:01:34 UTC. First attempt proves HTTP 409 + after Gallery publication, not the previously assumed retry chronology. +- #116 is open, base master, head `d25647d`, CI build/wiki passed. rc7 + publication is pending. The follow-up does not change that PR's head. +- Local changes: deletion/ownership guards (`a97e46f`); all scopes and + inheritance (`e7ee203`); absolute basic-user results (`51dec86`); + exact-package publication recovery (`95b827e`); first-hidden-item fix + (`d610372`); Force/descriptor guards (`3442194`); SMB regression above. +- Hidden omission was reproduced in all four configurations before the + fix. No parameter/design change. Publication tests are wholly mocked; + exact ordinal SHA-512 identity is required, no real upload occurred. +- Final uninstrumented suite: 914 each, zero failed. Passed/skipped: + elevated Desktop 890/24, Core 860/54; basic Desktop 749/165, Core 719/195. + Frozen aggregate: 2,641/3,559 sequence (74.21%), 974/1,933 branches + (50.39%); NTFSSecurity assembly 84.28%. All skipped templates have + executed counterparts; mutations restored exactly before green builds. +- Live packaged candidate, 09:20 to 09:51 UTC: 330 passed, zero failed, + two expected Server-module skips. Published rc6: 326 passed, four + expected failures (Hidden and rc7 warning text in each edition), two + skips. Tested folder and all 11 ZIP files are byte-identical. +- Temporary host result verifier failed on Desktop JSON wrapping/full + test names; corrected verification passed on unchanged raw results in + both editions. Cleanup wrapper's broad Error.Count was not acceptance + proof. Independent probes verified all fixture objects/members/profiles + gone from six machines. Raw failing markers and corrected evidence kept. +- One read-only independent code review approved, high confidence, no + significant findings or confirmed exploit. Custom reviewer could not + start (model unavailable); built-in code-review performed the one pass. +- Six checkpoints exist but report Standard, even after a successful + temporary ProductionOnly probe; policy restored, no restore performed. + Do not claim verified Production rollback evidence. +- Wider matrix not deployed: 13 Server 2025 VMs; Windows 11/2019/2022 + media present, OS detection cache empty. #34 has no reply since Oct 6. +- Full evidence: session artifact `quality-gate-3442194-20261009`; + repository report `Tests/Lab/Acceptance-2026-10-09-quality-gate.md`. ## Next step -1. The maintainer reruns the failed Release job of 5.0.0-rc6, which skips - the published package and creates the GitHub release, and closes #110. -2. He pushes the records to #116 and reviews the choices of Decision 22, - each its own commit, above all the two breaking changes of the link - cmdlets. After the CI of the push, he merges #116 with a merge commit - (Decision 15) and tags `5.0.0-rc7`; the live tests then run against the - published package (`-Version 5.0.0-rc7`). -3. He decides the fix of the publish step (`progress.md`, open work 8) and - the scope of Phase 3: the operating systems, the code that nothing - calls, and file servers that aren't Windows (#34). +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. diff --git a/.memory-bank/progress.md b/.memory-bank/progress.md index 72b5f84..15a0f01 100644 --- a/.memory-bank/progress.md +++ b/.memory-bank/progress.md @@ -1,203 +1,121 @@ --- status: current -last-verified: 2026-10-08 +last-verified: 2026-10-09 owner: active-agent -source: repository evidence +source: repository and validation evidence --- # Progress ## Current status -5.0.0-rc6 is on the PowerShell Gallery, published by CI on 2026-10-08 at -20:40 UTC from the tag `5.0.0-rc6` on `master` (`b51d970`, the merge of -pull request #115; Decision 12). The Release job failed after the upload, -so the GitHub release waits for a rerun of the failed job. The published -package passed the live tests. Phase 2 of the quality gate (Decision 21) -is complete. The pull request #116 (`ai/release-5.0.0-rc7`) holds the -behavior changes that Phase 2 found, decided as assumptions for the -maintainer's review (Decision 22), and waits for that review. Phase 3 -follows. The stable Gallery version is still 4.2.6. NTFSSecurity will be -archived soon; its users move to WindowsAccessControl (Decision 18). +5.0.0-rc6 is published on the Gallery and GitHub; its failed Release job +recovered in attempt 2 on 2026-10-09. #116 (rc7, `d25647d`, base `master`) +is open and green, not merged or published. Further quality-gate work is +local on `ai/quality-gate-coverage`; Phase 2 is not complete while the +remaining-path inventory is open. Stable Gallery version: 4.2.6. +After 5.0.0, archive in favor of WindowsAccessControl (Decision 18). ## Recent milestones -- 2026-10-02 to 2026-10-04: #91 to #97 aligned the docs with the code, - shipped the help file, kept the docs on GitHub, set version 5.0.0, moved - CI and a wiki generated from `Docs` to GitHub Actions, and completed the - version history (Decisions 6 to 11). -- 2026-10-04: #98 (`e0f5366`) added releases on a version tag through CI - (Decision 12). The tag `5.0.0-rc1` published to the Gallery and created - the GitHub prerelease; the installed module passed the full suite. -- 2026-10-05, overnight run: the 24 code defects of work package 5 and the - issues #3, #4, #5, #17, #74, #82, #86, and #88 fixed with regression - tests, plus what one security review per PR found; the 37 open issues - triaged; `Docs/FAQ.md`, Dependabot for the actions, and the label `rc2`. - #5 and #82 are breaking changes, like the change of Decision 13. -- 2026-10-05: the first CI runs of the eight PRs found a defect that the - workstation had skipped, fixed in `629f4e7` (audit inheritance of an item - without a SACL). The PRs #99 to #106 were merged in order with merge - commits, CI on `master` passed, and the tag `5.0.0-rc2` published the - prerelease; GitHub's Actions outage that day cancelled the first two - attempts of the release run before they started. Repository hardening is - optional (Decision 14); the merge rule and the maintainer's rule for - fixes are Decisions 15 and 16. -- 2026-10-06: the issues got their labels (Decision 17). The maintainer - decided to publish 5.0.0-rc3 before 5.0.0 and to archive the project in - favor of WindowsAccessControl (Decision 18); the README, the docs home, - and the changelog announce it. -- 2026-10-06: 5.0.0-rc3 (#112): the access and audit cmdlets write only - the section that they change, which fixes #34 and the inherited entries - that elevated sessions copied as explicit ones (Decision 19). #67 has its - cause outside the module (a share root over UNC can't re-inherit), is - explained in `Docs/FAQ.md`, and was closed as not planned. One - `security-reviewer` pass approved the branch. The PR description said - "fixes #34", so the merge closed #34; it was reopened for a tester. -- 2026-10-06: 5.0.0-rc4 (#113), test-first: drive and volume roots read - and change their root folder, not the device (#41); the audit cmdlets - reject a descriptor without the audit entries (#109); `-WhatIf` previews - `Copy-Item2` and `Move-Item2` despite an existing destination (#108); - small items (#111). One `security-reviewer` pass approved it; its Minor - findings R1, R2, R6, and R8 were fixed before the merge. #41, #108, - #109, and #111 closed as completed, #90 and #107 as not planned. -- 2026-10-07: live tests in the lab of WindowsAccessControl (Decision 20) - against 5.0.0-rc2 and 5.0.0-rc4 in both editions: rc2 fails #34 over SMB - with error 1307 for `Add-NTFSAccess`, `Clear-NTFSAccess`, and - `Set-NTFSSecurityDescriptor`; rc4 passes the four cases, except - `Get-NTFSEffectiveAccess -ServerName` with a computer that can't be - reached, which returned no access since before rc1. The maintainer chose - to fix it test-first in 5.0.0-rc5; the branch build passes all live tests - and the suite. One `security-reviewer` pass approved it with minor - findings (`activeContext.md`). -- 2026-10-08: #114 merged (`fcb370e`); the tag `5.0.0-rc5` published it to - the Gallery and the GitHub releases, whose `NTFSSecurity.zip` holds the - same 11 files. Phase 1 of the quality gate (Decision 21) measured rc5: - the published package passes the live tests in both editions; the 11 - tests that need a session without the Security privilege pass as a basic - user, so every test runs in at least one configuration, but CI runs only - elevated; the suite runs 55.9% of the C# lines and 37.4% of the branches. - The maintainer approved Phase 2. -- 2026-10-08: Phase 2, step 1 on `ai/release-5.0.0-rc6` (local): tests for - `Set-NTFSOwner`, `Test-Path2`, `Get-DiskSpace`, and the link cmdlets - (suite: 555 tests). Fixed test-first: the privileges stayed enabled after - an early stop; `Test-Path2` stopped for invalid characters in Windows - PowerShell; and, from one `security-reviewer` pass, the privilege cleanup - decided on stale states, a defect since 4.2.6. The page of - `New-NTFSSymbolicLink` was corrected after a lab check of Developer Mode. -- 2026-10-08: Phase 2 finished on `ai/release-5.0.0-rc6` (local). Tests for - `Get-NTFSOrphanedAudit`, `Get-NTFSSimpleAccess`, the - `-SecurityDescriptor` parameter sets, the error contracts of all path - cmdlets, and #110. Fixed test-first: `Get-NTFSSimpleAccess` (`ReadData`, - relative paths), `Copy-Item2` and `Move-Item2` (folder conflicts, the - missing destination folder of #21), the hard-link cmdlets on shares, - `Set-NTFSSecurityDescriptor -PassThru` (R5), and the error ID of - `Get-NTFSOrphanedAccess`; from the coverage report, relative paths that - start with a dot (every cmdlet acted on the item without the first two - characters), comparing output objects (`InvalidCastException`), and - `InheritedFrom`. CI runs the suite as a basic user too; the live tests - cover all cmdlet groups and accounts of three more domains. Suite: 677 - tests, none failed, none skipped in every configuration; C# coverage - 68.1% of the lines and 44.3% of the branches (rc5: 58.1% and 38.0%, - measured again; the first measurements counted one of four runs). Two - `security-reviewer` passes; the lab acceptance of `7b0781f` passed - (`Tests/Lab/Acceptance-2026-10-08-5.0.0-rc6.md`). -- 2026-10-08: #115 (rc6, head `be04cb7`) passed CI in all four - configurations. On `ai/release-5.0.0-rc7` (local), the behavior changes - of Phase 2 were decided as assumptions for review (Decision 22) and - implemented test-first, two of them breaking (the link cmdlets); new - defects found on the way: `Move-Item2` deleted an empty folder that it - moved to another volume, the link cmdlets failed for every piped object, - and `Get-NTFSEffectiveAccess` warned for names of this computer. One - `security-reviewer` pass (no Blocker or Major; its findings fixed but - one, declined). Suite and lab acceptance in `activeContext.md`. -- 2026-10-08: #115 merged (`b51d970`) and tagged `5.0.0-rc6`. The Release - job published the package at 20:40 UTC, then failed: `Publish-PSResource` - gave up waiting after 100 seconds while the Gallery accepted the upload, - and its retry got 409, so the job didn't create the GitHub release. The - published package passed the live tests of rc7 in both editions except - the one test whose expected warning text rc7 changed - (`Tests/Lab/Acceptance-2026-10-08-5.0.0-rc6.md`, After the release). - #116 (5.0.0-rc7) was opened on the rc6 branch and moved to `master`. +- 2026-10-02 to 2026-10-06: documentation/help aligned with source, CI and + wiki moved to GitHub Actions, versioning/release automation established, + and prereleases rc1 to rc4 published. Earlier detail is in git, + `CHANGELOG.md`, `Docs/Version-History.md`, and Decisions 1 to 19. +- 2026-10-07: lab comparison of rc2/rc4 reproduced #34 over SMB and proved + changed-section writes preserve the owner. Remote effective-access + fallback returned no result; fixed test-first for rc5 (Decision 20). +- 2026-10-08: #114 merged (`fcb370e`), rc5 published and live-tested. + Decision 21 established the quality gate. Corrected four-run coverage: + rc5 58.1% sequence points/38.0% branches, not the initial one-run result. +- 2026-10-08: rc6 Phase 2 added basic-user CI, parameter-set/error tests, + expanded domain/SMB cases, and fixes for privilege cleanup, path + resolution, ownership retries, item conflicts, output equality, and + inherited flags. Suite: 677; coverage 68.14% sequence/44.32% branches. + Lab acceptance of `7b0781f` passed; two independent review passes. +- 2026-10-08: rc7 implemented proposed Decision 22, including breaking + link binding/error changes, SimpleAccess traversal, cross-volume folder + preservation, and named effective-access warnings. Suite: 712, no + failures or test skipped everywhere. One review: no Blocker/Major. + Lab candidate `dc6e9f5`: 326 passed, 2 skipped, cleanup verified. +- 2026-10-08: #115 merged (`b51d970`) and tagged rc6. Release run + `37839669028` failed with HTTP 409 after Gallery publication. The log + does not establish the previously assumed initial timeout/retry cause. + Published rc6 live tests differed only in rc7's warning expectation. +- 2026-10-09: release attempt 2 succeeded; rc6 GitHub prerelease and zip + appeared at 07:01:34 UTC. #116 passed CI on `d25647d`. +- 2026-10-09: autonomous follow-up on `ai/quality-gate-coverage`, through + `3442194`, adds 202 cases above rc7: deletion/owner failures, all 13 + scopes, inheritance transitions, enumeration, forced replacement, + descriptor failures, and offline CI recovery. Reproduced/fixed rooted + result-path handling and first-hidden-item omission. Publication recovery + verifies exact SHA-512 identity, not merely version existence. +- 2026-10-09: final uninstrumented suite: 914 per configuration, zero + failures; coverage: 2,641/3,559 sequence points (74.21%) and 974/1,933 + branches (50.39%), aggregate of four runs without AltCover `--save`. + All skipped templates have executed counterparts. Mutation guards were + proved and production source restored; Release build/checks pass. +- 2026-10-09: live comparison, 09:20 to 09:51 UTC: candidate 330 passed, + zero failed, two expected skips; published rc6 four expected Hidden/ + warning-text failures only. Independent cleanup probes verified fixture + absence; raw host-verifier failures retained with corrected verification. + Lab guards/acceptance committed in `7594e0c`. One independent code review + approved with no significant finding (custom model unavailable; built-in + fallback). All 11 tested files match the ZIP. OS/path gates stay open. ## Stable capabilities -- 36 cmdlets: access (7), audit (5), inheritance (6), owner and security - descriptor (4), privileges (3), long-path items (6), links, hash, and - disk space (5). -- Works in Windows PowerShell 5.1 and PowerShell 7. In PowerShell 7, - `Get-FileHash2` lacks `RIPEMD160` and `MACTripleDES`, which .NET lacks. -- Pester tests in `Tests\` run in `$env:TEMP` sandboxes through - `Tests\TestHelpers.psm1`; tests that need privileges skip without them - and run in CI, whose runners are elevated. +- 36 cmdlets: access, audit, inheritance, owners/descriptors, privileges, + long-path items, links, hash, and disk space. +- Windows PowerShell 5.1 and PowerShell 7; RIPEMD160 and MACTripleDES are + available only in Desktop. Both editions run elevated/basic-user in CI. +- Pester fixtures use `Tests/TestHelpers.psm1` TEMP sandboxes. Live tests + are excluded from CI and run only on approved lab client/server targets. ## Open work -1. Quality gate before 5.0.0 (Decision 21): the maintainer reruns the - failed Release job of 5.0.0-rc6, which creates the GitHub release; - reviews the choices of Decision 22 in #116; merges #116 and tags - 5.0.0-rc7, whose published package then runs the live tests. Phase 3 - runs the live tests on more operating systems. Then release 5.0.0 - through CI (Decision 12): remove the label, date - `[Unreleased]` as `[5.0.0]`, add the last prerelease to - `$publishedVersions`, and tag `5.0.0` (steps in - `Docs/Contributing/05-Releasing.md`). #34 stays open with Bug and Help - Wanted until a tester with a file server that refuses the owner - confirms the fix, or until 5.0.0 ships. -2. Issues: 5.0.0-rc6 addresses the seven items of #110 (tests); #115 - named it without a closing keyword, so the maintainer closes it now. - #21 (a misleading error of `Move-Item2`) got - a fix in rc6 that names the missing destination folder; the folder - moves to another volume that rc7 fixes are a different defect. #68 - tracks `-WhatIf` and `-Confirm` for every cmdlet that changes security. - The labels follow Decision 17; #16, #21, #45, and #89 wait for their - reporters (Needs Info). Not planned for 5.0.0: the enhancements #22, - #49, #68, #77, #87. -3. Review findings, not filed: of rc3, an extra DACL read and four SDDL - snapshots on read paths, and a duplicate SACL check; of rc4, R3 to R5, - R7, and five older defects, listed in the description of #113, among - them the accounts filter that `RemoveFileSystemAccessRuleAll` and - `RemoveFileSystemAuditRuleAll` ignore, which no cmdlet passes; of rc5, - the bare `catch` in `Win32.GetEffectiveAccess`, the unchecked - `AUTHZ_ACCESS_REPLY.Error`, and hardening of the lab controller (guards - in the setup blocks, interpolated `-EncodedCommand` paths, CredSSP by - IP address, the password string in memory, disabling the role accounts - after a run); of rc6, a privilege that fails to be disabled isn't tried - again by `Dispose` (finding 2, not reproducible); of rc7, one error ID - for a missing path in the audit cmdlets (declined, Decision 22). -4. Behavior changes found in Phase 2 (Decision 16): decided in Decision 22 - as assumptions for the maintainer's review, on `ai/release-5.0.0-rc7`; - two of them are breaking changes of the link cmdlets. Left for Phase 3: - 400 points of code that nothing calls besides the 244 lines of unused - classes. -5. `pwsh` 7.6.1 crashed three times during test runs on the ARM64 - workstation (x64 emulation), without module frames; none of the CI runs - on native x64 on 2026-10-05 crashed. -6. Optional for the maintainer: delete the AppVeyor project and revoke its - GitHub authorization, restrict wiki editing to collaborators, ask - `Sup3rlativ3` to delete the Read the Docs project, and delete the branch - `test/transfer`. In the lab, delete the checkpoints - `ntfs-rc6-*-before-acceptance` and `ntfs-rc7-*-before-acceptance` of the - six machines when they are no longer needed. -7. Reachable code that no test runs (coverage report of rc6, ranked by - impact; about 300 points): `Remove-Item2` on folders (`-Recurse`, - `-Force`, `DeleteError`); the owner restore after taking ownership - (`RestoreOwnerError`); the inheritance cmdlets on folders and - `Set-NTFSInheritance -AccessInheritanceEnabled $true`; the mapping of - all 13 `-AppliesTo` values and the flag parameters of - `Remove-NTFSAccess`, `Add-NTFSAudit`, and `Remove-NTFSAudit`; the - switches and errors of `Get-ChildItem2`; the table views and - `InheritedFrom` in them; `Move-Item2 -Force`; account input errors; - `-PassThru` after success of the audit and inheritance cmdlets; - `Set-NTFSSecurityDescriptor` failures; the audit cmdlets without the - Security privilege on a local item; `Get-NTFSEffectiveAccess` for an - unresolvable SID; failed ownership retries of `Clear-NTFSAccess` and - `Set-NTFSInheritance`. A display limit, not a defect: a conditional ACE - shows as an unconditional entry, because the .NET rules have no - condition. -8. The publish step of the Release job fails when `Publish-PSResource` - gives up waiting after 100 seconds while the Gallery accepts the - package, because its retry gets 409 (5.0.0-rc6). Proposed for the - maintainer (Decision 16, not reproducible on demand; he was asked on - 2026-10-08 and didn't answer, so it stays open): treat the error as - success when `Find-PSResource` then lists the version, in a script with - Pester tests. Until then, rerun the failed job. +1. Decision 21 gate: review Decision 22, integrate reviewed quality-gate + follow-up, publish the next candidate, and test the published package. + Do not release 5.0.0 until the remaining-path and OS-matrix gates close. + Release steps: `Docs/Contributing/05-Releasing.md`; remove prerelease + label, date `[5.0.0]`, update `$publishedVersions`, tag through CI. +2. Issues: #110's seven items were addressed by rc6, but #115 deliberately + used no closing keyword. #34 stays open for non-Windows owner feedback + or maintainer acceptance. #16, #21, #45, #89 await reporters. #68 tracks + ShouldProcess for security cmdlets; enhancements #22/#49/#68/#77/#87 + are not planned for 5.0.0. Labels follow Decision 17. +3. Deferred reviews (not silently accepted): rc3 extra DACL read/SDDL + snapshots/duplicate SACL check; rc4 findings listed in #113, including + library-only RemoveAll account filters; rc5 unchecked Authz errors and + lab-controller hardening; rc6 failed privilege-disable retry (not + reproduced); rc7 audit missing-path error IDs declined in Decision 22. +4. Architecture/cmdlet design: Decision 22 remains proposed; two link + changes are breaking. Keep unused classes/helper overloads until a + maintainer decision; do not remove them to improve coverage percentages. +5. ARM64 workstation: PowerShell 7.6.1 crashed under x64 emulation without + module frames; native-x64 CI did not reproduce it. +6. Optional maintainer cleanup: obsolete AppVeyor/Read the Docs access, + wiki editing restrictions, `test/transfer`, and old lab checkpoints + when no longer needed. No remote changes or snapshot restores here. +7. Fresh coverage inventory at `3442194`: 918 unvisited sequence points. + Of these, 244 are in classes unused by cmdlets and 112 in parameter + getters; 562 remain for finer review/testing, including unused overloads, + defensive/native failures, and environment-specific branches. High-value + local gaps closed: folders/Force/DeleteError, RestoreOwnerError, all + scopes, file/folder inheritance, enumeration/depth/link skipping, + descriptor write failures, and forced file replacement. Remaining + candidates: audit ownership-retry failures, SD inheritance edge cases, + effective-access unresolved identity, recursive denial/error surfaces, + output-object comparisons/formatting. A conditional ACE display remains + a .NET representation limit, not evidence of unconditional permissions. +8. Publication recovery is implemented locally in `95b827e`, with 14 offline + tests and exact artifact SHA-512 verification. Original upload errors + remain errors for missing/different/unverifiable outcomes. Not deployed + until the maintainer merges/pushes; no publication was performed here. +9. Phase 3: choose OS scope (proposed Windows 11 client/2019/2022 servers), + detect ISO editions, provision without repurposing shared VMs, then run + published-package acceptance. #34 has no new reply since 2026-10-06. +10. Lab rollback evidence: new checkpoints exist but report Standard even + after a successful temporary ProductionOnly probe. Classification is + unresolved; original VM policy restored, no checkpoint restored. Do + not represent these as verified Production snapshots. diff --git a/.memory-bank/systemPatterns.md b/.memory-bank/systemPatterns.md index 4f71acb..6feb456 100644 --- a/.memory-bank/systemPatterns.md +++ b/.memory-bank/systemPatterns.md @@ -1,57 +1,27 @@ --- status: current -last-verified: 2026-10-08 +last-verified: 2026-10-09 owner: active-agent -source: repository evidence +source: repository and regression evidence --- # System patterns ## Architecture -```text -NTFSSecurity.psd1 ─┬─ ScriptsToProcess: NTFSSecurity.Init.ps1 - │ Add-Type: Security2.dll, PrivilegeControl.dll, - │ ProcessPrivileges.dll, inline NTFS.DriveInfoExt; - │ Update-FormatData -PrependPath format.ps1xml - ├─ TypesToProcess: NTFSSecurity.types.ps1xml - │ (Owner, IsInheritanceBlocked, LengthOnDisk on - │ FileInfo/DirectoryInfo; AccountType on ACEs) - ├─ RootModule: NTFSSecurity.psm1 (aliases) - ├─ NestedModules: NTFSSecurity.dll (36 cmdlets) - └─ en-US\NTFSSecurity.dll-Help.xml (Get-Help; generated - from Docs/Cmdlets, Decision 8) -NTFSSecurity.dll ── cmdlets ──> Security2.dll (FileSystemAccessRule2, - FileSystemAuditRule2, IdentityReference2, - FileSystemInheritanceInfo, EffectiveAccess) - ── long paths ──> AlphaFS - ── privileges ──> PrivilegeControl / ProcessPrivileges -``` - -- `BaseCmdlet` resolves only relative paths, against the current file - system location of the session (not `$PWD`, #86). Path parameters carry - `[FileSystemPathTransformation]`, which binds file objects as full paths. -- On access denied, most cmdlets retry through `InvokeAsOwner`, which takes - ownership and restores the previous owner on every exit path. -- `BaseCmdletWithPrivControl` enables Backup, Restore, TakeOwnership, and - Security in `BeginProcessing` when `PrivateData.EnablePrivileges` is - `$true`, and disables the ones it enabled in `EndProcessing` and, since - 5.0.0-rc6, in `Dispose`: PowerShell skips `EndProcessing` when a later - command, such as `Select-Object -First`, or a terminating error stops the - pipeline, but calls `Dispose`. `Enable-Privileges` keeps them - (`KeepEnabledPrivileges`). The cleanup reads the current state of each - privilege, because another command in the pipeline can have changed it, - and tries every privilege even when one fails: `EndProcessing` warns, - `Dispose` stays silent, because PowerShell ignores exceptions thrown - there and no stream is open anymore. -- `PrivateData` switches: `EnablePrivileges`, `GetInheritedFrom`, - `GetFileSystemModeProperty`, `IdentifyHardLinks`, `ShowAccountSid`. -- Cmdlets accept `-Path` (alias `FullName`) or `-SecurityDescriptor`; the - SD sets change the object in memory until `Set-NTFSSecurityDescriptor`. +| Component | Responsibility | +| --- | --- | +| `NTFSSecurity.psd1` | Root script, nested binary, initialization, types, help | +| `NTFSSecurity.Init.ps1` | Loads Security2/privilege assemblies and prepends formatting | +| `NTFSSecurity.dll` | 36 PowerShell cmdlets; BaseCmdlet path/privilege behavior | +| `Security2.dll` | DACL/SACL objects, owners, inheritance, effective access, Win32 | +| AlphaFS | Long-path files/directories/links | +| PrivilegeControl / ProcessPrivileges | Token privilege operations | +| `en-US/NTFSSecurity.dll-Help.xml` | Committed help generated from cmdlet Markdown | ## Decisions -Each Decision record is a file in `decisions/`; read only the relevant ones. +Read only task-relevant records; the index controls routing. | # | Decision | | --- | --- | @@ -80,65 +50,45 @@ Each Decision record is a file in `decisions/`; read only the relevant ones. ## Patterns -### Writing cmdlets +### Cmdlets and security sections -- A parameter that takes pipeline input needs a getter that doesn't - throw: PowerShell reads it before it binds each input object, and an - exception turns every object into `GetDefaultValueFailed` (the link - cmdlets before 5.0.0-rc7). -- An error for one item is non-terminating, so that the cmdlet goes on - with the next path or pipeline object; since 5.0.0-rc7, the link cmdlets - too. Its message names the item, and its target object is the item that - the cmdlet was asked to process. Resolving a path can throw in Windows - PowerShell for an invalid character, so that belongs inside the - per-item error handling. -- Folders move without `MoveOptions.CopyAllowed`: for another volume, - AlphaFS then copies and deletes, which lost empty folders. Windows - refuses such a move with `NotSameDeviceException` (17). Tests reach - another volume through `\\localhost\C$`, elevated only. +- BaseCmdlet resolves relative paths against the current filesystem + location; file-object input binds FullName through path transformation. +- Write only changed/read sections (Decision 19); a descriptor parameter + changes memory until `Set-NTFSSecurityDescriptor` persists it. +- Access denial can retry through InvokeAsOwner; restore the previous owner + on every exit, except a successful descriptor write that intentionally + sets the owner. Restoration failures must report RestoreOwnerError. +- Privilege cleanup runs in EndProcessing and Dispose, reads current states, + attempts all cleanup, and preserves explicit enables. Dispose has no stream. +- Pipeline getters never throw; per-item errors name input and allow continuation. +- Folder moves never use CopyAllowed; preserve cross-volume source folders. +- Apply implied Hidden/Force before deciding to emit, including the first item. -### Verifying documentation +### Tests and documentation -- Run platyPS in Windows PowerShell 5.1 against a Release build; a copy of - `Docs/Cmdlets` must round-trip through `Update-MarkdownHelp` unchanged. - Keep cmdlet pages ASCII-only. platyPS takes `Position` and `Required` from - the shipped help file: after such a change, edit the page YAML, run - `New-ExternalHelp`, rebuild, and check the round trip. -- MarkdownLinkCheck checks relative `Docs` links, `Tests\Wiki.Tests.ps1` - the wiki links and anchors. The wiki is generated from `Docs` (never edit - it); `Docs/README.md` becomes Home, its cmdlet groups the sidebar. -- In cmdlet pages, end a sentence with a link (platyPS drops the space - after it). Verify examples in a `$env:TEMP` sandbox, never on real data. +- Tests import Release in isolated processes, both editions and privilege + modes. File/ACL/link fixtures use shared sandbox guards and cleanup. + Privilege-dependent skips must have eligible counterparts in the matrix. +- Assert persisted state, errors/targets, continuation, and no failed + PassThru output. Prove new characterization guards with bounded mutations; + restore source exactly and rebuild before green validation or packaging. +- Fixture DACLs use .NET SetAccessControl, not Set-Acl's unintended SACL writes. +- Scope/descendant expectations are independent of the production converter. +- Desktop platyPS: generate help, rebuild, round-trip unchanged, check links. +- Live tests use only approved lab targets, SMB then independent server state; + Get/SetFileSecurity preserves stored DACLs; rights oracles use S4U tokens. -### Testing the module +### CI results and publication -- Pester 5 tests in `Tests/*.Tests.ps1` import the Release build; CI runs - them in Windows PowerShell 5.1 and PowerShell 7 (Decision 11). -- A test that changes files, links, or security descriptors uses - `Tests\TestHelpers.psm1`: its own sandbox, `Assert-TestSandboxPath` - before each change, `Remove-TestSandbox`. Cases that need a privilege - skip with `Test-PrivilegeHeld`, cases that need its absence skip when - elevated; CI runs the suite elevated and as a basic user in both - editions, so each case runs somewhere. `Block-Test*` make a read or a - write fail without elevation; `Set-TestOwner` with - `EnablePrivileges = $false` reproduces an owner the user can't assign. -- Fixtures write a DACL with `SetAccessControl`, never with `Set-Acl`: - `Set-Acl` compares `AreAuditRulesProtected` of the new descriptor with - `AreAccessRulesProtected` of the item (`FileSystemSecurity.cs` of - PowerShell), so for an item with a protected DACL it writes the audit - section too. Without the Security privilege that fails with - `PrivilegeNotHeldException`; with it, `Set-Acl` writes every section and - drops the audit entries. Windows PowerShell has - `FileInfo`/`DirectoryInfo.SetAccessControl`; PowerShell 7 has - `[System.IO.FileSystemAclExtensions]::SetAccessControl`. -- `Get-Help -Online` tests run only in Windows PowerShell, which honors the - hook `BypassOnlineHelpRetrieval`. `Manifest.Tests.ps1` and - `Release.Tests.ps1` check the manifest, the version (Decision 10), the - release notes, and the packages. -- The live tests in `Tests\Lab` (Decision 20) run as domain accounts in a - lab: on the client over SMB, then on the file server, which checks what - the client runs left. They read and write descriptors as Windows stores - them with `GetFileSecurity` and `SetFileSecurity`, because - `GetNamedSecurityInfo` converts a DACL without the auto-inherit flag and - returns its owner. The expected effective rights come from the S4U tokens - of the file server and the client, like the Effective Access tab. +- Use Path.Combine then GetFullPath for a rooted-or-repository-relative + result path; Join-Path appends even a rooted child and corrupts it. +- Discovery handles only expected PackageNotFound as absence; repository, + authentication, and network errors remain failures. +- Rerun/uncertain-upload success requires Gallery SHA-512 equality with the + exact build artifact. Base64 is case-sensitive: use ordinal comparison. + Missing/different/unverifiable metadata preserves the upload error. +- Secrets stay by environment reference, never in process arguments/logs. + Test all external publication commands with mocks; no test may upload. +- AltCover aggregates all four sequential runs without --save. Report + sequence points, not unique source lines; keep unmatched paths visible. diff --git a/.memory-bank/techContext.md b/.memory-bank/techContext.md index 9b949e1..83acc4c 100644 --- a/.memory-bank/techContext.md +++ b/.memory-bank/techContext.md @@ -1,251 +1,156 @@ --- status: current -last-verified: 2026-10-08 +last-verified: 2026-10-09 owner: active-agent -source: repository evidence +source: repository and executable evidence --- # Tech context ## Stack -- C# class libraries, old-style `.csproj`, .NET Framework 4.5.2, - solution `NTFSSecurity.sln` (Visual Studio 2017 format). -- Projects: `NTFSSecurity` (cmdlets), `Security2` (ACL object model, Win32 - interop), `PrivilegeControl` and `ProcessPrivileges` (token privileges), - `Log`, `TestClient`, `NTFSSecurityTest` (MSTest, minimal coverage). -- NuGet (`packages.config`): AlphaFS 2.2.x for long paths; - `System.Management.Automation.dll` 10.0.10586.0. For a drive or volume - root, AlphaFS `DirectoryInfo` reaches the device object, while - `Directory.Get/SetAccessControl('C:\')` reaches the root folder (#41). -- Module: `NTFSSecurity.psd1` loads `NTFSSecurity.psm1` (aliases `dir2`, - `gi2`, `rm2`, `del2`), `NTFSSecurity.Init.ps1` (Add-Type of the helper - assemblies, prepends `NTFSSecurity.format.ps1xml`), and `NTFSSecurity.dll`. -- Documentation: Markdown in `Docs` and `README.md`, rendered by GitHub and - published to the wiki by CI; no documentation site (Decisions 9 and 11). - Cmdlet pages are platyPS 0.14 markdown (schema 2.0.0) in `Docs/Cmdlets`. -- Help: `NTFSSecurity\en-US\NTFSSecurity.dll-Help.xml`, generated from - `Docs/Cmdlets` and committed (Decision 8). -- Tests: Pester 5 in `Tests`, one file per area, against the Release - build; `Wiki.Tests.ps1` (wiki conversion) runs without a build. -- CI: GitHub Actions, `.github/workflows/ci.yml` with the scripts in - `.github/scripts` (Decision 11). +- Legacy C# projects, .NET Framework 4.5.2, `NTFSSecurity.sln`. + Cmdlets depend on Security2, PrivilegeControl/ProcessPrivileges, and + AlphaFS 2.2.x. System.Management.Automation reference: 10.0.10586.0. +- Module supports Windows PowerShell 5.1 and PowerShell 7; 36 cmdlets. + Manifest initializes helper assemblies, aliases, type data, formatting, + and committed help generated from `Docs/Cmdlets` (platyPS 0.14/schema 2). +- CI: `.github/workflows/ci.yml`, scripts in `.github/scripts`; GitHub + renders Docs and publishes a generated wiki. No separate docs site. -## Environment +## Current environment -- Windows only (NTFS, Win32 security APIs). -- The Debug build writes straight into - `C:\Program Files\WindowsPowerShell\Modules\NTFSSecurity\`. -- No Visual Studio MSBuild or .NET Framework targeting pack on the - workstation. A local build works with the .NET Framework MSBuild - (`%WINDIR%\Microsoft.NET\Framework64\v4.0.30319\MSBuild.exe`) plus - `/p:CscToolPath` to the Roslyn `csc.exe` of the `Microsoft.Net.Compilers` - package; the legacy C# 5 compiler fails with CS0136. `dotnet msbuild` - fails on the binary resources in `Resources.resx` (MSB3822, MSB3823). -- platyPS 0.14.2, Pester 5.7.1, PSScriptAnalyzer, and powershell-yaml are - installed only for PowerShell 7. Windows PowerShell 5.1, started from - PowerShell 7, imports platyPS and Pester by full path - (`~\OneDrive\Documents\PowerShell\Modules\platyPS\0.14.2`, - `C:\Program Files\PowerShell\Modules\Pester\5.7.1`). Leave - `$env:PSModulePath` alone: PowerShell 7 hands the child the Windows - PowerShell default path, and clearing it leaves Windows PowerShell without - its core modules (Pester fails: `Add-Member` not found). -- MarkdownLinkCheck is not installed, and `Save-Module` crashed (FailFast) - in PowerShell 7.6 on 2026-10-04. Download the 0.2.0 package from - `https://www.powershellgallery.com/api/v2/package/MarkdownLinkCheck/0.2.0` - into `$env:TEMP`, extract it, and import it by path. -- The first workstation is ARM64; PowerShell 7 runs as x64 under emulation. -- The NuGet cache (`~\.nuget\packages`) holds every build dependency: copy - `alphafs\2.2.1`, `system.management.automation.dll\10.0.10586`, and - `microsoft.netframework.referenceassemblies.net452\1.0.3` into - `packages\.`, and point `CscToolPath` at - `microsoft.net.compilers\4.2.0\tools`. -- The second workstation (x64, used since 2026-10-05) runs the agent - session elevated, so the tests that need privileges run there as in CI. - It has no NuGet cache with these packages: download each from - `https://api.nuget.org/v3-flatcontainer///..nupkg`, - extract the first three into `packages\.` and the compilers - into `$env:TEMP`; Pester 5.7.1 comes from the Gallery package API the - same way, its folder first on `$env:PSModulePath` of the test process. - The GitHub CLI is in `C:\Program Files\GitHub CLI`, outside the PATH of - sessions started before its installation. -- The third workstation (`ExHost`, a Windows Server 2025 VM, x64, used since - 2026-10-07) runs the agent session elevated and hosts the AutomatedLab lab - `WindowsAccessControlLab` (Decision 20) with Hyper-V and AutomatedLab - 5.61.704. It has no NuGet cache or platyPS: check each - nuget.org package against the SHA-512 `packageHash` of its catalog entry - (`https://api.nuget.org/v3/registration5-semver1//.json`, - then `catalogEntry`), and each Gallery package against `PackageHash` of - `api/v2/Packages(Id='',Version='')`. Pester 5.7.1 is in - `V:\Git\WindowsAccessControl\output\RequiredModules`. The GitHub CLI - 2.102.0 is in `C:\Program Files\GitHub CLI`, outside the PATH, and signed - in as `raandree` since 2026-10-08; `Block-RemoteMutation` denies its - mutating commands, so the agent uses it read-only. The lab domains - `a.forest1.net` and `b.forest1.net` had a maximum password age of 42 - days, so the password of `install` expired on 2026-09-15 and AutomatedLab - got access denied; it never expires since 2026-10-07, as in - `forest1.net`. +- Host `ExHost`: Windows Server 2025 VM, native x64, elevated agent; + repository `V:\Git\NTFSSecurity`. AutomatedLab 5.61.704, Hyper-V, + approved lab `WindowsAccessControlLab` (Decision 20). +- Build Release only: Debug writes to Program Files. Native .NET Framework + MSBuild plus Roslyn `Microsoft.Net.Compilers` 4.2.0 and .NET 4.5.2 + reference assemblies work; legacy compiler fails CS0136, dotnet MSBuild + fails binary resources MSB3822/MSB3823. Build packages are already cached + in `packages`; compiler/tools are under TEMP `ntfs-build`. +- Pester 5.7.1 is in + `V:\Git\WindowsAccessControl\output\RequiredModules\Pester\5.7.1`. + platyPS 0.14.2 and MarkdownLinkCheck 0.2.0 are under TEMP `ntfs-docs-tools`. + PSScriptAnalyzer and PSResourceGet are available in PowerShell 7. +- Use Desktop's module paths in Desktop children, not inherited Core-only + paths. Never import NTFSSecurity in the agent shell; every package/build + runs in a new process. Current prereleases share assembly version 5.0.0.0. +- GitHub CLI: `C:\Program Files\GitHub CLI\gh.exe`, signed in as raandree. + Read-only queries work; remote mutations belong to the maintainer. +- LabSources: `V:\LabSources`. All 13 deployed machines are Server 2025. + Windows 11 consumer/enterprise-evaluation media and Server 2019/2022 + ISO files exist. OS cache is empty; exact detected editions are not yet + verified. Do not equate present media with a deployed/tested OS matrix. ## Constraints -- `ModuleVersion` is `5.0.0` with the prerelease label `rc6` on the branch - `ai/release-5.0.0-rc6` (`rc5` on `master`). - The latest stable tag and Gallery release is `4.2.6`. The manifest - requires PowerShell 5.1 and .NET Framework 4.5.2, uses `RootModule`, and - lists exactly 36 cmdlets; `Test-ModuleManifest` passes in Windows - PowerShell 5.1 and PowerShell 7.6. -- The module source at `master` differs from tag `4.2.6` by the changes - that `CHANGELOG.md` lists under `[Unreleased]`, the release notes of each - 5.0.0 prerelease. -- PowerShell Gallery versions (publish dates): 4.0.0 (2015-08-19), 4.2.2 - (2016-05-18), 4.2.3 (2016-05-19), 4.2.4 (2018-08-13), 4.2.5 (2019-07-11), - 4.2.6 (2019-07-12), none with release notes; 5.0.0-rc1 (2026-10-04), - 5.0.0-rc2 (2026-10-05), 5.0.0-rc3 and 5.0.0-rc4 (2026-10-06), 5.0.0-rc5 - (2026-10-08), published - by CI. Older versions were released on CodePlex only, and their dates are - lost. The git history starts on 2016-10-10, when the project moved from - CodePlex. -- Releases up to 4.2.6 were Debug builds published by hand, with the whole - output folder; their tags carry the previous version. From 5.0.0 on, CI - publishes on a version tag (Decision 12). GitHub releases attach - `NTFSSecurity.zip`. -- CI: GitHub Actions on pull requests, pushes to `master`, and version tags - (Decision 11); AppVeyor and Read the Docs aren't used (Decision 9). -- `CHANGELOG.md` lists user-visible changes only; CI and build-only changes - get no entry - ([Decision 7](decisions/0007-changelog-user-visible-only.md)). -- Remote mutations are the maintainer's: the user-level preToolUse hook - `Block-RemoteMutation.ps1` denies `git push` and mutating `gh` commands - (`pr create`, `pr close`, and others) from the agent session, even after - an explicit request. Its override, `COPILOT_ATELIER_ALLOW_REMOTE=1`, is - read from the environment that VS Code starts the hook with; setting it - inside an agent command has no effect (verified 2026-10-04). The hook - matches the whole command text, so a commit message that quotes such a - command is blocked too. Prepare the commands and descriptions; the - maintainer runs them. Hand over each command as its own fenced code block - at the end of the reply, which the chat shows with a copy button, and end - the turn there; the maintainer reports back in the chat. The question - dialog joins the lines of its text, has no copy button, and covers the - reply before it (maintainer, 2026-10-06). A long question also hides its - choices, so that it can't be answered: keep it to a few short sentences - (2026-10-07). A pull request description - names an issue without a closing keyword (fixes, closes, resolves) unless - the merge should close it: "fixes #34" in #112 closed #34. Simulated `gh` - commands in offline tests must print what the real ones print, such as - the URL of a new comment. +- Source manifest: ModuleVersion 5.0.0, prerelease rc7 on #116/follow-up. + Latest stable: 4.2.6; latest published prerelease: rc6 (2026-10-08). + GitHub rc6 release recovered 2026-10-09. rc7 publication is pending. +- Changed-section writes preserve unchanged owner/group/DACL/SACL (19). + Roots use root-folder APIs, not AlphaFS device security (#41). +- CHANGELOG contains user-visible changes only (7); tests and CI-only fixes + get no entry. No stable release until Decision 21 gates close. +- Honor separate topic branches, no amendment, two AI co-author trailers. + Never work around remote-mutation blocking. Provide each maintainer + command separately at reply end, no question dialog after commands. + Issue references use no closing keyword unless closure is intended. +- Lab passwords stay in memory and are lab-only; no secret in repository, + logs, or process arguments. Live ACL mutations occur only in the lab. +- Existing expired installation passwords of a.forest1/b.forest1 were + configured not to expire on 2026-10-07, matching the root domain. -## Validation +## Build and focused checks -- CI (`.github/workflows/ci.yml`): job `build` on `windows-2025` installs - platyPS 0.14.2, MarkdownLinkCheck 0.2.0, and Pester 5.7.1 for all users, - restores `packages.config` per project plus - `Microsoft.NETFramework.ReferenceAssemblies.net452` 1.0.3, builds - `NTFSSecurity.csproj` in Release with the MSBuild that `vswhere` finds, - then: 01 `Update-MarkdownHelp` and fail on `git diff -- Docs/Cmdlets`; 02 - `Get-MarkdownLink -BrokenOnly`; 03 regenerate the help file and fail on - `git status --porcelain -- NTFSSecurity/en-US`; 04 `Invoke-Tests.ps1` in - Windows PowerShell 5.1 and in PowerShell 7, then - `Invoke-TestsAsBasicUser.ps1` in both editions (since 5.0.0-rc6). Job - `wiki` on `ubuntu-latest` - (read-only) clones the wiki (`gh auth setup-git` with the built-in token), - runs `Export-WikiContent.ps1`, and lists the changed pages in the job - summary; job `publish-wiki` (`contents: write`) repeats that and publishes, - for `master` only. After the tests, `build` runs - `New-ModulePackage.ps1` and uploads the artifact `packages` (nupkg and - `NTFSSecurity.zip`). Job `release` runs only for tags matching - `[0-9]+.[0-9]+.[0-9]+` or `[0-9]+.[0-9]+.[0-9]+-*`, in the environment - `powershell-gallery` (secret `PSGALLERY_API_KEY`); see Decision 12. - Actions are pinned by commit SHA: `actions/checkout` v7.0.1, - `actions/upload-artifact` v7.0.1, `actions/download-artifact` v8.0.1; - Dependabot proposes updates weekly, one week after a release. -- Packaging needs PSResourceGet (`Compress-PSResource`, PowerShell 7.4 or - later); its tests skip in Windows PowerShell. Dry run locally: run - `New-ModulePackage.ps1` against `NTFSSecurity\bin\Release` into - `$env:TEMP`, then extract the nupkg into a folder and import it there. -- Read CI runs with `gh run list --repo raandree/NTFSSecurity --workflow - ci.yml`, `gh pr checks `, and `gh run view --log-failed` - (read-only). -- Workflow lint: actionlint (download the release zip into `$env:TEMP` and - check its SHA-256 against the checksum file; 1.7.12 on 2026-10-08); - PowerShell steps check - `$LASTEXITCODE` after every native command, because GitHub checks only - the last one. -- Run platyPS in Windows PowerShell 5.1 to avoid PowerShell 7.4+ - `-ProgressAction` noise. -- Placeholder check: no `{{` left in `Docs/Cmdlets/*.md`. -- Help file: `New-ExternalHelp -Path .\Docs\Cmdlets -OutputPath - .\NTFSSecurity\en-US -Force` must leave `git status` unchanged. -- Pester: run detached (`Start-DetachedPowerShell.ps1`) in Windows - PowerShell 5.1: the launcher starts `pwsh`, and its payload runs - `powershell.exe -NoProfile -EncodedCommand` with Pester imported by full - path. A run without `bin\Release\en-US` must fail. -- Tests that run only without a privilege skip in an elevated session. - `.github\scripts\Invoke-TestsAsBasicUser.ps1` runs the suite from an - elevated session with a token of the SAFER level Normal User, like - `runas /trustlevel:0x20000`, and CI runs it in both editions. For a - single file, `runas /trustlevel:0x20000` works too; give Windows - PowerShell its own `PSModulePath`, and note that `runas` returns at once, - so the script it starts writes its own log. Both tokens hold only the - privilege to bypass traverse checking. - Pester reports a skipped `-ForEach` test under its template name, such as - `<_> should ...`, and a test that ran under the expanded name: compare - runs by template. -- C# coverage (Decision 21): AltCover 9.0.145 (`tools\net472\AltCover.exe` - of the nuget.org package) instruments a copy of the local Release build, - which has the PDB files that the published package lacks: - `--reportFormat=OpenCover`, AlphaFS and `System.Management.Automation` - excluded with `--assemblyFilter`, and no `--save`: then every process - writes its hits into the report when it exits. With `--save`, each - process writes a recorder file, and `runner --collect` keeps only the - first one (verified 2026-10-08), so the numbers measured that way held - only the main process of the elevated Windows PowerShell run. Put the - instrumented module in `NTFSSecurity\bin\Release` of a `git worktree`, - run `.github\scripts\Invoke-Tests.ps1` elevated and - `Invoke-TestsAsBasicUser.ps1` in both editions, then - `AltCover.exe runner --collect --recorderDirectory=`, which recalculates the summary of the report from the hits. - All four configurations, 2026-10-08: the rc5 tree 58.1% of the lines - (2,020 of 3,476) and 38.0% of the branches (711 of 1,873), 62.5% without - 244 lines in classes that no cmdlet calls; the rc6 candidate (`1b9edbb`) - 68.1% of the lines (2,412 of 3,540) and 44.3% of the branches (850 of - 1,918), 73.2% without those classes, the `NTFSSecurity` assembly 78.1%. - The earlier figures, 55.9% for rc5 and 65.6% for rc6, used `--save`. -- Live tests (Decision 20): in an elevated Windows PowerShell 5.1 session - on the lab host, `Tests\Lab\Invoke-NTFSSecurityLabTest.ps1` with - `-Version` for Gallery packages or `-ModulePath` for a build; it writes - the results to `$env:TEMP\NTFSSecurityLab\Results`. A run of two versions - in both editions takes about 30 minutes; `-RemoveFixture` removes its - accounts, share, and folders from the lab. For a check on the client as - an account without administrator rights, use `NtfsLiveServerAdmin` - (Remote Management Users on the client, CredSSP by IP address like the - controller): reset its password on the PDC emulator to a random value - in memory; the next run of the controller sets a new one anyway. -- Lab acceptance of a candidate (modeled on the WindowsAccessControl - handoff 07): build once, package it with `New-ModulePackage.ps1`, and - record the SHA-256 of the packages and module files; check WinRM, LDAP - (RootDSE), Kerberos (`klist get`), the secure channel, and the clock of - the six VMs; take a Production checkpoint named - `ntfs-