Browse Source

Merge pull request #120 from raandree/ai/quality-gate-coverage

fix: close additional 5.0.0 quality gaps
pull/121/head
Raimund Andrée 2 days ago
committed by GitHub
parent
commit
bdb9981893
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      .github/scripts/Invoke-TestsAsBasicUser.ps1
  2. 105
      .github/scripts/Publish-ModulePackage.ps1
  3. 14
      .github/workflows/ci.yml
  4. 103
      .memory-bank/activeContext.md
  5. 288
      .memory-bank/progress.md
  6. 148
      .memory-bank/systemPatterns.md
  7. 375
      .memory-bank/techContext.md
  8. 3
      CHANGELOG.md
  9. 2
      Docs/Cmdlets/Get-ChildItem2.md
  10. 13
      Docs/Contributing/05-Releasing.md
  11. 5
      NTFSSecurity/ItemCmdlets/GetChildItem2.cs
  12. 2
      NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml
  13. 81
      Tests/BasicUserRunner.Tests.ps1
  14. 34
      Tests/FileHash.Tests.ps1
  15. 126
      Tests/Inheritance.Tests.ps1
  16. 203
      Tests/ItemCmdlets.Tests.ps1
  17. 156
      Tests/Lab/Acceptance-2026-10-09-quality-gate.md
  18. 5
      Tests/Lab/Invoke-NTFSSecurityLabTest.ps1
  19. 19
      Tests/Lab/NTFSSecurity.Live.Tests.ps1
  20. 5
      Tests/Lab/README.md
  21. 63
      Tests/PathErrors.Tests.ps1
  22. 165
      Tests/PermissionScopes.Tests.ps1
  23. 188
      Tests/Publish-ModulePackage.Tests.ps1
  24. 154
      Tests/Remove-Item2.Tests.ps1
  25. 51
      Tests/SecurityDescriptor.Tests.ps1

4
.github/scripts/Invoke-TestsAsBasicUser.ps1

@ -11,7 +11,7 @@
results through a file of this account, which the restricted token can write. results through a file of this account, which the restricted token can write.
.PARAMETER ResultPath .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 .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 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 $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 $resultFolder = Split-Path -Path $resultFullPath -Parent
if (-not (Test-Path -LiteralPath $resultFolder)) { if (-not (Test-Path -LiteralPath $resultFolder)) {
New-Item -ItemType Directory -Path $resultFolder | Out-Null New-Item -ItemType Directory -Path $resultFolder | Out-Null

105
.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
}

14
.github/workflows/ci.yml

@ -295,8 +295,8 @@ jobs:
"notes=$notes" "notes=$notes"
) )
# Publishes the package that the build job built and tested. A rerun skips # Publish the tested package; recovery and reruns verify the Gallery SHA512
# a version that the PowerShell Gallery already has. # so a different package with the same version cannot count as success.
- name: Publish to the PowerShell Gallery - name: Publish to the PowerShell Gallery
shell: pwsh shell: pwsh
env: env:
@ -304,15 +304,7 @@ jobs:
RELEASE_VERSION: ${{ steps.release.outputs.version }} RELEASE_VERSION: ${{ steps.release.outputs.version }}
RELEASE_PACKAGE: ${{ steps.release.outputs.package }} RELEASE_PACKAGE: ${{ steps.release.outputs.package }}
run: | run: |
if (-not $env:PSGALLERY_API_KEY) { & ./.github/scripts/Publish-ModulePackage.ps1 -NupkgPath $env:RELEASE_PACKAGE -Version $env:RELEASE_VERSION
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
- name: Create the GitHub release - name: Create the GitHub release
shell: pwsh shell: pwsh

103
.memory-bank/activeContext.md

@ -1,6 +1,6 @@
--- ---
status: current status: current
last-verified: 2026-10-08 last-verified: 2026-10-09
owner: active-agent owner: active-agent
source: current task evidence source: current task evidence
--- ---
@ -9,62 +9,57 @@ source: current task evidence
## Current focus ## Current focus
5.0.0-rc6 is on the PowerShell Gallery (tag `5.0.0-rc6` on `b51d970`, the Quality-gate follow-up is implemented and validated locally on
merge of #115); its GitHub release waits for a rerun of the failed Release `ai/quality-gate-coverage`, based on `d25647d` of #116. Code/test baseline
job. #116 (`ai/release-5.0.0-rc7`, base `master`) holds the behavior `3442194`, lab regression/acceptance `7594e0c`; final records follow.
changes that Phase 2 found, decided as assumptions for the maintainer's No remote mutation. Architecture/cmdlet-design choices remain deferred;
review (Decision 22), and waits for that review. Then 5.0.0-rc7, Phase 3, Decision 22 is still proposed. Stable 5.0.0 is not ready (Decision 21).
and 5.0.0; after 5.0.0 the repository is archived in favor of
WindowsAccessControl (Decision 18).
## Evidence ## Evidence
- 2026-10-08, 5.0.0-rc6: the Release job of the tag (run `37839669028`) - rc6 Release run `37839669028`, attempt 2, succeeded; GitHub prerelease
published the package at 20:40 UTC and failed after it, because with zip appeared 2026-10-09 07:01:34 UTC. First attempt proves HTTP 409
`Publish-PSResource` gave up waiting after 100 seconds and its retry got after Gallery publication, not the previously assumed retry chronology.
409 (`progress.md`, open work 8). The live tests of rc7 ran with - #116 is open, base master, head `d25647d`, CI build/wiki passed. rc7
`-Version 5.0.0-rc6`, which checks the hash of the Gallery, in both publication is pending. The follow-up does not change that PR's head.
editions, 20:43 to 21:00 UTC: all passed except the warning text that - Local changes: deletion/ownership guards (`a97e46f`); all scopes and
rc7 changed, which matches the live tests of rc6. The fixture was inheritance (`e7ee203`); absolute basic-user results (`51dec86`);
removed at 21:03 UTC and its removal checked. exact-package publication recovery (`95b827e`); first-hidden-item fix
- 2026-10-08, #116, 11 commits on `be04cb7` (`3899228` to `1063b29`) and (`d610372`); Force/descriptor guards (`3442194`); SMB regression above.
commits of records: - Hidden omission was reproduced in all four configurations before the
- Decision 22: items 1, 2, 5, 6, 7, and 8 changed, 7 and 8 breaking (the fix. No parameter/design change. Publication tests are wholly mocked;
link cmdlets require `-Path` and `-Target` and write non-terminating exact ordinal SHA-512 identity is required, no real upload occurred.
errors); items 3, 4, 9, and 10 kept, 9 with an FAQ entry. New defects, - Final uninstrumented suite: 914 each, zero failed. Passed/skipped:
fixed with a regression test that failed first: `Move-Item2` deleted elevated Desktop 890/24, Core 860/54; basic Desktop 749/165, Core 719/195.
an empty folder that it moved to another volume (AlphaFS emulated the Frozen aggregate: 2,641/3,559 sequence (74.21%), 974/1,933 branches
move); the link cmdlets failed with `GetDefaultValueFailed` for every (50.39%); NTFSSecurity assembly 84.28%. All skipped templates have
piped object; `Get-NTFSSimpleAccess` failed for a folder that came executed counterparts; mutations restored exactly before green builds.
after its parent folder a second time. - Live packaged candidate, 09:20 to 09:51 UTC: 330 passed, zero failed,
- One `security-reviewer` pass over `be04cb7..4ee01e5`: no Blocker or two expected Server-module skips. Published rc6: 326 passed, four
Major. Minor 1 to 5 and Nits 7 to 9 fixed test-first in `7936d9f` to expected failures (Hidden and rc7 warning text in each edition), two
`1063b29`; Nit 7, the warning of `Get-NTFSEffectiveAccess` for names skips. Tested folder and all 11 ZIP files are byte-identical.
of this computer, was reproduced first. Nit 6 declined (Decision 22). - Temporary host result verifier failed on Desktop JSON wrapping/full
- Suite of `1063b29`: 712 tests. Elevated: 688 passed and 24 skipped in test names; corrected verification passed on unchanged raw results in
Windows PowerShell 5.1, 658 and 54 in PowerShell 7. As a basic user: both editions. Cleanup wrapper's broad Error.Count was not acceptance
612 and 100, 582 and 130. No failure, none skipped in all four. proof. Independent probes verified all fixture objects/members/profiles
- Lab acceptance of `dc6e9f5` after the checkpoint gone from six machines. Raw failing markers and corrected evidence kept.
`ntfs-rc7-dc6e9f5-before-acceptance`, 16:24 to 16:40 UTC: 326 tests in - One read-only independent code review approved, high confidence, no
both editions, none failed, 2 skipped as in rc6 significant findings or confirmed exploit. Custom reviewer could not
(`Tests/Lab/Acceptance-2026-10-08-5.0.0-rc7.md`). The code of start (model unavailable); built-in code-review performed the one pass.
`4ee01e5` and a first run of `dc6e9f5` without the checkpoint had the - Six checkpoints exist but report Standard, even after a successful
same counts. The fixture was removed at 16:21 and 16:44 UTC, and its temporary ProductionOnly probe; policy restored, no restore performed.
removal checked each time. Do not claim verified Production rollback evidence.
- #115 passed CI in all four configurations on `be04cb7`, with the first - Wider matrix not deployed: 13 Server 2025 VMs; Windows 11/2019/2022
runs of `Invoke-TestsAsBasicUser.ps1` on GitHub runners; #116 passed CI media present, OS detection cache empty. #34 has no reply since Oct 6.
on `ebe91fe` against the rc6 branch. - Full evidence: session artifact `quality-gate-3442194-20261009`;
- #34: no reply from the tester since 2026-10-06. repository report `Tests/Lab/Acceptance-2026-10-09-quality-gate.md`.
## Next step ## Next step
1. The maintainer reruns the failed Release job of 5.0.0-rc6, which skips 1. Maintainer pushes/reviews this follow-up; retain separate commits and
the published package and creates the GitHub release, and closes #110. stacked-PR merge order (15). #116's Decision 22 review remains required.
2. He pushes the records to #116 and reviews the choices of Decision 22, 2. Integrate and pass CI, then publish/test the next candidate package.
each its own commit, above all the two breaking changes of the link 3. Close the remaining-path inventory (918 points, 562 for finer review),
cmdlets. After the CI of the push, he merges #116 with a merge commit decide/provision the OS matrix, obtain or explicitly accept #34 feedback.
(Decision 15) and tags `5.0.0-rc7`; the live tests then run against the 4. Only then release 5.0.0 through documented CI steps; never claim the
published package (`-Version 5.0.0-rc7`). current coverage percentage alone meets the quality gate.
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).

288
.memory-bank/progress.md

@ -1,203 +1,121 @@
--- ---
status: current status: current
last-verified: 2026-10-08 last-verified: 2026-10-09
owner: active-agent owner: active-agent
source: repository evidence source: repository and validation evidence
--- ---
# Progress # Progress
## Current status ## Current status
5.0.0-rc6 is on the PowerShell Gallery, published by CI on 2026-10-08 at 5.0.0-rc6 is published on the Gallery and GitHub; its failed Release job
20:40 UTC from the tag `5.0.0-rc6` on `master` (`b51d970`, the merge of recovered in attempt 2 on 2026-10-09. #116 (rc7, `d25647d`, base `master`)
pull request #115; Decision 12). The Release job failed after the upload, is open and green, not merged or published. Further quality-gate work is
so the GitHub release waits for a rerun of the failed job. The published local on `ai/quality-gate-coverage`; Phase 2 is not complete while the
package passed the live tests. Phase 2 of the quality gate (Decision 21) remaining-path inventory is open. Stable Gallery version: 4.2.6.
is complete. The pull request #116 (`ai/release-5.0.0-rc7`) holds the After 5.0.0, archive in favor of WindowsAccessControl (Decision 18).
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).
## Recent milestones ## Recent milestones
- 2026-10-02 to 2026-10-04: #91 to #97 aligned the docs with the code, - 2026-10-02 to 2026-10-06: documentation/help aligned with source, CI and
shipped the help file, kept the docs on GitHub, set version 5.0.0, moved wiki moved to GitHub Actions, versioning/release automation established,
CI and a wiki generated from `Docs` to GitHub Actions, and completed the and prereleases rc1 to rc4 published. Earlier detail is in git,
version history (Decisions 6 to 11). `CHANGELOG.md`, `Docs/Version-History.md`, and Decisions 1 to 19.
- 2026-10-04: #98 (`e0f5366`) added releases on a version tag through CI - 2026-10-07: lab comparison of rc2/rc4 reproduced #34 over SMB and proved
(Decision 12). The tag `5.0.0-rc1` published to the Gallery and created changed-section writes preserve the owner. Remote effective-access
the GitHub prerelease; the installed module passed the full suite. fallback returned no result; fixed test-first for rc5 (Decision 20).
- 2026-10-05, overnight run: the 24 code defects of work package 5 and the - 2026-10-08: #114 merged (`fcb370e`), rc5 published and live-tested.
issues #3, #4, #5, #17, #74, #82, #86, and #88 fixed with regression Decision 21 established the quality gate. Corrected four-run coverage:
tests, plus what one security review per PR found; the 37 open issues rc5 58.1% sequence points/38.0% branches, not the initial one-run result.
triaged; `Docs/FAQ.md`, Dependabot for the actions, and the label `rc2`. - 2026-10-08: rc6 Phase 2 added basic-user CI, parameter-set/error tests,
#5 and #82 are breaking changes, like the change of Decision 13. expanded domain/SMB cases, and fixes for privilege cleanup, path
- 2026-10-05: the first CI runs of the eight PRs found a defect that the resolution, ownership retries, item conflicts, output equality, and
workstation had skipped, fixed in `629f4e7` (audit inheritance of an item inherited flags. Suite: 677; coverage 68.14% sequence/44.32% branches.
without a SACL). The PRs #99 to #106 were merged in order with merge Lab acceptance of `7b0781f` passed; two independent review passes.
commits, CI on `master` passed, and the tag `5.0.0-rc2` published the - 2026-10-08: rc7 implemented proposed Decision 22, including breaking
prerelease; GitHub's Actions outage that day cancelled the first two link binding/error changes, SimpleAccess traversal, cross-volume folder
attempts of the release run before they started. Repository hardening is preservation, and named effective-access warnings. Suite: 712, no
optional (Decision 14); the merge rule and the maintainer's rule for failures or test skipped everywhere. One review: no Blocker/Major.
fixes are Decisions 15 and 16. Lab candidate `dc6e9f5`: 326 passed, 2 skipped, cleanup verified.
- 2026-10-06: the issues got their labels (Decision 17). The maintainer - 2026-10-08: #115 merged (`b51d970`) and tagged rc6. Release run
decided to publish 5.0.0-rc3 before 5.0.0 and to archive the project in `37839669028` failed with HTTP 409 after Gallery publication. The log
favor of WindowsAccessControl (Decision 18); the README, the docs home, does not establish the previously assumed initial timeout/retry cause.
and the changelog announce it. Published rc6 live tests differed only in rc7's warning expectation.
- 2026-10-06: 5.0.0-rc3 (#112): the access and audit cmdlets write only - 2026-10-09: release attempt 2 succeeded; rc6 GitHub prerelease and zip
the section that they change, which fixes #34 and the inherited entries appeared at 07:01:34 UTC. #116 passed CI on `d25647d`.
that elevated sessions copied as explicit ones (Decision 19). #67 has its - 2026-10-09: autonomous follow-up on `ai/quality-gate-coverage`, through
cause outside the module (a share root over UNC can't re-inherit), is `3442194`, adds 202 cases above rc7: deletion/owner failures, all 13
explained in `Docs/FAQ.md`, and was closed as not planned. One scopes, inheritance transitions, enumeration, forced replacement,
`security-reviewer` pass approved the branch. The PR description said descriptor failures, and offline CI recovery. Reproduced/fixed rooted
"fixes #34", so the merge closed #34; it was reopened for a tester. result-path handling and first-hidden-item omission. Publication recovery
- 2026-10-06: 5.0.0-rc4 (#113), test-first: drive and volume roots read verifies exact SHA-512 identity, not merely version existence.
and change their root folder, not the device (#41); the audit cmdlets - 2026-10-09: final uninstrumented suite: 914 per configuration, zero
reject a descriptor without the audit entries (#109); `-WhatIf` previews failures; coverage: 2,641/3,559 sequence points (74.21%) and 974/1,933
`Copy-Item2` and `Move-Item2` despite an existing destination (#108); branches (50.39%), aggregate of four runs without AltCover `--save`.
small items (#111). One `security-reviewer` pass approved it; its Minor All skipped templates have executed counterparts. Mutation guards were
findings R1, R2, R6, and R8 were fixed before the merge. #41, #108, proved and production source restored; Release build/checks pass.
#109, and #111 closed as completed, #90 and #107 as not planned. - 2026-10-09: live comparison, 09:20 to 09:51 UTC: candidate 330 passed,
- 2026-10-07: live tests in the lab of WindowsAccessControl (Decision 20) zero failed, two expected skips; published rc6 four expected Hidden/
against 5.0.0-rc2 and 5.0.0-rc4 in both editions: rc2 fails #34 over SMB warning-text failures only. Independent cleanup probes verified fixture
with error 1307 for `Add-NTFSAccess`, `Clear-NTFSAccess`, and absence; raw host-verifier failures retained with corrected verification.
`Set-NTFSSecurityDescriptor`; rc4 passes the four cases, except Lab guards/acceptance committed in `7594e0c`. One independent code review
`Get-NTFSEffectiveAccess -ServerName` with a computer that can't be approved with no significant finding (custom model unavailable; built-in
reached, which returned no access since before rc1. The maintainer chose fallback). All 11 tested files match the ZIP. OS/path gates stay open.
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`.
## Stable capabilities ## Stable capabilities
- 36 cmdlets: access (7), audit (5), inheritance (6), owner and security - 36 cmdlets: access, audit, inheritance, owners/descriptors, privileges,
descriptor (4), privileges (3), long-path items (6), links, hash, and long-path items, links, hash, and disk space.
disk space (5). - Windows PowerShell 5.1 and PowerShell 7; RIPEMD160 and MACTripleDES are
- Works in Windows PowerShell 5.1 and PowerShell 7. In PowerShell 7, available only in Desktop. Both editions run elevated/basic-user in CI.
`Get-FileHash2` lacks `RIPEMD160` and `MACTripleDES`, which .NET lacks. - Pester fixtures use `Tests/TestHelpers.psm1` TEMP sandboxes. Live tests
- Pester tests in `Tests\` run in `$env:TEMP` sandboxes through are excluded from CI and run only on approved lab client/server targets.
`Tests\TestHelpers.psm1`; tests that need privileges skip without them
and run in CI, whose runners are elevated.
## Open work ## Open work
1. Quality gate before 5.0.0 (Decision 21): the maintainer reruns the 1. Decision 21 gate: review Decision 22, integrate reviewed quality-gate
failed Release job of 5.0.0-rc6, which creates the GitHub release; follow-up, publish the next candidate, and test the published package.
reviews the choices of Decision 22 in #116; merges #116 and tags Do not release 5.0.0 until the remaining-path and OS-matrix gates close.
5.0.0-rc7, whose published package then runs the live tests. Phase 3 Release steps: `Docs/Contributing/05-Releasing.md`; remove prerelease
runs the live tests on more operating systems. Then release 5.0.0 label, date `[5.0.0]`, update `$publishedVersions`, tag through CI.
through CI (Decision 12): remove the label, date 2. Issues: #110's seven items were addressed by rc6, but #115 deliberately
`[Unreleased]` as `[5.0.0]`, add the last prerelease to used no closing keyword. #34 stays open for non-Windows owner feedback
`$publishedVersions`, and tag `5.0.0` (steps in or maintainer acceptance. #16, #21, #45, #89 await reporters. #68 tracks
`Docs/Contributing/05-Releasing.md`). #34 stays open with Bug and Help ShouldProcess for security cmdlets; enhancements #22/#49/#68/#77/#87
Wanted until a tester with a file server that refuses the owner are not planned for 5.0.0. Labels follow Decision 17.
confirms the fix, or until 5.0.0 ships. 3. Deferred reviews (not silently accepted): rc3 extra DACL read/SDDL
2. Issues: 5.0.0-rc6 addresses the seven items of #110 (tests); #115 snapshots/duplicate SACL check; rc4 findings listed in #113, including
named it without a closing keyword, so the maintainer closes it now. library-only RemoveAll account filters; rc5 unchecked Authz errors and
#21 (a misleading error of `Move-Item2`) got lab-controller hardening; rc6 failed privilege-disable retry (not
a fix in rc6 that names the missing destination folder; the folder reproduced); rc7 audit missing-path error IDs declined in Decision 22.
moves to another volume that rc7 fixes are a different defect. #68 4. Architecture/cmdlet design: Decision 22 remains proposed; two link
tracks `-WhatIf` and `-Confirm` for every cmdlet that changes security. changes are breaking. Keep unused classes/helper overloads until a
The labels follow Decision 17; #16, #21, #45, and #89 wait for their maintainer decision; do not remove them to improve coverage percentages.
reporters (Needs Info). Not planned for 5.0.0: the enhancements #22, 5. ARM64 workstation: PowerShell 7.6.1 crashed under x64 emulation without
#49, #68, #77, #87. module frames; native-x64 CI did not reproduce it.
3. Review findings, not filed: of rc3, an extra DACL read and four SDDL 6. Optional maintainer cleanup: obsolete AppVeyor/Read the Docs access,
snapshots on read paths, and a duplicate SACL check; of rc4, R3 to R5, wiki editing restrictions, `test/transfer`, and old lab checkpoints
R7, and five older defects, listed in the description of #113, among when no longer needed. No remote changes or snapshot restores here.
them the accounts filter that `RemoveFileSystemAccessRuleAll` and 7. Fresh coverage inventory at `3442194`: 918 unvisited sequence points.
`RemoveFileSystemAuditRuleAll` ignore, which no cmdlet passes; of rc5, Of these, 244 are in classes unused by cmdlets and 112 in parameter
the bare `catch` in `Win32.GetEffectiveAccess`, the unchecked getters; 562 remain for finer review/testing, including unused overloads,
`AUTHZ_ACCESS_REPLY.Error`, and hardening of the lab controller (guards defensive/native failures, and environment-specific branches. High-value
in the setup blocks, interpolated `-EncodedCommand` paths, CredSSP by local gaps closed: folders/Force/DeleteError, RestoreOwnerError, all
IP address, the password string in memory, disabling the role accounts scopes, file/folder inheritance, enumeration/depth/link skipping,
after a run); of rc6, a privilege that fails to be disabled isn't tried descriptor write failures, and forced file replacement. Remaining
again by `Dispose` (finding 2, not reproducible); of rc7, one error ID candidates: audit ownership-retry failures, SD inheritance edge cases,
for a missing path in the audit cmdlets (declined, Decision 22). effective-access unresolved identity, recursive denial/error surfaces,
4. Behavior changes found in Phase 2 (Decision 16): decided in Decision 22 output-object comparisons/formatting. A conditional ACE display remains
as assumptions for the maintainer's review, on `ai/release-5.0.0-rc7`; a .NET representation limit, not evidence of unconditional permissions.
two of them are breaking changes of the link cmdlets. Left for Phase 3: 8. Publication recovery is implemented locally in `95b827e`, with 14 offline
400 points of code that nothing calls besides the 244 lines of unused tests and exact artifact SHA-512 verification. Original upload errors
classes. remain errors for missing/different/unverifiable outcomes. Not deployed
5. `pwsh` 7.6.1 crashed three times during test runs on the ARM64 until the maintainer merges/pushes; no publication was performed here.
workstation (x64 emulation), without module frames; none of the CI runs 9. Phase 3: choose OS scope (proposed Windows 11 client/2019/2022 servers),
on native x64 on 2026-10-05 crashed. detect ISO editions, provision without repurposing shared VMs, then run
6. Optional for the maintainer: delete the AppVeyor project and revoke its published-package acceptance. #34 has no new reply since 2026-10-06.
GitHub authorization, restrict wiki editing to collaborators, ask 10. Lab rollback evidence: new checkpoints exist but report Standard even
`Sup3rlativ3` to delete the Read the Docs project, and delete the branch after a successful temporary ProductionOnly probe. Classification is
`test/transfer`. In the lab, delete the checkpoints unresolved; original VM policy restored, no checkpoint restored. Do
`ntfs-rc6-*-before-acceptance` and `ntfs-rc7-*-before-acceptance` of the not represent these as verified Production snapshots.
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.

148
.memory-bank/systemPatterns.md

@ -1,57 +1,27 @@
--- ---
status: current status: current
last-verified: 2026-10-08 last-verified: 2026-10-09
owner: active-agent owner: active-agent
source: repository evidence source: repository and regression evidence
--- ---
# System patterns # System patterns
## Architecture ## Architecture
```text | Component | Responsibility |
NTFSSecurity.psd1 ─┬─ ScriptsToProcess: NTFSSecurity.Init.ps1 | --- | --- |
│ Add-Type: Security2.dll, PrivilegeControl.dll, | `NTFSSecurity.psd1` | Root script, nested binary, initialization, types, help |
│ ProcessPrivileges.dll, inline NTFS.DriveInfoExt; | `NTFSSecurity.Init.ps1` | Loads Security2/privilege assemblies and prepends formatting |
│ Update-FormatData -PrependPath format.ps1xml | `NTFSSecurity.dll` | 36 PowerShell cmdlets; BaseCmdlet path/privilege behavior |
├─ TypesToProcess: NTFSSecurity.types.ps1xml | `Security2.dll` | DACL/SACL objects, owners, inheritance, effective access, Win32 |
│ (Owner, IsInheritanceBlocked, LengthOnDisk on | AlphaFS | Long-path files/directories/links |
│ FileInfo/DirectoryInfo; AccountType on ACEs) | PrivilegeControl / ProcessPrivileges | Token privilege operations |
├─ RootModule: NTFSSecurity.psm1 (aliases) | `en-US/NTFSSecurity.dll-Help.xml` | Committed help generated from cmdlet Markdown |
├─ 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`.
## Decisions ## Decisions
Each Decision record is a file in `decisions/`; read only the relevant ones. Read only task-relevant records; the index controls routing.
| # | Decision | | # | Decision |
| --- | --- | | --- | --- |
@ -80,65 +50,45 @@ Each Decision record is a file in `decisions/`; read only the relevant ones.
## Patterns ## Patterns
### Writing cmdlets ### Cmdlets and security sections
- A parameter that takes pipeline input needs a getter that doesn't - BaseCmdlet resolves relative paths against the current filesystem
throw: PowerShell reads it before it binds each input object, and an location; file-object input binds FullName through path transformation.
exception turns every object into `GetDefaultValueFailed` (the link - Write only changed/read sections (Decision 19); a descriptor parameter
cmdlets before 5.0.0-rc7). changes memory until `Set-NTFSSecurityDescriptor` persists it.
- An error for one item is non-terminating, so that the cmdlet goes on - Access denial can retry through InvokeAsOwner; restore the previous owner
with the next path or pipeline object; since 5.0.0-rc7, the link cmdlets on every exit, except a successful descriptor write that intentionally
too. Its message names the item, and its target object is the item that sets the owner. Restoration failures must report RestoreOwnerError.
the cmdlet was asked to process. Resolving a path can throw in Windows - Privilege cleanup runs in EndProcessing and Dispose, reads current states,
PowerShell for an invalid character, so that belongs inside the attempts all cleanup, and preserves explicit enables. Dispose has no stream.
per-item error handling. - Pipeline getters never throw; per-item errors name input and allow continuation.
- Folders move without `MoveOptions.CopyAllowed`: for another volume, - Folder moves never use CopyAllowed; preserve cross-volume source folders.
AlphaFS then copies and deletes, which lost empty folders. Windows - Apply implied Hidden/Force before deciding to emit, including the first item.
refuses such a move with `NotSameDeviceException` (17). Tests reach
another volume through `\\localhost\C$`, elevated only.
### Verifying documentation ### Tests and documentation
- Run platyPS in Windows PowerShell 5.1 against a Release build; a copy of - Tests import Release in isolated processes, both editions and privilege
`Docs/Cmdlets` must round-trip through `Update-MarkdownHelp` unchanged. modes. File/ACL/link fixtures use shared sandbox guards and cleanup.
Keep cmdlet pages ASCII-only. platyPS takes `Position` and `Required` from Privilege-dependent skips must have eligible counterparts in the matrix.
the shipped help file: after such a change, edit the page YAML, run - Assert persisted state, errors/targets, continuation, and no failed
`New-ExternalHelp`, rebuild, and check the round trip. PassThru output. Prove new characterization guards with bounded mutations;
- MarkdownLinkCheck checks relative `Docs` links, `Tests\Wiki.Tests.ps1` restore source exactly and rebuild before green validation or packaging.
the wiki links and anchors. The wiki is generated from `Docs` (never edit - Fixture DACLs use .NET SetAccessControl, not Set-Acl's unintended SACL writes.
it); `Docs/README.md` becomes Home, its cmdlet groups the sidebar. - Scope/descendant expectations are independent of the production converter.
- In cmdlet pages, end a sentence with a link (platyPS drops the space - Desktop platyPS: generate help, rebuild, round-trip unchanged, check links.
after it). Verify examples in a `$env:TEMP` sandbox, never on real data. - 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 - Use Path.Combine then GetFullPath for a rooted-or-repository-relative
them in Windows PowerShell 5.1 and PowerShell 7 (Decision 11). result path; Join-Path appends even a rooted child and corrupts it.
- A test that changes files, links, or security descriptors uses - Discovery handles only expected PackageNotFound as absence; repository,
`Tests\TestHelpers.psm1`: its own sandbox, `Assert-TestSandboxPath` authentication, and network errors remain failures.
before each change, `Remove-TestSandbox`. Cases that need a privilege - Rerun/uncertain-upload success requires Gallery SHA-512 equality with the
skip with `Test-PrivilegeHeld`, cases that need its absence skip when exact build artifact. Base64 is case-sensitive: use ordinal comparison.
elevated; CI runs the suite elevated and as a basic user in both Missing/different/unverifiable metadata preserves the upload error.
editions, so each case runs somewhere. `Block-Test*` make a read or a - Secrets stay by environment reference, never in process arguments/logs.
write fail without elevation; `Set-TestOwner` with Test all external publication commands with mocks; no test may upload.
`EnablePrivileges = $false` reproduces an owner the user can't assign. - AltCover aggregates all four sequential runs without --save. Report
- Fixtures write a DACL with `SetAccessControl`, never with `Set-Acl`: sequence points, not unique source lines; keep unmatched paths visible.
`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.

375
.memory-bank/techContext.md

@ -1,251 +1,156 @@
--- ---
status: current status: current
last-verified: 2026-10-08 last-verified: 2026-10-09
owner: active-agent owner: active-agent
source: repository evidence source: repository and executable evidence
--- ---
# Tech context # Tech context
## Stack ## Stack
- C# class libraries, old-style `.csproj`, .NET Framework 4.5.2, - Legacy C# projects, .NET Framework 4.5.2, `NTFSSecurity.sln`.
solution `NTFSSecurity.sln` (Visual Studio 2017 format). Cmdlets depend on Security2, PrivilegeControl/ProcessPrivileges, and
- Projects: `NTFSSecurity` (cmdlets), `Security2` (ACL object model, Win32 AlphaFS 2.2.x. System.Management.Automation reference: 10.0.10586.0.
interop), `PrivilegeControl` and `ProcessPrivileges` (token privileges), - Module supports Windows PowerShell 5.1 and PowerShell 7; 36 cmdlets.
`Log`, `TestClient`, `NTFSSecurityTest` (MSTest, minimal coverage). Manifest initializes helper assemblies, aliases, type data, formatting,
- NuGet (`packages.config`): AlphaFS 2.2.x for long paths; and committed help generated from `Docs/Cmdlets` (platyPS 0.14/schema 2).
`System.Management.Automation.dll` 10.0.10586.0. For a drive or volume - CI: `.github/workflows/ci.yml`, scripts in `.github/scripts`; GitHub
root, AlphaFS `DirectoryInfo` reaches the device object, while renders Docs and publishes a generated wiki. No separate docs site.
`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).
## Environment ## Current environment
- Windows only (NTFS, Win32 security APIs). - Host `ExHost`: Windows Server 2025 VM, native x64, elevated agent;
- The Debug build writes straight into repository `V:\Git\NTFSSecurity`. AutomatedLab 5.61.704, Hyper-V,
`C:\Program Files\WindowsPowerShell\Modules\NTFSSecurity\`. approved lab `WindowsAccessControlLab` (Decision 20).
- No Visual Studio MSBuild or .NET Framework targeting pack on the - Build Release only: Debug writes to Program Files. Native .NET Framework
workstation. A local build works with the .NET Framework MSBuild MSBuild plus Roslyn `Microsoft.Net.Compilers` 4.2.0 and .NET 4.5.2
(`%WINDIR%\Microsoft.NET\Framework64\v4.0.30319\MSBuild.exe`) plus reference assemblies work; legacy compiler fails CS0136, dotnet MSBuild
`/p:CscToolPath` to the Roslyn `csc.exe` of the `Microsoft.Net.Compilers` fails binary resources MSB3822/MSB3823. Build packages are already cached
package; the legacy C# 5 compiler fails with CS0136. `dotnet msbuild` in `packages`; compiler/tools are under TEMP `ntfs-build`.
fails on the binary resources in `Resources.resx` (MSB3822, MSB3823). - Pester 5.7.1 is in
- platyPS 0.14.2, Pester 5.7.1, PSScriptAnalyzer, and powershell-yaml are `V:\Git\WindowsAccessControl\output\RequiredModules\Pester\5.7.1`.
installed only for PowerShell 7. Windows PowerShell 5.1, started from platyPS 0.14.2 and MarkdownLinkCheck 0.2.0 are under TEMP `ntfs-docs-tools`.
PowerShell 7, imports platyPS and Pester by full path PSScriptAnalyzer and PSResourceGet are available in PowerShell 7.
(`~\OneDrive\Documents\PowerShell\Modules\platyPS\0.14.2`, - Use Desktop's module paths in Desktop children, not inherited Core-only
`C:\Program Files\PowerShell\Modules\Pester\5.7.1`). Leave paths. Never import NTFSSecurity in the agent shell; every package/build
`$env:PSModulePath` alone: PowerShell 7 hands the child the Windows runs in a new process. Current prereleases share assembly version 5.0.0.0.
PowerShell default path, and clearing it leaves Windows PowerShell without - GitHub CLI: `C:\Program Files\GitHub CLI\gh.exe`, signed in as raandree.
its core modules (Pester fails: `Add-Member` not found). Read-only queries work; remote mutations belong to the maintainer.
- MarkdownLinkCheck is not installed, and `Save-Module` crashed (FailFast) - LabSources: `V:\LabSources`. All 13 deployed machines are Server 2025.
in PowerShell 7.6 on 2026-10-04. Download the 0.2.0 package from Windows 11 consumer/enterprise-evaluation media and Server 2019/2022
`https://www.powershellgallery.com/api/v2/package/MarkdownLinkCheck/0.2.0` ISO files exist. OS cache is empty; exact detected editions are not yet
into `$env:TEMP`, extract it, and import it by path. verified. Do not equate present media with a deployed/tested OS matrix.
- 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\<Id>.<Version>`, 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/<id>/<version>/<id>.<version>.nupkg`,
extract the first three into `packages\<Id>.<Version>` 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/<id>/<version>.json`,
then `catalogEntry`), and each Gallery package against `PackageHash` of
`api/v2/Packages(Id='<id>',Version='<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`.
## Constraints ## Constraints
- `ModuleVersion` is `5.0.0` with the prerelease label `rc6` on the branch - Source manifest: ModuleVersion 5.0.0, prerelease rc7 on #116/follow-up.
`ai/release-5.0.0-rc6` (`rc5` on `master`). Latest stable: 4.2.6; latest published prerelease: rc6 (2026-10-08).
The latest stable tag and Gallery release is `4.2.6`. The manifest GitHub rc6 release recovered 2026-10-09. rc7 publication is pending.
requires PowerShell 5.1 and .NET Framework 4.5.2, uses `RootModule`, and - Changed-section writes preserve unchanged owner/group/DACL/SACL (19).
lists exactly 36 cmdlets; `Test-ModuleManifest` passes in Windows Roots use root-folder APIs, not AlphaFS device security (#41).
PowerShell 5.1 and PowerShell 7.6. - CHANGELOG contains user-visible changes only (7); tests and CI-only fixes
- The module source at `master` differs from tag `4.2.6` by the changes get no entry. No stable release until Decision 21 gates close.
that `CHANGELOG.md` lists under `[Unreleased]`, the release notes of each - Honor separate topic branches, no amendment, two AI co-author trailers.
5.0.0 prerelease. Never work around remote-mutation blocking. Provide each maintainer
- PowerShell Gallery versions (publish dates): 4.0.0 (2015-08-19), 4.2.2 command separately at reply end, no question dialog after commands.
(2016-05-18), 4.2.3 (2016-05-19), 4.2.4 (2018-08-13), 4.2.5 (2019-07-11), Issue references use no closing keyword unless closure is intended.
4.2.6 (2019-07-12), none with release notes; 5.0.0-rc1 (2026-10-04), - Lab passwords stay in memory and are lab-only; no secret in repository,
5.0.0-rc2 (2026-10-05), 5.0.0-rc3 and 5.0.0-rc4 (2026-10-06), 5.0.0-rc5 logs, or process arguments. Live ACL mutations occur only in the lab.
(2026-10-08), published - Existing expired installation passwords of a.forest1/b.forest1 were
by CI. Older versions were released on CodePlex only, and their dates are configured not to expire on 2026-10-07, matching the root domain.
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.
## Validation ## Build and focused checks
- CI (`.github/workflows/ci.yml`): job `build` on `windows-2025` installs - Build `NTFSSecurity\NTFSSecurity.csproj` with Configuration=Release,
platyPS 0.14.2, MarkdownLinkCheck 0.2.0, and Pester 5.7.1 for all users, Framework MSBuild, TargetFrameworkRootPath/FrameworkPathOverride to
restores `packages.config` per project plus `packages\Microsoft.NETFramework.ReferenceAssemblies.net452.1.0.3\build`,
`Microsoft.NETFramework.ReferenceAssemblies.net452` 1.0.3, builds CscToolPath to the cached compiler. Expected legacy CS1591/CS0618 warnings
`NTFSSecurity.csproj` in Release with the MSBuild that `vswhere` finds, are not new failures. Never copy a mutated DLL into acceptance artifacts.
then: 01 `Update-MarkdownHelp` and fail on `git diff -- Docs/Cmdlets`; 02 - Pester/builds run in detached monitored child processes through
`Get-MarkdownLink -BrokenOnly`; 03 regenerate the help file and fail on `Start-DetachedPowerShell.ps1`; use unique TEMP logs/result paths and an
`git status --porcelain -- NTFSSecurity/en-US`; 04 `Invoke-Tests.ps1` in explicit-PID watcher. No foreground sleep/poll loop. Long payloads use
Windows PowerShell 5.1 and in PowerShell 7, then a script file: nested Base64 encoding can exceed Windows command limits.
`Invoke-TestsAsBasicUser.ps1` in both editions (since 5.0.0-rc6). Job - Focused helper: TEMP `ntfs-focused\Start-FocusedRuns.ps1`; detach that
`wiki` on `ubuntu-latest` driver too because its internal wait loop must not block the agent shell.
(read-only) clones the wiki (`gh auth setup-git` with the built-in token), - TEMP `ntfs-docs-tools\Invoke-ChangeChecks.ps1 -File <relative paths>`
runs `Export-WikiContent.ps1`, and lists the changed pages in the job performs AST/analyzer/lint/help checks. Absolute input paths misroute
summary; job `publish-wiki` (`contents: write`) repeats that and publishes, cmdlet pages. Check actual analyzer/lint output, not just helper exit.
for `master` only. After the tests, `build` runs - actionlint 1.7.12 checks the workflow. Script changes use AST parse and
`New-ModulePackage.ps1` and uploads the artifact `packages` (nupkg and PSScriptAnalyzer; prose Markdown uses MD013 and changelog siblings-only
`NTFSSecurity.zip`). Job `release` runs only for tags matching repeated-heading allowance. Native error codes must be checked explicitly.
`[0-9]+.[0-9]+.[0-9]+` or `[0-9]+.[0-9]+.[0-9]+-*`, in the environment - Documentation: run platyPS in Desktop, generate external help, rebuild,
`powershell-gallery` (secret `PSGALLERY_API_KEY`); see Decision 12. require an unchanged Markdown round trip. Links in Docs are checked
Actions are pinned by commit SHA: `actions/checkout` v7.0.1, relatively; Wiki tests cover generated anchors, not arbitrary web URLs.
`actions/upload-artifact` v7.0.1, `actions/download-artifact` v8.0.1;
Dependabot proposes updates weekly, one week after a release. ## CI and packaging
- Packaging needs PSResourceGet (`Compress-PSResource`, PowerShell 7.4 or
later); its tests skip in Windows PowerShell. Dry run locally: run - CI `build` on windows-2025 installs tools, restores dependencies, builds
`New-ModulePackage.ps1` against `NTFSSecurity\bin\Release` into Release, round-trips pages/help, checks links, and runs the suite in
`$env:TEMP`, then extract the nupkg into a folder and import it there. Desktop/Core, elevated/basic user. Lab tests are explicitly excluded.
- Read CI runs with `gh run list --repo raandree/NTFSSecurity --workflow - `.github/scripts/Invoke-TestsAsBasicUser.ps1` launches a SAFER Normal User
ci.yml`, `gh pr checks <number>`, and `gh run view <id> --log-failed` token. Result paths may be absolute or repository-relative: Path.Combine
(read-only). then GetFullPath, not Join-Path with a rooted child.
- Workflow lint: actionlint (download the release zip into `$env:TEMP` and - Packaging needs Compress-PSResource (Core 7.4+). New-ModulePackage copies
check its SHA-256 against the checksum file; 1.7.12 on 2026-10-08); only FileList, validates the manifest, creates nupkg plus NTFSSecurity.zip.
PowerShell steps check Check package/file hashes and test the extracted artifact, not build extras.
`$LASTEXITCODE` after every native command, because GitHub checks only - Release runs only for validated version tags in powershell-gallery.
the last one. API key stays as PSGALLERY_API_KEY environment reference. Helper
- Run platyPS in Windows PowerShell 5.1 to avoid PowerShell 7.4+ Publish-ModulePackage treats only PackageNotFound as expected absence;
`-ProgressAction` noise. existing-version skip and uncertain-upload recovery require exact Gallery
- Placeholder check: no `{{` left in `Docs/Cmdlets/*.md`. SHA-512 equality. Base64 comparison is case-sensitive. Unverifiable,
- Help file: `New-ExternalHelp -Path .\Docs\Cmdlets -OutputPath missing, and different outcomes preserve errors. No test uploads.
.\NTFSSecurity\en-US -Force` must leave `git status` unchanged. - Read status through gh pr checks / gh run view --log-failed. A successful
- Pester: run detached (`Start-DetachedPowerShell.ps1`) in Windows Gallery upload followed by HTTP 409 does not prove its retry chronology.
PowerShell 5.1: the launcher starts `pwsh`, and its payload runs
`powershell.exe -NoProfile -EncodedCommand` with Pester imported by full ## Coverage and test eligibility
path. A run without `bin\Release\en-US` must fail.
- Tests that run only without a privilege skip in an elevated session. - AltCover 9.0.145 net472 instruments a copied Release build with PDBs,
`.github\scripts\Invoke-TestsAsBasicUser.ps1` runs the suite from an OpenCover format, localSource, excluding AlphaFS/System.Management.Automation.
elevated session with a token of the SAFER level Normal User, like Do not use --save: collection previously retained only one process's hits.
`runas /trustlevel:0x20000`, and CI runs it in both editions. For a - Freeze a git worktree, instrument its NTFSSecurity\bin\Release, run all
single file, `runas /trustlevel:0x20000` works too; give Windows four configurations sequentially with the real CI wrappers, then
PowerShell its own `PSModulePath`, and note that `runas` returns at once, AltCover runner --collect recalculates the report. Compute option paths
so the script it starts writes its own log. Both tokens hold only the before passing native arguments, not inline Join-Path expressions.
privilege to bypass traverse checking. - Report sequence points, not unique source lines. Four-run baselines:
Pester reports a skipped `-ForEach` test under its template name, such as rc5 2,020/3,476 (58.1%), branches 711/1,873 (38.0%);
`<_> should ...`, and a test that ran under the expanded name: compare rc6 2,412/3,540 (68.14%), branches 850/1,918 (44.32%);
runs by template. follow-up `3442194` 2,641/3,559 (74.21%), 974/1,933 (50.39%).
- C# coverage (Decision 21): AltCover 9.0.145 (`tools\net472\AltCover.exe` NTFSSecurity assembly: 1,769/2,099 (84.28%). Different code changes
of the nuget.org package) instruments a copy of the local Release build, denominators; never present these as same-source incremental percentages.
which has the PDB files that the published package lacks: - Final suite: 914 per configuration, zero failures. Passed/skipped:
`--reportFormat=OpenCover`, AlphaFS and `System.Management.Automation` elevated Desktop 890/24, Core 860/54; basic Desktop 749/165, Core 719/195.
excluded with `--assemblyFilter`, and no `--save`: then every process - NUnit skipped ForEach names retain placeholders and parameter tuples,
writes its hits into the report when it exits. With `--save`, each executed names expand them. Strip trailing data tuples and match templates;
process writes a recorder file, and `runner --collect` keeps only the raw-name intersection or positional alignment is invalid across editions.
first one (verified 2026-10-08), so the numbers measured that way held 139 skipped templates have eligible executed counterparts. Inspect input
only the main process of the elevated Windows PowerShell run. Put the eligibility when an individual data row has a condition of its own.
instrumented module in `NTFSSecurity\bin\Release` of a `git worktree`, - Remaining inventory: 918 points, including 244 in cmdlet-unused classes,
run `.github\scripts\Invoke-Tests.ps1` elevated and 112 parameter-getter points, and 562 awaiting finer classification/testing.
`Invoke-TestsAsBasicUser.ps1` in both editions, then Preserve raw XML, eligibility CSV, logs, commit identity, and build hashes.
`AltCover.exe runner --collect --recorderDirectory=<the instrumented
folder>`, which recalculates the summary of the report from the hits. ## Lab acceptance
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 - Defaults: F1ADC1 (domain), F1AFile2 (server), F1AFile1 (client), all in
244 lines in classes that no cmdlet calls; the rc6 candidate (`1b9edbb`) a.forest1.net. Foreign accounts use F1BDC1, F2DC1, F3DC1 and existing trusts.
68.1% of the lines (2,412 of 3,540) and 44.3% of the branches (850 of Controller accepts alternate machines; changing topology/OS scope waits
1,918), 73.2% without those classes, the `NTFSSecurity` assembly 78.1%. for a maintainer decision. Do not repurpose another project's shared VMs.
The earlier figures, 55.9% for rc5 and 65.6% for rc6, used `--save`. - Before a run: authenticated WinRM, LDAP RootDSE, Kerberos tickets, member
- Live tests (Decision 20): in an elevated Windows PowerShell 5.1 session secure channels, clocks; checkpoint only approved targets. Inspect actual
on the lab host, `Tests\Lab\Invoke-NTFSSecurityLabTest.ps1` with checkpoint kind: new checkpoints reported Standard even after a successful
`-Version` for Gallery packages or `-ModulePath` for a build; it writes temporary ProductionOnly request. Policy restored; no rollback performed;
the results to `$env:TEMP\NTFSSecurityLab\Results`. A run of two versions Production classification remains unverified, not a passed safety check.
in both editions takes about 30 minutes; `-RemoveFixture` removes its - Run Tests\Lab\Invoke-NTFSSecurityLabTest.ps1 in elevated Desktop with
accounts, share, and folders from the lab. For a check on the client as -Version for hash-checked Gallery packages or -ModulePath for the extracted
an account without administrator rights, use `NtfsLiveServerAdmin` build artifact, both editions. Per version/edition: Delegate, ServerAdmin,
(Remote Management Users on the client, CredSSP by IP address like the Admin on client, then Server independently checks persisted state.
controller): reset its password on the PDC emulator to a random value - Controller writes Summary.json even when tests fail: validate every role,
in memory; the next run of the controller sets a new one anyway. exit code, failure name, and total; DONE alone is not acceptance evidence.
- Lab acceptance of a candidate (modeled on the WindowsAccessControl Desktop ConvertFrom-Json can wrap arrays; explicitly enumerate the result
handoff 07): build once, package it with `New-ModulePackage.ps1`, and and compare full Describe-prefixed names. Never gate cleanup on the global
record the SHA-256 of the packages and module files; check WinRM, LDAP Error.Count, which includes handled errors; independently verify footprint.
(RootDSE), Kerberos (`klist get`), the secure channel, and the clock of - Remote Authz answers administrators and Access Control Assistance
the six VMs; take a Production checkpoint named Operators (S-1-5-32-579); other accounts get access denied. Check firewall
`ntfs-<label>-<commit>-before-acceptance` of `F1ADC1`, `F1BDC1`, when remote resource-manager RPC fails. Expected rights use S4U tokens.
`F2DC1`, `F3DC1`, `F1AFile1`, and `F1AFile2`; run the controller with - RemoveFixture after the run; verify OUs/accounts, share, folders, local
`-ModulePath` of the extracted `NTFSSecurity.zip` in both editions; then memberships, and test profiles removed. Credentials must never be printed.
`-RemoveFixture` and check that the accounts, share, folders, group
memberships, and profiles are gone.
- `Get-NTFSEffectiveAccess -ServerName`: the authorization manager of the
named computer answers only its administrators and the members of its
group Access Control Assistance Operators (S-1-5-32-579); others get
"Access is denied" (5). Lab probe of 2026-10-08 on `F1AFile2`.
- Markdown lint: `npx markdownlint-cli2` with `MD013` limited to prose
(tables, code, and headings excluded) on the conceptual pages; for
`CHANGELOG.md` also `MD024` with `siblings_only: true`, because every
version repeats the category headings.
- Gallery packages: download
`https://www.powershellgallery.com/api/v2/package/NTFSSecurity/<version>`
into `$env:TEMP` and extract it; dates come from the OData endpoint
`api/v2/FindPackagesById()?id='NTFSSecurity'`. Import each version in its
own process: every version's `NTFSSecurity.dll` has assembly version
4.2.1.0, so a second version in the same process reuses the first DLL.
- YAML: `ConvertFrom-Yaml` (powershell-yaml) on `.github/workflows/ci.yml`.
- Links: the CI step 02 (MarkdownLinkCheck 0.2.0) checks only relative
links in `Docs`; it strips anchors and skips absolute URLs.
`Wiki.Tests.ps1` checks the wiki links with their anchors; check the
links in `README.md` and `CHANGELOG.md` with a script.

3
CHANGELOG.md

@ -368,4 +368,7 @@ The format is based on
`localhost` in lowercase, such as `.`, `LOCALHOST`, or the computer name, `localhost` in lowercase, such as `.`, `LOCALHOST`, or the computer name,
where the computer doesn't offer the remote access check 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 [Unreleased]: https://github.com/raandree/NTFSSecurity/compare/4.2.6...HEAD

2
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. 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 ## RELATED LINKS

13
Docs/Contributing/05-Releasing.md

@ -99,11 +99,20 @@ describes, with `-Version` instead of `-ModulePath`.
## If a release fails ## If a release fails
The **Release** job skips what's already done: a version that the PowerShell The **Release** job skips a version that the PowerShell Gallery already has
Gallery already has, and a GitHub release that already exists. If the 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 failure doesn't need a change in the repository, fix the cause and rerun the
failed job. 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 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 doesn't have the version yet, delete the tag, merge the fix, and tag the new
commit: commit:

5
NTFSSecurity/ItemCmdlets/GetChildItem2.cs

@ -279,7 +279,7 @@ namespace NTFSSecurity
continue; continue;
} }
var writeItem = force.ToBool(); var writeItem = force.ToBool() || hidden.ToBool();
if (MyInvocation.BoundParameters.ContainsKey("Attributes")) if (MyInvocation.BoundParameters.ContainsKey("Attributes"))
{ {
@ -291,9 +291,6 @@ namespace NTFSSecurity
} }
else else
{ {
if (hidden)
force = true;
if ((current.Attributes & global::System.IO.FileAttributes.Hidden) != global::System.IO.FileAttributes.Hidden) if ((current.Attributes & global::System.IO.FileAttributes.Hidden) != global::System.IO.FileAttributes.Hidden)
writeItem = true; writeItem = true;

2
NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml

@ -3865,7 +3865,7 @@ PS C:\&gt; Disable-Privileges</dev:code>
<maml:para>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.</maml:para> <maml:para>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.</maml:para>
<maml:para>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.</maml:para> <maml:para>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.</maml:para>
<maml:para>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.</maml:para> <maml:para>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.</maml:para>
<maml:para>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.</maml:para> <maml:para>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.</maml:para>
</maml:alert> </maml:alert>
</maml:alertSet> </maml:alertSet>
<command:examples> <command:examples>

81
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, "<test-results />");
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 '<test-results />'
[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 '<test-results />'
[NTFSSecurityBasicUserProcess]::Calls | Should -Be 1
[NTFSSecurityBasicUserProcess]::WorkingDirectory | Should -Be $repository
}
}

34
Tests/FileHash.Tests.ps1

@ -98,8 +98,18 @@ Describe 'Get-FileHash2' {
} }
Context 'When the file cannot be read after taking ownership' { 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 BeforeAll {
# elevated process can make another account the owner first, so the test runs in CI. $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) { It 'Should restore the previous owner' -Skip:(-not $isElevated) {
$denied = New-TestSandboxItem -Sandbox $sandbox -Name 'Denied' $denied = New-TestSandboxItem -Sandbox $sandbox -Name 'Denied'
Assert-TestSandboxPath -Sandbox $sandbox -Path $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 Add-NTFSAccess -Path $denied -Account 'S-1-1-0' -AccessRights ReadData -AccessType Deny
$results = @(Get-FileHash2 -Path $denied -ErrorVariable hashErrors -ErrorAction SilentlyContinue) $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 | Should -HaveCount 1
$hashErrors[0].FullyQualifiedErrorId | Should -BeLike 'GetHashError,*' $hashErrors[0].FullyQualifiedErrorId | Should -BeLike 'GetHashError,*'
$results | Should -BeNullOrEmpty $results | Should -BeNullOrEmpty
(Get-NTFSOwner -Path $denied).Owner.Sid | Should -Be 'S-1-5-32-544' (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)
}
} }
} }

126
Tests/Inheritance.Tests.ps1

@ -12,6 +12,13 @@ BeforeDiscovery {
$canChangeAudit = Test-PrivilegeHeld -Name 'SeSecurityPrivilege' $canChangeAudit = Test-PrivilegeHeld -Name 'SeSecurityPrivilege'
# Assigning an owner other than the user or one of its groups needs the Restore privilege. # Assigning an owner other than the user or one of its groups needs the Restore privilege.
$canAssignAnyOwner = Test-PrivilegeHeld -Name 'SeRestorePrivilege' $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 { BeforeAll {
@ -276,6 +283,125 @@ Describe 'Audit inheritance switches' {
} }
} }
Describe 'Access inheritance transitions on files and folders' {
It 'Should set enabled=<Enable> on a <Type>, remove requested entries=<Remove>, 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=<Enable> on a <Type>, remove requested audit entries=<Remove>, 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' { Describe 'Access inheritance cmdlets' {
Context 'When the item has an owner that the user cannot assign' { Context 'When the item has an owner that the user cannot assign' {
BeforeAll { BeforeAll {

203
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 # A path on the administrative share of the drive of the sandboxes is another volume for Windows, like a share of a
# file server. # file server.
$canUseAdminShare = Test-AdminShareAvailable $canUseAdminShare = Test-AdminShareAvailable
$canCreateSymbolicLinks = Test-PrivilegeHeld -Name 'SeCreateSymbolicLinkPrivilege'
} }
BeforeAll { 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 <Case> 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 <Case>' -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' { Context 'Default table view' {
BeforeAll { BeforeAll {
$viewFolder = New-TestSandboxItem -Sandbox $sandbox -Name 'View' -Directory $viewFolder = New-TestSandboxItem -Sandbox $sandbox -Name 'View' -Directory
@ -241,6 +425,25 @@ Describe 'Copy-Item2, Move-Item2, and Remove-Item2 with several paths' {
Join-Path -Path $sourceFolder -ChildPath 'A.txt' | Should -Exist 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' { 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' $sourceFolder = Join-Path -Path $folder -ChildPath 'Merge'
$existingFolder = Join-Path -Path $destination -ChildPath 'Merge' $existingFolder = Join-Path -Path $destination -ChildPath 'Merge'

156
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.

5
Tests/Lab/Invoke-NTFSSecurityLabTest.ps1

@ -698,6 +698,11 @@ $fixtureScript = {
$null = New-Item -ItemType Directory -Path (Join-Path -Path $itemsPath -ChildPath 'Folder') $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 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. # Case 8: the link cmdlets.
$linksPath = New-FixtureFolder -RelativePath 'Case8\Links' -AccessRule $delegatesFullControl $linksPath = New-FixtureFolder -RelativePath 'Case8\Links' -AccessRule $delegatesFullControl
Set-Content -LiteralPath (Join-Path -Path $linksPath -ChildPath 'Target.txt') -Value 'Target' -NoNewline Set-Content -LiteralPath (Join-Path -Path $linksPath -ChildPath 'Target.txt') -Value 'Target' -NoNewline

19
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 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' { 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 $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' { It 'Should have the links that the link cmdlets created' {
$folder = Get-LabPath -RelativePath 'Case8\Links' $folder = Get-LabPath -RelativePath 'Case8\Links'

5
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. | | 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. | | 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. | | 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. | | 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. | | 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. | | 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. share, folders, group memberships, and profiles are gone.
Records: [5.0.0-rc6](Acceptance-2026-10-08-5.0.0-rc6.md), 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 ## Files

63
Tests/PathErrors.Tests.ps1

@ -13,6 +13,7 @@ param ()
BeforeDiscovery { BeforeDiscovery {
Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force
$holdsSecurityPrivilege = Test-PrivilegeHeld -Name 'SeSecurityPrivilege' $holdsSecurityPrivilege = Test-PrivilegeHeld -Name 'SeSecurityPrivilege'
$holdsRestorePrivilege = Test-PrivilegeHeld -Name 'SeRestorePrivilege'
$currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value $currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
$readEntry = @{ Account = 'S-1-1-0'; AccessRights = 'ReadData' } $readEntry = @{ Account = 'S-1-1-0'; AccessRights = 'ReadData' }
# An audit entry on a file has no inheritance flags. # 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 '<Command> should keep the denied item unchanged, report <ErrorId>, 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' { 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, # 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. # which Windows answers by removing the OWNER RIGHTS entries, write the DACL, and set the previous owner back.

165
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 <Name> using <Form> on <Source>, 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 <Name> 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 <Name> using <Form> on <Source>, 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
}
}

188
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: <Case>' -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
}
}

154
Tests/Remove-Item2.Tests.ps1

@ -8,17 +8,169 @@ param ()
Describe 'Remove-Item2' { Describe 'Remove-Item2' {
BeforeAll { BeforeAll {
Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force
$modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\NTFSSecurity\bin\Release\NTFSSecurity.psd1' $modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\NTFSSecurity\bin\Release\NTFSSecurity.psd1'
Import-Module -Name $modulePath -Force -ErrorAction Stop Import-Module -Name $modulePath -Force -ErrorAction Stop
$sandbox = New-TestSandbox -Name 'RemoveItem'
Push-Location -LiteralPath $sandbox
} }
AfterAll { AfterAll {
Pop-Location
Remove-TestSandbox -Sandbox $sandbox
Remove-Module -Name NTFSSecurity -Force -ErrorAction SilentlyContinue 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' { Context 'When called with -PassThur, the parameter name in 4.2.6 and earlier' {
BeforeAll { 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' Set-Content -LiteralPath $path -Value 'Remove-Item2 test'
$removedItem = Remove-Item2 -Path $path -PassThur $removedItem = Remove-Item2 -Path $path -PassThur

51
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' { Context 'When the written descriptor denies reading it again' {
BeforeAll { BeforeAll {
$privateData['EnablePrivileges'] = $false $privateData['EnablePrivileges'] = $false

Loading…
Cancel
Save