mirror of https://github.com/raandree/NTFSSecurity
committed by
GitHub
25 changed files with 1710 additions and 602 deletions
@ -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 |
|||
} |
|||
@ -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. |
|||
|
|||
@ -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. |
|||
|
|||
@ -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 |
|||
} |
|||
} |
|||
@ -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. |
|||
@ -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 |
|||
} |
|||
} |
|||
@ -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 |
|||
} |
|||
} |
|||
Loading…
Reference in new issue