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.
.PARAMETER ResultPath
Specifies the path of the result file in the NUnit format.
Specifies an absolute path, or a path relative to the repository, for the result file in the NUnit format.
.PARAMETER Title
Specifies the heading of the test results in the job summary. It can't contain a double quote, a percent sign, or a
@ -167,7 +167,7 @@ public static class NTFSSecurityBasicUserProcess
'@
$repositoryPath = (Resolve-Path -LiteralPath (Join-Path -Path $PSScriptRoot -ChildPath '..\..')).ProviderPath
$resultFullPath = [IO.Path]::GetFullPath((Join-Path -Path $repositoryPath -ChildPath $ResultPath))
$resultFullPath = [IO.Path]::GetFullPath([IO.Path]::Combine($repositoryPath, $ResultPath))
$resultFolder = Split-Path -Path $resultFullPath -Parent
if (-not (Test-Path -LiteralPath $resultFolder)) {
New-Item -ItemType Directory -Path $resultFolder | Out-Null

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"
)
# Publishes the package that the build job built and tested. A rerun skips
# a version that the PowerShell Gallery already has.
# Publish the tested package; recovery and reruns verify the Gallery SHA512
# so a different package with the same version cannot count as success.
- name: Publish to the PowerShell Gallery
shell: pwsh
env:
@ -304,15 +304,7 @@ jobs:
RELEASE_VERSION: ${{ steps.release.outputs.version }}
RELEASE_PACKAGE: ${{ steps.release.outputs.package }}
run: |
if (-not $env:PSGALLERY_API_KEY) {
throw 'The secret PSGALLERY_API_KEY of the environment powershell-gallery is not set.'
}
$published = Find-PSResource -Name NTFSSecurity -Version $env:RELEASE_VERSION -Prerelease -Repository PSGallery -ErrorAction SilentlyContinue
if ($published) {
"NTFSSecurity $env:RELEASE_VERSION is already in the PowerShell Gallery."
exit 0
}
Publish-PSResource -NupkgPath $env:RELEASE_PACKAGE -Repository PSGallery -ApiKey $env:PSGALLERY_API_KEY
& ./.github/scripts/Publish-ModulePackage.ps1 -NupkgPath $env:RELEASE_PACKAGE -Version $env:RELEASE_VERSION
- name: Create the GitHub release
shell: pwsh

103
.memory-bank/activeContext.md

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

288
.memory-bank/progress.md

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

148
.memory-bank/systemPatterns.md

@ -1,57 +1,27 @@
---
status: current
last-verified: 2026-10-08
last-verified: 2026-10-09
owner: active-agent
source: repository evidence
source: repository and regression evidence
---
# System patterns
## Architecture
```text
NTFSSecurity.psd1 ─┬─ ScriptsToProcess: NTFSSecurity.Init.ps1
│ Add-Type: Security2.dll, PrivilegeControl.dll,
│ ProcessPrivileges.dll, inline NTFS.DriveInfoExt;
│ Update-FormatData -PrependPath format.ps1xml
├─ TypesToProcess: NTFSSecurity.types.ps1xml
│ (Owner, IsInheritanceBlocked, LengthOnDisk on
│ FileInfo/DirectoryInfo; AccountType on ACEs)
├─ RootModule: NTFSSecurity.psm1 (aliases)
├─ NestedModules: NTFSSecurity.dll (36 cmdlets)
└─ en-US\NTFSSecurity.dll-Help.xml (Get-Help; generated
from Docs/Cmdlets, Decision 8)
NTFSSecurity.dll ── cmdlets ──> Security2.dll (FileSystemAccessRule2,
FileSystemAuditRule2, IdentityReference2,
FileSystemInheritanceInfo, EffectiveAccess)
── long paths ──> AlphaFS
── privileges ──> PrivilegeControl / ProcessPrivileges
```
- `BaseCmdlet` resolves only relative paths, against the current file
system location of the session (not `$PWD`, #86). Path parameters carry
`[FileSystemPathTransformation]`, which binds file objects as full paths.
- On access denied, most cmdlets retry through `InvokeAsOwner`, which takes
ownership and restores the previous owner on every exit path.
- `BaseCmdletWithPrivControl` enables Backup, Restore, TakeOwnership, and
Security in `BeginProcessing` when `PrivateData.EnablePrivileges` is
`$true`, and disables the ones it enabled in `EndProcessing` and, since
5.0.0-rc6, in `Dispose`: PowerShell skips `EndProcessing` when a later
command, such as `Select-Object -First`, or a terminating error stops the
pipeline, but calls `Dispose`. `Enable-Privileges` keeps them
(`KeepEnabledPrivileges`). The cleanup reads the current state of each
privilege, because another command in the pipeline can have changed it,
and tries every privilege even when one fails: `EndProcessing` warns,
`Dispose` stays silent, because PowerShell ignores exceptions thrown
there and no stream is open anymore.
- `PrivateData` switches: `EnablePrivileges`, `GetInheritedFrom`,
`GetFileSystemModeProperty`, `IdentifyHardLinks`, `ShowAccountSid`.
- Cmdlets accept `-Path` (alias `FullName`) or `-SecurityDescriptor`; the
SD sets change the object in memory until `Set-NTFSSecurityDescriptor`.
| Component | Responsibility |
| --- | --- |
| `NTFSSecurity.psd1` | Root script, nested binary, initialization, types, help |
| `NTFSSecurity.Init.ps1` | Loads Security2/privilege assemblies and prepends formatting |
| `NTFSSecurity.dll` | 36 PowerShell cmdlets; BaseCmdlet path/privilege behavior |
| `Security2.dll` | DACL/SACL objects, owners, inheritance, effective access, Win32 |
| AlphaFS | Long-path files/directories/links |
| PrivilegeControl / ProcessPrivileges | Token privilege operations |
| `en-US/NTFSSecurity.dll-Help.xml` | Committed help generated from cmdlet Markdown |
## Decisions
Each Decision record is a file in `decisions/`; read only the relevant ones.
Read only task-relevant records; the index controls routing.
| # | Decision |
| --- | --- |
@ -80,65 +50,45 @@ Each Decision record is a file in `decisions/`; read only the relevant ones.
## Patterns
### Writing cmdlets
### Cmdlets and security sections
- A parameter that takes pipeline input needs a getter that doesn't
throw: PowerShell reads it before it binds each input object, and an
exception turns every object into `GetDefaultValueFailed` (the link
cmdlets before 5.0.0-rc7).
- An error for one item is non-terminating, so that the cmdlet goes on
with the next path or pipeline object; since 5.0.0-rc7, the link cmdlets
too. Its message names the item, and its target object is the item that
the cmdlet was asked to process. Resolving a path can throw in Windows
PowerShell for an invalid character, so that belongs inside the
per-item error handling.
- Folders move without `MoveOptions.CopyAllowed`: for another volume,
AlphaFS then copies and deletes, which lost empty folders. Windows
refuses such a move with `NotSameDeviceException` (17). Tests reach
another volume through `\\localhost\C$`, elevated only.
- BaseCmdlet resolves relative paths against the current filesystem
location; file-object input binds FullName through path transformation.
- Write only changed/read sections (Decision 19); a descriptor parameter
changes memory until `Set-NTFSSecurityDescriptor` persists it.
- Access denial can retry through InvokeAsOwner; restore the previous owner
on every exit, except a successful descriptor write that intentionally
sets the owner. Restoration failures must report RestoreOwnerError.
- Privilege cleanup runs in EndProcessing and Dispose, reads current states,
attempts all cleanup, and preserves explicit enables. Dispose has no stream.
- Pipeline getters never throw; per-item errors name input and allow continuation.
- Folder moves never use CopyAllowed; preserve cross-volume source folders.
- Apply implied Hidden/Force before deciding to emit, including the first item.
### Verifying documentation
### Tests and documentation
- Run platyPS in Windows PowerShell 5.1 against a Release build; a copy of
`Docs/Cmdlets` must round-trip through `Update-MarkdownHelp` unchanged.
Keep cmdlet pages ASCII-only. platyPS takes `Position` and `Required` from
the shipped help file: after such a change, edit the page YAML, run
`New-ExternalHelp`, rebuild, and check the round trip.
- MarkdownLinkCheck checks relative `Docs` links, `Tests\Wiki.Tests.ps1`
the wiki links and anchors. The wiki is generated from `Docs` (never edit
it); `Docs/README.md` becomes Home, its cmdlet groups the sidebar.
- In cmdlet pages, end a sentence with a link (platyPS drops the space
after it). Verify examples in a `$env:TEMP` sandbox, never on real data.
- Tests import Release in isolated processes, both editions and privilege
modes. File/ACL/link fixtures use shared sandbox guards and cleanup.
Privilege-dependent skips must have eligible counterparts in the matrix.
- Assert persisted state, errors/targets, continuation, and no failed
PassThru output. Prove new characterization guards with bounded mutations;
restore source exactly and rebuild before green validation or packaging.
- Fixture DACLs use .NET SetAccessControl, not Set-Acl's unintended SACL writes.
- Scope/descendant expectations are independent of the production converter.
- Desktop platyPS: generate help, rebuild, round-trip unchanged, check links.
- Live tests use only approved lab targets, SMB then independent server state;
Get/SetFileSecurity preserves stored DACLs; rights oracles use S4U tokens.
### Testing the module
### CI results and publication
- Pester 5 tests in `Tests/*.Tests.ps1` import the Release build; CI runs
them in Windows PowerShell 5.1 and PowerShell 7 (Decision 11).
- A test that changes files, links, or security descriptors uses
`Tests\TestHelpers.psm1`: its own sandbox, `Assert-TestSandboxPath`
before each change, `Remove-TestSandbox`. Cases that need a privilege
skip with `Test-PrivilegeHeld`, cases that need its absence skip when
elevated; CI runs the suite elevated and as a basic user in both
editions, so each case runs somewhere. `Block-Test*` make a read or a
write fail without elevation; `Set-TestOwner` with
`EnablePrivileges = $false` reproduces an owner the user can't assign.
- Fixtures write a DACL with `SetAccessControl`, never with `Set-Acl`:
`Set-Acl` compares `AreAuditRulesProtected` of the new descriptor with
`AreAccessRulesProtected` of the item (`FileSystemSecurity.cs` of
PowerShell), so for an item with a protected DACL it writes the audit
section too. Without the Security privilege that fails with
`PrivilegeNotHeldException`; with it, `Set-Acl` writes every section and
drops the audit entries. Windows PowerShell has
`FileInfo`/`DirectoryInfo.SetAccessControl`; PowerShell 7 has
`[System.IO.FileSystemAclExtensions]::SetAccessControl`.
- `Get-Help -Online` tests run only in Windows PowerShell, which honors the
hook `BypassOnlineHelpRetrieval`. `Manifest.Tests.ps1` and
`Release.Tests.ps1` check the manifest, the version (Decision 10), the
release notes, and the packages.
- The live tests in `Tests\Lab` (Decision 20) run as domain accounts in a
lab: on the client over SMB, then on the file server, which checks what
the client runs left. They read and write descriptors as Windows stores
them with `GetFileSecurity` and `SetFileSecurity`, because
`GetNamedSecurityInfo` converts a DACL without the auto-inherit flag and
returns its owner. The expected effective rights come from the S4U tokens
of the file server and the client, like the Effective Access tab.
- Use Path.Combine then GetFullPath for a rooted-or-repository-relative
result path; Join-Path appends even a rooted child and corrupts it.
- Discovery handles only expected PackageNotFound as absence; repository,
authentication, and network errors remain failures.
- Rerun/uncertain-upload success requires Gallery SHA-512 equality with the
exact build artifact. Base64 is case-sensitive: use ordinal comparison.
Missing/different/unverifiable metadata preserves the upload error.
- Secrets stay by environment reference, never in process arguments/logs.
Test all external publication commands with mocks; no test may upload.
- AltCover aggregates all four sequential runs without --save. Report
sequence points, not unique source lines; keep unmatched paths visible.

375
.memory-bank/techContext.md

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

3
CHANGELOG.md

@ -368,4 +368,7 @@ The format is based on
`localhost` in lowercase, such as `.`, `LOCALHOST`, or the computer name,
where the computer doesn't offer the remote access check
- Fix [Get-ChildItem2](Docs/Cmdlets/Get-ChildItem2.md) with `-Hidden`,
which omitted the first hidden item unless `-Force` was also supplied
[Unreleased]: https://github.com/raandree/NTFSSecurity/compare/4.2.6...HEAD

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

13
Docs/Contributing/05-Releasing.md

@ -99,11 +99,20 @@ describes, with `-Version` instead of `-ModulePath`.
## If a release fails
The **Release** job skips what's already done: a version that the PowerShell
Gallery already has, and a GitHub release that already exists. If the
The **Release** job skips a version that the PowerShell Gallery already has
only after its published SHA-512 matches the exact package from the build
artifact. It also skips a GitHub release that already exists. If the
failure doesn't need a change in the repository, fix the cause and rerun the
failed job.
An upload can report an error even after the Gallery accepted it, for
example a timeout followed by HTTP 409 (version already exists). The
publication script checks the Gallery once more and recovers only if the
published SHA-512 verifies the exact local package. A missing version,
unavailable metadata, or a different package remains a failure; an existing
version alone is not proof of success. The API key stays in the
`powershell-gallery` environment secret.
If the fix needs a change in the repository and the PowerShell Gallery
doesn't have the version yet, delete the tag, merge the fix, and tag the new
commit:

5
NTFSSecurity/ItemCmdlets/GetChildItem2.cs

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

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 `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>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:alertSet>
<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' {
# Before 5.0.0, the account that ran the cmdlet stayed the owner when the second attempt failed. Only an
# elevated process can make another account the owner first, so the test runs in CI.
BeforeAll {
$privateData = (Get-Module -Name NTFSSecurity).PrivateData
$enablePrivileges = $privateData['EnablePrivileges']
$privateData['EnablePrivileges'] = $false
}
AfterAll {
$privateData['EnablePrivileges'] = $enablePrivileges
}
# Disable automatic privileges so that the deny entry reaches the ownership retry even in an elevated process.
# Administrators is an assignable owner for that process, unlike TrustedInstaller.
It 'Should restore the previous owner' -Skip:(-not $isElevated) {
$denied = New-TestSandboxItem -Sandbox $sandbox -Name 'Denied'
Assert-TestSandboxPath -Sandbox $sandbox -Path $denied
@ -107,14 +117,28 @@ Describe 'Get-FileHash2' {
Add-NTFSAccess -Path $denied -Account 'S-1-1-0' -AccessRights ReadData -AccessType Deny
$results = @(Get-FileHash2 -Path $denied -ErrorVariable hashErrors -ErrorAction SilentlyContinue)
if (-not $hashErrors) {
Set-ItResult -Inconclusive -Because 'the elevated process could read the file despite the deny entry'
}
$hashErrors | Should -HaveCount 1
$hashErrors[0].FullyQualifiedErrorId | Should -BeLike 'GetHashError,*'
$results | Should -BeNullOrEmpty
(Get-NTFSOwner -Path $denied).Owner.Sid | Should -Be 'S-1-5-32-544'
}
It 'Should report both the failed read and the failed owner restoration without returning a hash' -Skip:(-not $isElevated) {
$denied = New-TestSandboxItem -Sandbox $sandbox -Name 'RestoreDenied'
Add-TestDenyRule -Sandbox $sandbox -Path $denied -Rights @{ 'S-1-1-0' = 'ReadData' }
$originalOwner = 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464'
Set-TestOwner -Sandbox $sandbox -Path $denied -Sid $originalOwner
(Get-Privileges | Where-Object -Property Privilege -EQ -Value 'Restore').PrivilegeState | Should -Be 'Disabled'
$result = @(Get-FileHash2 -Path $denied -ErrorVariable hashErrors -ErrorAction SilentlyContinue)
$result | Should -BeNullOrEmpty
$hashErrors | Should -HaveCount 2
$hashErrors[0].FullyQualifiedErrorId | Should -BeLike 'RestoreOwnerError,*'
$hashErrors[1].FullyQualifiedErrorId | Should -BeLike 'GetHashError,*'
$hashErrors | ForEach-Object -Process { $_.TargetObject | Should -Be $denied }
(Get-NTFSOwner -Path $denied).Owner.Sid | Should -Be ([Security.Principal.WindowsIdentity]::GetCurrent().User.Value)
}
}
}

126
Tests/Inheritance.Tests.ps1

@ -12,6 +12,13 @@ BeforeDiscovery {
$canChangeAudit = Test-PrivilegeHeld -Name 'SeSecurityPrivilege'
# Assigning an owner other than the user or one of its groups needs the Restore privilege.
$canAssignAnyOwner = Test-PrivilegeHeld -Name 'SeRestorePrivilege'
$inheritanceCases = @(foreach ($type in 'file', 'folder') {
foreach ($enable in $false, $true) {
foreach ($remove in $false, $true) {
@{ Type = $type; Enable = $enable; Remove = $remove }
}
}
})
}
BeforeAll {
@ -276,6 +283,125 @@ Describe 'Audit inheritance switches' {
}
}
Describe 'Access inheritance transitions on files and folders' {
It 'Should set enabled=<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' {
Context 'When the item has an owner that the user cannot assign' {
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
# file server.
$canUseAdminShare = Test-AdminShareAvailable
$canCreateSymbolicLinks = Test-PrivilegeHeld -Name 'SeCreateSymbolicLinkPrivilege'
}
BeforeAll {
@ -62,6 +63,189 @@ Describe 'Get-ChildItem2' {
}
}
Context 'Attribute switches' {
BeforeAll {
$folder = New-TestSandboxItem -Sandbox $sandbox -Name 'Switches' -Directory
$attributeCases = @{
'Plain.txt' = 'Normal'
'Hidden.txt' = 'Hidden'
'System.txt' = 'System'
'ReadOnly.txt' = 'ReadOnly'
'HiddenSystem.txt' = 'Hidden, System'
'HiddenReadOnly.txt' = 'Hidden, ReadOnly'
'All.txt' = 'Hidden, ReadOnly, System'
}
foreach ($name in $attributeCases.Keys) {
$path = Join-Path -Path $folder -ChildPath $name
Assert-TestSandboxPath -Sandbox $sandbox -Path $path
Set-Content -LiteralPath $path -Value $name
[IO.File]::SetAttributes($path, [IO.FileAttributes] $attributeCases[$name])
}
}
It 'Should apply <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' {
BeforeAll {
$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
}
It 'Move-Item2 -Force should replace an existing file with PassThru=<_>' -ForEach @($false, $true) {
$target = Join-Path -Path $destination -ChildPath 'First.txt'
Assert-TestSandboxPath -Sandbox $sandbox -Path $target
Set-Content -LiteralPath $target -Value 'Previous'
$expected = Get-Content -LiteralPath $first -Raw
$result = @(Move-Item2 -Path $first -Destination $destination -Force -PassThru $_ -ErrorAction Stop)
$first | Should -Not -Exist
Get-Content -LiteralPath $target -Raw | Should -BeExactly $expected
if ($_) {
$result | Should -HaveCount 1
$result[0].FullName | Should -Be $target
}
else {
$result | Should -BeNullOrEmpty
}
}
It 'Copy-Item2 -Force should copy a folder into an existing folder of the same name and replace the files in both' {
$sourceFolder = Join-Path -Path $folder -ChildPath 'Merge'
$existingFolder = Join-Path -Path $destination -ChildPath 'Merge'

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')
Set-Content -LiteralPath (Join-Path -Path $itemsPath -ChildPath 'Folder\File.txt') -Value 'File' -NoNewline
$hiddenPath = New-FixtureFolder -RelativePath 'Case7\Hidden' -AccessRule $delegatesFullControl
$hiddenFile = Join-Path -Path $hiddenPath -ChildPath 'Only.txt'
Set-Content -LiteralPath $hiddenFile -Value 'Hidden' -NoNewline
[System.IO.File]::SetAttributes($hiddenFile, [System.IO.FileAttributes]::Hidden)
# Case 8: the link cmdlets.
$linksPath = New-FixtureFolder -RelativePath 'Case8\Links' -AccessRule $delegatesFullControl
Set-Content -LiteralPath (Join-Path -Path $linksPath -ChildPath 'Target.txt') -Value 'Target' -NoNewline

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
}
It 'Get-ChildItem2 -Hidden should include the first hidden file without explicit -Force over SMB' {
$hiddenFolder = Get-LabPath -RelativePath 'Case7\Hidden'
$hiddenFile = Join-Path -Path $hiddenFolder -ChildPath 'Only.txt'
$result = @(Get-ChildItem2 -Path $hiddenFolder -Hidden -ErrorVariable operationErrors -ErrorAction SilentlyContinue)
Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty
$result | Should -HaveCount 1
$result[0].FullName | Should -Be $hiddenFile
[System.IO.File]::GetAttributes($hiddenFile).HasFlag([System.IO.FileAttributes]::Hidden) | Should -BeTrue
}
It 'Get-ChildItem2 should list the file and the folder that the tests leave in place' {
$names = @(Get-ChildItem2 -Path $folder -ErrorVariable operationErrors -ErrorAction SilentlyContinue).Name
@ -871,6 +883,13 @@ Describe 'Security descriptors on the file server after the runs on the client'
}
}
It 'Should retain the hidden file and its attribute after the client listing' {
$hiddenFile = Get-LabPath -RelativePath 'Case7\Hidden\Only.txt'
Get-Content -LiteralPath $hiddenFile -Raw | Should -BeExactly 'Hidden'
[System.IO.File]::GetAttributes($hiddenFile).HasFlag([System.IO.FileAttributes]::Hidden) | Should -BeTrue
}
It 'Should have the links that the link cmdlets created' {
$folder = Get-LabPath -RelativePath 'Case8\Links'

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. |
| 5 | Admin, ServerAdmin, Delegate | `Get-NTFSOwner` and `Set-NTFSOwner` on share folders that Administrators own. Every role makes itself the owner; only the administrators of the file server, which hold the Restore privilege there, assign another account. The delegated account gets a `SetOwnerError`, and the owner stays. |
| 6 | Admin, ServerAdmin, Delegate | `Disable-NTFSAuditInheritance`, `Enable-NTFSAuditInheritance`, `Clear-NTFSAudit`, and `Get-NTFSInheritance` on share folders that inherit an audit entry. The administrators of the file server change the audit entries; the delegated account gets the errors that the cmdlet pages describe, and `Get-NTFSInheritance` reports no audit state for it. |
| 7 | Delegate | `Get-Item2`, `Test-Path2`, `Get-FileHash2`, `Copy-Item2`, `Move-Item2`, `Remove-Item2`, and `Get-ChildItem2` in a share folder. |
| 7 | Delegate | `Get-Item2`, `Test-Path2`, `Get-FileHash2`, `Copy-Item2`, `Move-Item2`, `Remove-Item2`, and `Get-ChildItem2` in a share folder, including the first hidden file with `-Hidden` and without explicit `-Force`. |
| 8 | Admin | `New-NTFSHardLink`, `Get-NTFSHardLink`, and `New-NTFSSymbolicLink` in a share folder. Windows can't list the names of a file on a share, so `Get-NTFSHardLink` and `New-NTFSHardLink -PassThru` write the `GetHardLinkError` that their pages describe. |
| 9 | Delegate, Admin | `Get-NTFSSimpleAccess` compares a share folder with its parent. For the accounts of another domain and of other forests, `Get-NTFSAccess` returns their names, `Get-NTFSOrphanedAccess` doesn't report them, `Add-NTFSAccess` and `Remove-NTFSAccess` find them by name, and `Get-NTFSEffectiveAccess -ServerName` returns the rights that the file server's own token of each account gets. |
| Long paths | Admin | `Get-ChildItem2` and `Get-NTFSAccess` with a share path longer than 260 characters. |
@ -142,7 +142,8 @@ and record the evidence in this folder:
share, folders, group memberships, and profiles are gone.
Records: [5.0.0-rc6](Acceptance-2026-10-08-5.0.0-rc6.md),
[5.0.0-rc7](Acceptance-2026-10-08-5.0.0-rc7.md).
[5.0.0-rc7](Acceptance-2026-10-08-5.0.0-rc7.md), and
[quality-gate follow-up](Acceptance-2026-10-09-quality-gate.md).
## Files

63
Tests/PathErrors.Tests.ps1

@ -13,6 +13,7 @@ param ()
BeforeDiscovery {
Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force
$holdsSecurityPrivilege = Test-PrivilegeHeld -Name 'SeSecurityPrivilege'
$holdsRestorePrivilege = Test-PrivilegeHeld -Name 'SeRestorePrivilege'
$currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
$readEntry = @{ Account = 'S-1-1-0'; AccessRights = 'ReadData' }
# An audit entry on a file has no inheritance flags.
@ -151,6 +152,68 @@ Describe 'An item whose owner may not read its permissions' {
}
}
Describe 'A denied write and a denied ownership retry' {
It '<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' {
# 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.

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' {
BeforeAll {
Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force
$modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\NTFSSecurity\bin\Release\NTFSSecurity.psd1'
Import-Module -Name $modulePath -Force -ErrorAction Stop
$sandbox = New-TestSandbox -Name 'RemoveItem'
Push-Location -LiteralPath $sandbox
}
AfterAll {
Pop-Location
Remove-TestSandbox -Sandbox $sandbox
Remove-Module -Name NTFSSecurity -Force -ErrorAction SilentlyContinue
}
Context 'Folders and their contents' {
It 'Should remove an empty folder and return its folder object with -PassThru' {
$folder = New-TestSandboxItem -Sandbox $sandbox -Name 'Empty' -Directory
$result = @(Remove-Item2 -Path $folder -PassThru -ErrorAction Stop)
$folder | Should -Not -Exist
$result | Should -HaveCount 1
$result[0] | Should -BeOfType [Alphaleonis.Win32.Filesystem.DirectoryInfo]
$result[0].FullName | Should -Be $folder
}
It 'Should report DeleteError for a non-empty folder without -Recurse and continue with the next path' {
$folder = New-TestSandboxItem -Sandbox $sandbox -Name 'NonEmpty' -Directory
$content = Join-Path -Path $folder -ChildPath 'Keep.txt'
$next = New-TestSandboxItem -Sandbox $sandbox -Name 'Next'
Assert-TestSandboxPath -Sandbox $sandbox -Path $content
Set-Content -LiteralPath $content -Value 'Keep'
$result = @(Remove-Item2 -Path $folder, $next -PassThru -ErrorVariable removeErrors -ErrorAction SilentlyContinue)
$removeErrors | Should -HaveCount 1
$removeErrors[0].FullyQualifiedErrorId | Should -BeLike 'DeleteError,*'
$removeErrors[0].CategoryInfo.Category | Should -Be 'InvalidData'
$removeErrors[0].TargetObject | Should -Be $folder
Get-Content -LiteralPath $content | Should -Be 'Keep'
$next | Should -Not -Exist
$result | Should -HaveCount 1
$result[0].FullName | Should -Be $next
}
It 'Should remove a folder tree with -Recurse without touching its sibling' {
$folder = New-TestSandboxItem -Sandbox $sandbox -Name 'Tree' -Directory
$nested = Join-Path -Path $folder -ChildPath 'Child\Grandchild'
$content = Join-Path -Path $nested -ChildPath 'Delete.txt'
$sibling = New-TestSandboxItem -Sandbox $sandbox -Name 'Sibling'
Assert-TestSandboxPath -Sandbox $sandbox -Path $nested, $content
New-Item -ItemType Directory -Path $nested -Force | Out-Null
Set-Content -LiteralPath $content -Value 'Delete'
Remove-Item2 -Path $folder -Recurse -ErrorAction Stop
$folder | Should -Not -Exist
Get-Content -LiteralPath $sibling | Should -Be 'Sibling'
}
It 'Should leave a folder tree unchanged with -Recurse -Force -WhatIf and write nothing with -PassThru' {
$folder = New-TestSandboxItem -Sandbox $sandbox -Name 'Preview' -Directory
$content = Join-Path -Path $folder -ChildPath 'Keep.txt'
Assert-TestSandboxPath -Sandbox $sandbox -Path $content
Set-Content -LiteralPath $content -Value 'Keep'
[IO.File]::SetAttributes($content, [IO.FileAttributes]::ReadOnly)
$result = @(Remove-Item2 -Path $folder -Recurse -Force -WhatIf -PassThru -ErrorAction Stop)
$result | Should -BeNullOrEmpty
Get-Content -LiteralPath $content | Should -Be 'Keep'
([IO.File]::GetAttributes($content) -band [IO.FileAttributes]::ReadOnly) | Should -Not -Be 0
}
It 'Should remove a folder tree containing read-only files with -Recurse -Force' {
$folder = New-TestSandboxItem -Sandbox $sandbox -Name 'ReadOnlyTree' -Directory
$content = Join-Path -Path $folder -ChildPath 'Child\ReadOnly.txt'
Assert-TestSandboxPath -Sandbox $sandbox -Path $content
New-Item -ItemType Directory -Path (Split-Path -Path $content -Parent) | Out-Null
Set-Content -LiteralPath $content -Value 'ReadOnly'
[IO.File]::SetAttributes($content, [IO.FileAttributes]::ReadOnly)
Remove-Item2 -Path $folder -Recurse -Force -ErrorAction Stop
$folder | Should -Not -Exist
}
It 'Should write DeleteError when a descendant is open without delete sharing, not a successful -PassThru result' {
$folder = New-TestSandboxItem -Sandbox $sandbox -Name 'LockedTree' -Directory
$content = Join-Path -Path $folder -ChildPath 'Locked.txt'
Assert-TestSandboxPath -Sandbox $sandbox -Path $content
Set-Content -LiteralPath $content -Value 'Locked'
$stream = [IO.File]::Open($content, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::None)
try {
$result = @(Remove-Item2 -Path $folder -Recurse -Force -PassThru -ErrorVariable removeErrors -ErrorAction SilentlyContinue)
}
finally {
$stream.Dispose()
}
$removeErrors | Should -HaveCount 1
$removeErrors[0].FullyQualifiedErrorId | Should -BeLike 'DeleteError,*'
$removeErrors[0].TargetObject | Should -Be $folder
$result | Should -BeNullOrEmpty
Get-Content -LiteralPath $content | Should -Be 'Locked'
}
It 'Should delete a junction with -Recurse without deleting or changing its target' {
$target = New-TestSandboxItem -Sandbox $sandbox -Name 'JunctionTarget' -Directory
$content = Join-Path -Path $target -ChildPath 'Keep.txt'
$link = Join-Path -Path $sandbox -ChildPath 'Junction'
Assert-TestSandboxPath -Sandbox $sandbox -Path $content, $link
Set-Content -LiteralPath $content -Value 'Keep'
$before = (Get-Acl -LiteralPath $target).Sddl
New-Item -ItemType Junction -Path $link -Value $target | Out-Null
Remove-Item2 -Path $link -Recurse -Force -ErrorAction Stop
$link | Should -Not -Exist
Get-Content -LiteralPath $content | Should -Be 'Keep'
(Get-Acl -LiteralPath $target).Sddl | Should -BeExactly $before
}
It 'Should delete a folder tree whose path exceeds 260 characters' {
$folder = New-TestSandboxItem -Sandbox $sandbox -Name 'LongTree' -Directory
$long = Join-Path -Path $folder -ChildPath (('A' * 100), ('B' * 100), ('C' * 100) -join '\')
Assert-TestSandboxPath -Sandbox $sandbox -Path $long
[IO.Directory]::CreateDirectory('\\?\' + $long) | Out-Null
[IO.File]::WriteAllText(('\\?\' + $long + '\Delete.txt'), 'Long')
$long.Length | Should -BeGreaterThan 260
Remove-Item2 -Path $folder -Recurse -Force -ErrorAction Stop
$folder | Should -Not -Exist
}
}
Context 'Read-only files' {
It 'Should refuse a read-only file without -Force, keep its contents and attribute, and return nothing' {
$file = New-TestSandboxItem -Sandbox $sandbox -Name 'ReadOnly'
[IO.File]::SetAttributes($file, [IO.FileAttributes]::ReadOnly)
$result = @(Remove-Item2 -Path $file -PassThru -ErrorVariable removeErrors -ErrorAction SilentlyContinue)
$removeErrors | Should -HaveCount 1
$removeErrors[0].FullyQualifiedErrorId | Should -BeLike 'DeleteError,*'
$result | Should -BeNullOrEmpty
Get-Content -LiteralPath $file | Should -Be 'ReadOnly'
([IO.File]::GetAttributes($file) -band [IO.FileAttributes]::ReadOnly) | Should -Not -Be 0
}
It 'Should remove a read-only file with -Force' {
$file = New-TestSandboxItem -Sandbox $sandbox -Name 'Forced'
[IO.File]::SetAttributes($file, [IO.FileAttributes]::ReadOnly)
Remove-Item2 -Path $file -Force -ErrorAction Stop
$file | Should -Not -Exist
}
}
Context 'When called with -PassThur, the parameter name in 4.2.6 and earlier' {
BeforeAll {
$path = Join-Path -Path $TestDrive -ChildPath 'PassThur.txt'
$path = Join-Path -Path $sandbox -ChildPath 'PassThur.txt'
Assert-TestSandboxPath -Sandbox $sandbox -Path $path
Set-Content -LiteralPath $path -Value 'Remove-Item2 test'
$removedItem = Remove-Item2 -Path $path -PassThur

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' {
BeforeAll {
$privateData['EnablePrivileges'] = $false

Loading…
Cancel
Save