From 2b1e0406d0bb4231587458c1db38607ad5d2442a Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Thu, 8 Oct 2026 08:10:20 +0000 Subject: [PATCH 01/33] chore(memory-bank): record 5.0.0-rc5 and the quality gate before 5.0.0 Record the release of 5.0.0-rc5, Phase 1 of the quality gate that the maintainer set before 5.0.0 (Decision 21): the published package passes the live tests, every test runs in at least one configuration, and the suite runs 55.9% of the C# lines and 37.4% of the branches. Record the coverage measurement with AltCover, the GitHub CLI on the third workstation, and the plan of Phase 2, which ends with 5.0.0-rc6. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- .memory-bank/activeContext.md | 92 ++++++++++--------- .../0021-quality-gate-before-5.0.0.md | 35 +++++++ .memory-bank/progress.md | 44 +++++---- .memory-bank/systemPatterns.md | 3 +- .memory-bank/techContext.md | 35 +++++-- 5 files changed, 140 insertions(+), 69 deletions(-) create mode 100644 .memory-bank/decisions/0021-quality-gate-before-5.0.0.md diff --git a/.memory-bank/activeContext.md b/.memory-bank/activeContext.md index 935054f..c4b404a 100644 --- a/.memory-bank/activeContext.md +++ b/.memory-bank/activeContext.md @@ -1,6 +1,6 @@ --- status: current -last-verified: 2026-10-07 +last-verified: 2026-10-08 owner: active-agent source: current task evidence --- @@ -9,50 +9,58 @@ source: current task evidence ## Current focus -5.0.0-rc5, prepared on the branch `ai/release-5.0.0-rc5`: the live tests -in `Tests\Lab` (Decision 20) and the fix of -`Get-NTFSEffectiveAccess -ServerName` that they found. The maintainer -pushes the branch, opens and merges the pull request, and tags -`5.0.0-rc5`; CI publishes it (Decision 12). Then, with the tester feedback -in #34, he decides on 5.0.0. After 5.0.0 the repository is archived in -favor of WindowsAccessControl (Decision 18). +Phase 2 of the quality gate before 5.0.0 (Decision 21), on the branch +`ai/release-5.0.0-rc6`, released as 5.0.0-rc6: tests until every code +path is tested or explained, live tests for the remaining cmdlets, and +test-first fixes of the known defects. The maintainer approved it on +2026-10-08. Then Phase 3 and 5.0.0; after 5.0.0 the repository is archived +in favor of WindowsAccessControl (Decision 18). ## Evidence -- 2026-10-07: the live tests ran in the lab of WindowsAccessControl - (`F1ADC1`, `F1AFile2`, `F1AFile1` in `a.forest1.net`) against the Gallery - packages of 5.0.0-rc2 and 5.0.0-rc4 and against the branch build, in - Windows PowerShell 5.1 and PowerShell 7, with the same results in both: - - Case 1 (#34 over SMB): rc2 fails `Add-NTFSAccess`, `Clear-NTFSAccess`, - and `Set-NTFSSecurityDescriptor` with error 1307, on folders with and - without the auto-inherit flag; the other four cmdlets succeed in rc2 - too. rc4 passes all seven and keeps Administrators as the owner. - - Case 2: the administrators of the file server read, add, and remove - audit entries over SMB; the delegated account gets the errors that the - cmdlet pages describe, and the folders stay unchanged. Same in rc2. - - Case 3: with `-ServerName` of the file server, the result includes its - local group, without a warning; without it, only the domain groups - count. With a computer that can't be reached, rc2 and rc4 returned no - access, because error 1722 was swallowed; fixed on the branch. - - Case 4, long paths on the share, and #108 pass; #108 fails in rc2. - - The branch build passes every live test, and the 499 tests of `Tests` - in both editions. -- One `security-reviewer` pass approved the branch with minor findings; - the assertion of the fallback warning and the path guard of the live - tests were hardened. Deferred: the bare `catch` in - `Win32.GetEffectiveAccess`, which still swallows any other error of the - remote initialization (not reproducible: for a user who isn't an - administrator of the file server, the cmdlet writes "Access is denied"); - the unchecked `AUTHZ_ACCESS_REPLY.Error`; a fallback warning that names - the server and the error, which would change behavior (Decision 16). -- #34: no report from the tester by 16:00 UTC on 2026-10-07; he announced - results against two file servers, one of them IBM ESS, for that day. -- The lab keeps the accounts, the share, and the folders of the last run; - `Invoke-NTFSSecurityLabTest.ps1 -RemoveFixture` removes them. +- 2026-10-08, Phase 1, measured on 5.0.0-rc5 (`fcb370e`): + - The published package passes the live tests in both editions: 8 role + runs, no failure. + - As a basic user, the 11 tests that CI skips pass in both editions. The + one failure, `Enable-Privileges should write one object per + privilege`, assumes more than one privilege. Every test runs in at + least one of four configurations (elevated or basic user, two + editions), but CI runs only elevated. + - The suite runs 55.9% of the C# lines and 37.4% of the branches + (`techContext.md`, Validation). No line of `Test-Path2` and + `Get-DiskSpace` runs; `Set-NTFSOwner` (46%) runs only as a setup step + of another test; `Clear-NTFSAccess` and the access inheritance cmdlets + run about 43%, `FileSystemSecurity2` 42%. No cmdlet calls the registry + classes, `SimpleFileSystemAuditRule`, `PrivilegeEnabler`, or + `FileSystemEffectivePermissionEntry` (244 lines). + - 19 of the 36 cmdlets never ran over SMB, among them `Get-NTFSOwner`, + `Set-NTFSOwner`, the audit inheritance cmdlets, and `Clear-NTFSAudit`; + no account of another domain or forest ran; both ends of the lab run + Windows Server 2025. +- Known defects from the reviews of #113 and #114 (`progress.md`, open + work 3): `Move-Item2 -PassThru` returns the source item; the conflict + checks of `Copy-Item2` and `Move-Item2` use `File.Exists` also for + folders, maybe the cause of #21; an error while restoring the owner can + hide the original one (R4); `Set-NTFSSecurityDescriptor -PassThru` reads + the item again inside the retry (R5); the access check doesn't read + `AUTHZ_ACCESS_REPLY.Error`, and its buffers aren't initialized. #110 + lists seven test follow-ups. +- #34: no reply from the tester by 06:44 UTC on 2026-10-08. ## Next step -The maintainer runs the push and pull request commands of the session of -2026-10-07, merges, and tags `5.0.0-rc5`. Then check the published package -with `Invoke-NTFSSecurityLabTest.ps1 -Version 5.0.0-rc5`, wait for the #34 -feedback, and release 5.0.0 as `progress.md` describes. +Phase 2, one step at a time, each with evidence before the next: + +1. Tests for the cmdlets without tests of their own: `Test-Path2`, + `Get-DiskSpace`, `Set-NTFSOwner`, the link cmdlets, + `Get-NTFSOrphanedAudit`, and `Get-NTFSSimpleAccess`. +2. The other parameter sets and error paths, such as the + `-SecurityDescriptor` sets of the inheritance cmdlets and of + `Clear-NTFSAccess`. +3. The paths of `Security2` that no test runs, and #110. +4. Test-first fixes of the known defects; behavior changes go to the + maintainer (Decision 16). +5. A CI job as a basic user, live tests for the other cmdlets over SMB and + for accounts of other forests, and a lab run. +6. Measure the coverage again, explain what remains, review, and prepare + 5.0.0-rc6. diff --git a/.memory-bank/decisions/0021-quality-gate-before-5.0.0.md b/.memory-bank/decisions/0021-quality-gate-before-5.0.0.md new file mode 100644 index 0000000..d32a8d7 --- /dev/null +++ b/.memory-bank/decisions/0021-quality-gate-before-5.0.0.md @@ -0,0 +1,35 @@ +--- +status: accepted +date: 2026-10-08 +last-verified: 2026-10-08 +owner: shared +source: maintainer decision of 2026-10-08 +--- + +# Decision 21: A quality gate before 5.0.0 + +- Choice: 5.0.0 ships only at the highest quality, with everything tested + (maintainer, 2026-10-08). The gate has three phases: + 1. Measure 5.0.0-rc5: done on 2026-10-08 (`progress.md`). + 2. Add tests until every code path is tested or explained, live tests + for the remaining cmdlets, and test-first fixes of the known defects; + release them as 5.0.0-rc6. The maintainer approved it on 2026-10-08. + 3. Run the live tests on more operating systems, such as a Windows 11 + client and Server 2019 and 2022 file servers, then release 5.0.0. +- Exit criteria for 5.0.0, as proposed on 2026-10-08: + - Every cmdlet and parameter set has behavior tests, error paths + included. + - No test is skipped in every configuration that runs. + - The C# coverage is measured, and every path that no test runs is + tested or explained. + - Every known defect is fixed, or accepted by the maintainer and listed + in the release notes. + - The published package passes the live tests on every operating system + of the matrix. +- Rationale: rc5 passed every test that ran, but the tests ran 55.9% of + the code lines and 37.4% of the branches; five cmdlets had no tests of + their own, and 19 cmdlets never ran over SMB. +- Open: behavior changes found on the way stay the maintainer's decision + (Decision 16); so do the 244 lines of classes that no cmdlet calls, the + operating systems of Phase 3, and how to cover file servers that aren't + Windows (#34). diff --git a/.memory-bank/progress.md b/.memory-bank/progress.md index e17a9d8..05fc1ac 100644 --- a/.memory-bank/progress.md +++ b/.memory-bank/progress.md @@ -1,6 +1,6 @@ --- status: current -last-verified: 2026-10-07 +last-verified: 2026-10-08 owner: active-agent source: repository evidence --- @@ -9,15 +9,15 @@ source: repository evidence ## Current status -5.0.0-rc4 is on the PowerShell Gallery and in the GitHub releases, -published by CI from the tag `5.0.0-rc4` on `master` (`01d9264`, the merge -of #113) on 2026-10-06 (Decision 12). It adds to 5.0.0-rc3 the fixes of the -issues #41, #108, #109, and #111 and of the leftovers of the rc3 review. -5.0.0-rc5 is prepared on the branch `ai/release-5.0.0-rc5`: the live tests -in a lab (Decision 20) and the fix of `Get-NTFSEffectiveAccess -ServerName` -that they found. The stable Gallery version is still 4.2.6. NTFSSecurity -will be archived soon; its users move to WindowsAccessControl -(Decision 18). +5.0.0-rc5 is on the PowerShell Gallery and in the GitHub releases, +published by CI on 2026-10-08 from the tag `5.0.0-rc5` on `master` +(`fcb370e`, the merge of #114; Decision 12). It adds to 5.0.0-rc4 the live +tests in a lab (Decision 20) and the fix of +`Get-NTFSEffectiveAccess -ServerName` that they found. Before 5.0.0, the +maintainer wants the highest quality with everything tested: the quality +gate of Decision 21, now in Phase 2, which ends with 5.0.0-rc6. The stable +Gallery version is still 4.2.6. NTFSSecurity will be archived soon; its +users move to WindowsAccessControl (Decision 18). ## Recent milestones @@ -68,6 +68,14 @@ will be archived soon; its users move to WindowsAccessControl 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. ## Stable capabilities @@ -82,14 +90,14 @@ will be archived soon; its users move to WindowsAccessControl ## Open work -1. Publish 5.0.0-rc5 (merge, then tag), check its package in the lab with - `Tests\Lab\Invoke-NTFSSecurityLabTest.ps1 -Version 5.0.0-rc5`, and then - release 5.0.0 through CI (Decision 12) with the tester feedback in #34: - remove the label, date `[Unreleased]` as `[5.0.0]`, add `5.0.0-rc5` 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. +1. Quality gate before 5.0.0 (Decision 21): Phase 2 on the branch + `ai/release-5.0.0-rc6` (`activeContext.md`), released as 5.0.0-rc6; + 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: #110 (tests) is the open follow-up of the review findings; #68 tracks `-WhatIf` and `-Confirm` for every cmdlet that changes security. The labels follow Decision 17; #16, #21, #45, and #89 wait for their diff --git a/.memory-bank/systemPatterns.md b/.memory-bank/systemPatterns.md index c28e09b..f3c0f3d 100644 --- a/.memory-bank/systemPatterns.md +++ b/.memory-bank/systemPatterns.md @@ -1,6 +1,6 @@ --- status: current -last-verified: 2026-10-07 +last-verified: 2026-10-08 owner: active-agent source: repository evidence --- @@ -67,6 +67,7 @@ Each Decision record is a file in `decisions/`; read only the relevant ones. | 18 | [NTFSSecurity will be archived](decisions/0018-archive-for-windowsaccesscontrol.md) | | 19 | [Cmdlets write only the sections that they change](decisions/0019-write-only-changed-sections.md) | | 20 | [Live tests in a lab live in Tests\Lab](decisions/0020-live-tests-in-tests-lab.md) | +| 21 | [A quality gate before 5.0.0](decisions/0021-quality-gate-before-5.0.0.md) | ## Patterns diff --git a/.memory-bank/techContext.md b/.memory-bank/techContext.md index 625f722..b7355a2 100644 --- a/.memory-bank/techContext.md +++ b/.memory-bank/techContext.md @@ -1,6 +1,6 @@ --- status: current -last-verified: 2026-10-07 +last-verified: 2026-10-08 owner: active-agent source: repository evidence --- @@ -72,16 +72,19 @@ source: repository evidence - 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, platyPS, or GitHub CLI: check each + 5.61.704. It has no NuGet cache or platyPS: check each nuget.org package against the SHA-512 `packageHash` of its catalog entry (`https://api.nuget.org/v3/registration5-semver1//.json`, then `catalogEntry`), and each Gallery package against `PackageHash` of `api/v2/Packages(Id='',Version='')`. Pester 5.7.1 is in - `V:\Git\WindowsAccessControl\output\RequiredModules`; read issues and pull - requests through the GitHub REST API. 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`. + `V:\Git\WindowsAccessControl\output\RequiredModules`. The GitHub CLI + 2.102.0 is in `C:\Program Files\GitHub CLI`, outside the PATH, and signed + in as `raandree` since 2026-10-08; `Block-RemoteMutation` denies its + mutating commands, so the agent uses it read-only. The lab domains + `a.forest1.net` and `b.forest1.net` had a maximum password age of 42 + days, so the password of `install` expired on 2026-09-15 and AutomatedLab + got access denied; it never expires since 2026-10-07, as in + `forest1.net`. ## Constraints @@ -96,7 +99,8 @@ source: repository evidence - 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), published + 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. @@ -175,6 +179,21 @@ source: repository evidence session. Run them as a basic user with `runas /trustlevel:0x20000`, and give Windows PowerShell its own `PSModulePath`; that token holds one privilege, so the `Enable-Privileges -PassThru` count test fails there. + `runas` returns at once, so the script it starts writes its own log. + 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: `--save`, + `--reportFormat=OpenCover`, AlphaFS and `System.Management.Automation` + excluded with `--assemblyFilter`. Put the instrumented module in + `NTFSSecurity\bin\Release` of a `git worktree`, run + `.github\scripts\Invoke-Tests.ps1` elevated and as a basic user in both + editions, then `AltCover.exe runner --collect --recorderDirectory=`. Baseline of the rc5 tree on 2026-10-08: 55.9% of + the lines (1,943 of 3,476), 37.4% of the branches (700 of 1,873); 60.1% + of the lines without 244 lines in classes that no cmdlet calls. - 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 From 82969ba089fdb00caea4a8ac97efb8695f6e4d49 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Thu, 8 Oct 2026 08:28:27 +0000 Subject: [PATCH 02/33] fix: disable the privileges also when the pipeline stops early The cmdlets that enable the Backup, Restore, Take Ownership, and Security privileges disabled them only in EndProcessing, which PowerShell skips when a later command, such as Select-Object -First, or a terminating error stops the pipeline. The privileges then stayed enabled in the session. BaseCmdletWithPrivControl now implements IDisposable and disables them in Dispose as well; Enable-Privileges keeps them. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- CHANGELOG.md | 4 ++++ Docs/Concepts.md | 13 +++++++----- NTFSSecurity/BaseCmdlets.cs | 37 +++++++++++++++++++++++++++++--- NTFSSecurity/OtherCmdlets.cs | 6 ++++++ Tests/Privileges.Tests.ps1 | 41 ++++++++++++++++++++++++++++++++++++ 5 files changed, 93 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a80a74..2144024 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -269,5 +269,9 @@ The format is based on - Fix `Get-NTFSEffectiveAccess`, which returned no access when the computer of `-ServerName` couldn't be reached, although it warned that it had calculated the result on this computer; it now returns that result +- Fix the cmdlets that enable the Backup, Restore, Take Ownership, and + Security privileges, which left them enabled in the session when a later + command, such as `Select-Object -First`, or a terminating error stopped + the pipeline early; they now disable them also then [Unreleased]: https://github.com/raandree/NTFSSecurity/compare/4.2.6...HEAD diff --git a/Docs/Concepts.md b/Docs/Concepts.md index 9ba01cf..d196d8e 100644 --- a/Docs/Concepts.md +++ b/Docs/Concepts.md @@ -188,11 +188,14 @@ session runs elevated (**Run as administrator**). The access, audit, inheritance, owner, and security descriptor cmdlets enable these privileges automatically while they run and disable the ones they -enabled when they finish. If a privilege cannot be enabled, the cmdlet -continues without it. You can turn this behavior off with the -`EnablePrivileges` module setting. Before 5.0.0, the inheritance cmdlets were -an exception: they always tried to enable the privileges, and when -`EnablePrivileges` was `$false`, they left them enabled. +enabled when they finish, also when a later command such as +`Select-Object -First` or a terminating error stops the pipeline early. If a +privilege cannot be enabled, the cmdlet continues without it. You can turn +this behavior off with the `EnablePrivileges` module setting. Before 5.0.0, +the inheritance cmdlets were an exception: they always tried to enable the +privileges, and when `EnablePrivileges` was `$false`, they left them enabled. +And before 5.0.0, every cmdlet left the privileges enabled in the session when +the pipeline stopped early. `Enable-Privileges` enables the four privileges for the current PowerShell process until you run `Disable-Privileges` or close the session. diff --git a/NTFSSecurity/BaseCmdlets.cs b/NTFSSecurity/BaseCmdlets.cs index 91b5353..9a300be 100644 --- a/NTFSSecurity/BaseCmdlets.cs +++ b/NTFSSecurity/BaseCmdlets.cs @@ -182,13 +182,19 @@ namespace NTFSSecurity #endregion } - public class BaseCmdletWithPrivControl : BaseCmdlet + public class BaseCmdletWithPrivControl : BaseCmdlet, IDisposable { protected PrivilegeAndAttributesCollection privileges = null; protected PrivilegeControl privControl = new PrivilegeControl(); private List enabledPrivileges = new List(); Hashtable privateData = null; + // A cmdlet that enables the privileges for the session, such as Enable-Privileges, keeps them enabled. + protected virtual bool KeepEnabledPrivileges + { + get { return false; } + } + protected override void BeginProcessing() { privateData = (Hashtable)MyInvocation.MyCommand.Module.PrivateData; @@ -208,15 +214,40 @@ namespace NTFSSecurity //disable all privileges that have been enabled by this cmdlet WriteVerbose(string.Format("Disabeling all {0} enabled privileges...", enabledPrivileges.Count)); - foreach (var privilege in enabledPrivileges) + foreach (var privilege in DisableEnabledPrivileges()) { - DisablePrivilege((Privilege)Enum.Parse(typeof(Privilege), privilege)); WriteVerbose(string.Format("\t{0} disabled", privilege)); } WriteVerbose(string.Format("...finished")); } } + // PowerShell calls Dispose also when a later command or a terminating error stops the pipeline, and then + // skips EndProcessing. Before 5.0.0-rc6, the privileges that the cmdlet had enabled stayed enabled in the + // session in that case. + public void Dispose() + { + DisableEnabledPrivileges(); + GC.SuppressFinalize(this); + } + + // Disables the privileges that this cmdlet enabled, once, and returns their names. + private List DisableEnabledPrivileges() + { + var disabled = new List(); + if (!KeepEnabledPrivileges) + { + foreach (var privilege in enabledPrivileges) + { + DisablePrivilege((Privilege)Enum.Parse(typeof(Privilege), privilege)); + disabled.Add(privilege); + } + } + + enabledPrivileges.Clear(); + return disabled; + } + protected void EnablePrivilege(Privilege privilege) { //throw an exception if the specified prililege is not held by the client diff --git a/NTFSSecurity/OtherCmdlets.cs b/NTFSSecurity/OtherCmdlets.cs index 6cd18d3..fd21efd 100644 --- a/NTFSSecurity/OtherCmdlets.cs +++ b/NTFSSecurity/OtherCmdlets.cs @@ -61,6 +61,12 @@ namespace NTFSSecurity { //nothing as we want to keep the privileges enabled } + + // Keeps the privileges enabled also when the pipeline stops early and PowerShell calls only Dispose. + protected override bool KeepEnabledPrivileges + { + get { return true; } + } } #endregion Enable-Privileges diff --git a/Tests/Privileges.Tests.ps1 b/Tests/Privileges.Tests.ps1 index 55b3c97..c688cf2 100644 --- a/Tests/Privileges.Tests.ps1 +++ b/Tests/Privileges.Tests.ps1 @@ -119,3 +119,44 @@ Describe 'Inheritance cmdlets' { } } } + +Describe 'Privileges when the pipeline stops early' { + BeforeAll { + $files = 1..3 | ForEach-Object -Process { New-TestSandboxItem -Sandbox $sandbox -Name "Stopped$_" } + $missing = Join-Path -Path $sandbox -ChildPath 'StoppedMissing.txt' + } + + BeforeEach { + Disable-Privileges -ErrorAction SilentlyContinue -WarningAction SilentlyContinue + } + + AfterEach { + # A failing test must not leave the privileges enabled for the tests that follow. + Disable-Privileges -ErrorAction SilentlyContinue -WarningAction SilentlyContinue + } + + # Before 5.0.0-rc6, a cmdlet disabled the privileges that it had enabled only in EndProcessing, which PowerShell + # skips when a later command or a terminating error stops the pipeline. The Backup, Restore, Take Ownership, and + # Security privileges then stayed enabled in the session. + It 'Should disable the privileges after Select-Object -First stops the pipeline' -Skip:(-not $holdsPrivileges) { + Get-BackupPrivilegeState | Should -Be 'Disabled' + + Get-NTFSOwner -Path $files | Select-Object -First 1 | Out-Null + + Get-BackupPrivilegeState | Should -Be 'Disabled' + } + + It 'Should disable the privileges after a terminating error' -Skip:(-not $holdsPrivileges) { + Get-BackupPrivilegeState | Should -Be 'Disabled' + + { Get-NTFSAccess -Path $missing, $files[0] -ErrorAction Stop } | Should -Throw + + Get-BackupPrivilegeState | Should -Be 'Disabled' + } + + It 'Enable-Privileges should keep the privileges enabled also when the pipeline stops early' -Skip:(-not $holdsPrivileges) { + Enable-Privileges -PassThru | Select-Object -First 1 | Out-Null + + Get-BackupPrivilegeState | Should -Be 'Enabled' + } +} From 511c16479ebec791256f0e7e4179a96efa19fa23 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Thu, 8 Oct 2026 08:28:27 +0000 Subject: [PATCH 03/33] fix: return false from Test-Path2 for a path with invalid characters In Windows PowerShell, .NET rejects a path with a character that Windows doesn't allow in names, such as | or <, and Test-Path2 stopped with the terminating error "Illegal characters in path". Such an item can't exist, so the cmdlet now writes false, as in PowerShell 7 and like Test-Path. Add tests for every -PathType, long paths, relative paths, and the pipeline, and for Get-DiskSpace, which had no tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- CHANGELOG.md | 4 + Docs/Cmdlets/Test-Path2.md | 2 +- NTFSSecurity/PathCmdlets/TestPath2.cs | 10 ++ NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml | 2 +- Tests/ItemCmdlets.Tests.ps1 | 127 +++++++++++++++++++ 5 files changed, 143 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2144024..770d467 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -269,6 +269,10 @@ The format is based on - Fix `Get-NTFSEffectiveAccess`, which returned no access when the computer of `-ServerName` couldn't be reached, although it warned that it had calculated the result on this computer; it now returns that result +- Fix `Test-Path2`, which stopped with the terminating error "Illegal + characters in path" in Windows PowerShell for a path with a character + that Windows doesn't allow in names, such as `|`; it now returns `$false` + for such a path, as in PowerShell 7 - Fix the cmdlets that enable the Backup, Restore, Take Ownership, and Security privileges, which left them enabled in the session when a later command, such as `Select-Object -First`, or a terminating error stopped diff --git a/Docs/Cmdlets/Test-Path2.md b/Docs/Cmdlets/Test-Path2.md index 41e14d6..bf28c32 100644 --- a/Docs/Cmdlets/Test-Path2.md +++ b/Docs/Cmdlets/Test-Path2.md @@ -117,7 +117,7 @@ For each path, the cmdlet writes `$true` when the item exists and matches `-Path The cmdlet resolves paths through the AlphaFS library, which is not bound by the 260-character `MAX_PATH` limit of the Windows PowerShell file system provider. Use `Test-Path2` instead of `Test-Path` when a path can be longer than that limit. -A path that does not exist is not an error condition. The cmdlet writes `$false` and continues with the next path. +A path that does not exist is not an error condition. The cmdlet writes `$false` and continues with the next path. This also applies to a path with a character that Windows doesn't allow in names, such as `|` or `<`; before 5.0.0, such a path stopped the cmdlet with the terminating error "Illegal characters in path" in Windows PowerShell. ## RELATED LINKS diff --git a/NTFSSecurity/PathCmdlets/TestPath2.cs b/NTFSSecurity/PathCmdlets/TestPath2.cs index b443d73..b087a2b 100644 --- a/NTFSSecurity/PathCmdlets/TestPath2.cs +++ b/NTFSSecurity/PathCmdlets/TestPath2.cs @@ -68,6 +68,16 @@ namespace NTFSSecurity { WriteError(new ErrorRecord(ex, "PathNotFound", ErrorCategory.ObjectNotFound, path)); } + // In Windows PowerShell, .NET rejects a path with a character that Windows doesn't allow in names. + // Such an item can't exist, so the cmdlet writes $false, as in PowerShell 7 and like Test-Path. + catch (System.ArgumentException) + { + WriteObject(false); + } + catch (System.NotSupportedException) + { + WriteObject(false); + } } } diff --git a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml index 0f6bb54..1312a20 100644 --- a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml +++ b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml @@ -10136,7 +10136,7 @@ PS C:\> Set-NTFSSecurityDescriptor -SecurityDescriptor $sd The cmdlet resolves paths through the AlphaFS library, which is not bound by the 260-character `MAX_PATH` limit of the Windows PowerShell file system provider. Use `Test-Path2` instead of `Test-Path` when a path can be longer than that limit. - A path that does not exist is not an error condition. The cmdlet writes `$false` and continues with the next path. + A path that does not exist is not an error condition. The cmdlet writes `$false` and continues with the next path. This also applies to a path with a character that Windows doesn't allow in names, such as `|` or `<`; before 5.0.0, such a path stopped the cmdlet with the terminating error "Illegal characters in path" in Windows PowerShell. diff --git a/Tests/ItemCmdlets.Tests.ps1 b/Tests/ItemCmdlets.Tests.ps1 index 36fd8e2..cfd91df 100644 --- a/Tests/ItemCmdlets.Tests.ps1 +++ b/Tests/ItemCmdlets.Tests.ps1 @@ -245,3 +245,130 @@ Describe 'Copy-Item2' { } } } + +Describe 'Test-Path2' { + BeforeAll { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'TestPath' -Directory + $file = Join-Path -Path $folder -ChildPath 'File.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $file + Set-Content -LiteralPath $file -Value 'File' + $missing = Join-Path -Path $folder -ChildPath 'Missing.txt' + $paths = @{ 'file' = $file; 'folder' = $folder; 'missing item' = $missing } + } + + It 'Should return for a with -PathType ' -ForEach @( + @{ Kind = 'file'; PathType = 'Any'; Expected = $true } + @{ Kind = 'folder'; PathType = 'Any'; Expected = $true } + @{ Kind = 'missing item'; PathType = 'Any'; Expected = $false } + @{ Kind = 'file'; PathType = 'Leaf'; Expected = $true } + @{ Kind = 'folder'; PathType = 'Leaf'; Expected = $false } + @{ Kind = 'missing item'; PathType = 'Leaf'; Expected = $false } + @{ Kind = 'file'; PathType = 'Container'; Expected = $false } + @{ Kind = 'folder'; PathType = 'Container'; Expected = $true } + @{ Kind = 'missing item'; PathType = 'Container'; Expected = $false } + ) { + $result = @(Test-Path2 -Path $paths[$Kind] -PathType $PathType -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0] | Should -BeOfType [bool] + $result[0] | Should -Be $Expected + } + + It 'Should write one value per path in the order of the paths' { + $result = @(Test-Path2 -Path $file, $missing, $folder -ErrorAction Stop) + + $result -join ',' | Should -Be 'True,False,True' + } + + It 'Should take the items from the pipeline' { + $result = @(Get-ChildItem -LiteralPath $folder | Test-Path2 -PathType Leaf -ErrorAction Stop) + + $result -join ',' | Should -Be 'True' + } + + It 'Should resolve a relative path against the current location' { + $relative = Join-Path -Path (Split-Path -Path $folder -Leaf) -ChildPath 'File.txt' + + Test-Path2 -Path $relative -ErrorAction Stop | Should -BeTrue + } + + It 'Should find a folder whose path is longer than 260 characters' { + $longRoot = Join-Path -Path $folder -ChildPath 'Long' + Assert-TestSandboxPath -Sandbox $sandbox -Path $longRoot + $long = Join-Path -Path $longRoot -ChildPath (('A' * 100), ('B' * 100), ('C' * 100) -join '\') + Add-Type -Path (Join-Path -Path (Get-Module -Name NTFSSecurity).ModuleBase -ChildPath 'AlphaFS.dll') + [Alphaleonis.Win32.Filesystem.Directory]::CreateDirectory($long) | Out-Null + try { + $long.Length | Should -BeGreaterThan 260 + + Test-Path2 -Path $long -PathType Container -ErrorAction Stop | Should -BeTrue + } finally { + # Remove-TestSandbox can't delete paths longer than 260 characters (#110). + [Alphaleonis.Win32.Filesystem.Directory]::Delete($longRoot, $true) + } + } + + # Before 5.0.0-rc6, a path with a character that Windows doesn't allow in file names stopped the cmdlet with a + # terminating "Illegal characters in path" error in Windows PowerShell. Such an item can't exist, so the cmdlet + # writes $false like for any other missing item, as in PowerShell 7 and like Test-Path. + It 'Should return $false for a path with the character <_> and continue with the next path' -ForEach @('|', '<', '>', '"', '*', '?') { + $invalid = Join-Path -Path $folder -ChildPath ('a{0}b' -f $_) + + $result = @(Test-Path2 -Path $invalid, $file -ErrorVariable testErrors -ErrorAction SilentlyContinue) + + $testErrors | Should -BeNullOrEmpty + $result -join ',' | Should -Be 'False,True' + } +} + +Describe 'Get-DiskSpace' { + BeforeAll { + $systemDrive = New-Object -TypeName 'System.IO.DriveInfo' -ArgumentList $env:SystemDrive + } + + It 'Should return the size of the system drive' { + $result = @(Get-DiskSpace -DriveLetter $env:SystemDrive -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0] | Should -BeOfType [Alphaleonis.Win32.Filesystem.DiskSpaceInfo] + $result[0].DriveName | Should -Be ('{0}\' -f $env:SystemDrive) + $result[0].TotalNumberOfBytes | Should -Be $systemDrive.TotalSize + } + + It 'Should report free space and clusters that fit the size' { + $result = Get-DiskSpace -DriveLetter $env:SystemDrive -ErrorAction Stop + + $result.TotalNumberOfFreeBytes | Should -BeLessOrEqual $result.TotalNumberOfBytes + $result.FreeBytesAvailable | Should -BeLessOrEqual $result.TotalNumberOfFreeBytes + $result.ClusterSize | Should -Be ($result.BytesPerSector * $result.SectorsPerCluster) + $result.NumberOfFreeClusters | Should -BeLessOrEqual $result.TotalNumberOfClusters + } + + It 'Should return the volumes with a size greater than zero without -DriveLetter' { + $result = @(Get-DiskSpace -WarningAction SilentlyContinue -ErrorAction Stop) + + $result | Should -Not -BeNullOrEmpty + $result | ForEach-Object -Process { $_.TotalNumberOfBytes | Should -BeGreaterThan 0 } + $result.TotalNumberOfBytes | Should -Contain $systemDrive.TotalSize + } + + It 'Should warn and return nothing for a drive letter without a volume' { + $used = @((Get-PSDrive -PSProvider FileSystem).Name) + @([System.IO.DriveInfo]::GetDrives() | ForEach-Object -Process { $_.Name.Substring(0, 1) }) + $letter = [char[]](68..90) | Where-Object -FilterScript { [string] $_ -notin $used } | Select-Object -Last 1 + if (-not $letter) { + Set-ItResult -Skipped -Because 'every drive letter is in use' + return + } + + $result = @(Get-DiskSpace -DriveLetter "${letter}:" -WarningVariable spaceWarnings -WarningAction SilentlyContinue -ErrorVariable spaceErrors -ErrorAction SilentlyContinue) + + $result | Should -BeNullOrEmpty + $spaceErrors | Should -BeNullOrEmpty + $spaceWarnings.Message | Should -Be "Could not get drive details for '${letter}:'" + } + + It 'Should reject a drive letter without a colon' { + { Get-DiskSpace -DriveLetter 'C' -ErrorAction Stop } | + Should -Throw -ErrorId 'ParameterArgumentValidationError,NTFSSecurity.GetDiskSpace' + } +} From ff74100d1a6bda661498ad0cbe01a7c2d6a8ec5f Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Thu, 8 Oct 2026 08:28:27 +0000 Subject: [PATCH 04/33] test: cover Set-NTFSOwner Set-NTFSOwner ran in no test of its own. Cover the owner change with and without -PassThru, pipeline input, an owner that only the Restore privilege allows, a missing path, an owner that Windows refuses (1307), and the -SecurityDescriptor parameter set. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- Tests/Owner.Tests.ps1 | 116 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/Tests/Owner.Tests.ps1 b/Tests/Owner.Tests.ps1 index 6e169ac..0ddc8ae 100644 --- a/Tests/Owner.Tests.ps1 +++ b/Tests/Owner.Tests.ps1 @@ -13,6 +13,8 @@ BeforeDiscovery { Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force # With the Backup privilege, Windows may grant reading the owner despite a deny entry. $canBypassDeny = Test-PrivilegeHeld -Name 'SeBackupPrivilege' + # Assigning an owner other than the user or one of its groups needs the Restore privilege. + $canAssignAnyOwner = Test-PrivilegeHeld -Name 'SeRestorePrivilege' } BeforeAll { @@ -126,3 +128,117 @@ Describe 'File and folder objects as arguments' { $result.FullName | Should -Be $file } } + +Describe 'Set-NTFSOwner' { + BeforeAll { + $sidType = [System.Security.Principal.SecurityIdentifier] + $currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value + # An owner that the user can assign only with the Restore privilege + $trustedInstaller = 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464' + $privateData = (Get-Module -Name NTFSSecurity).PrivateData + $enablePrivileges = $privateData['EnablePrivileges'] + + function Get-TestOwner { + param ([string] $Path) + + (Get-Acl -LiteralPath $Path).GetOwner($sidType).Value + } + } + + AfterAll { + $privateData['EnablePrivileges'] = $enablePrivileges + } + + It 'Should make the account the owner and write nothing without -PassThru' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'SetOwner' + + $result = @(Set-NTFSOwner -Path $file -Account $currentUser -ErrorAction Stop) + + $result | Should -BeNullOrEmpty + Get-TestOwner -Path $file | Should -Be $currentUser + } + + It 'Should return the new owner of a folder with -PassThru' { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'SetOwnerFolder' -Directory + + $result = @(Set-NTFSOwner -Path $folder -Account $currentUser -PassThru -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0] | Should -BeOfType [Security2.FileSystemOwner] + $result[0].FullName | Should -Be $folder + $result[0].Owner.Sid | Should -Be $currentUser + Get-TestOwner -Path $folder | Should -Be $currentUser + } + + It 'Should take the items from the pipeline' { + $files = 1..2 | ForEach-Object -Process { New-TestSandboxItem -Sandbox $sandbox -Name "SetOwnerPiped$_" } + + $result = @(Get-Item2 -Path $files | Set-NTFSOwner -Account $currentUser -PassThru -ErrorAction Stop) + + ($result.FullName -join '|') | Should -Be ($files -join '|') + } + + It 'Should set an owner that only the Restore privilege allows' -Skip:(-not $canAssignAnyOwner) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'SetOwnerRestore' + + Set-NTFSOwner -Path $file -Account $trustedInstaller -ErrorAction Stop + + Get-TestOwner -Path $file | Should -Be $trustedInstaller + } + + It 'Should write a read error for a path that does not exist and continue with the next path' { + $missing = Join-Path -Path $sandbox -ChildPath 'SetOwnerMissing.txt' + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'SetOwnerAfterMissing' + + Set-NTFSOwner -Path $missing, $file -Account $currentUser -ErrorVariable ownerErrors -ErrorAction SilentlyContinue + + $ownerErrors | Should -HaveCount 1 + $ownerErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadFileError,*' + Get-TestOwner -Path $file | Should -Be $currentUser + } + + Context 'When Windows refuses the owner' { + BeforeAll { + $privateData['EnablePrivileges'] = $false + } + + AfterAll { + $privateData['EnablePrivileges'] = $enablePrivileges + } + + # Without the Restore privilege, Windows refuses an owner other than the user or one of its groups: (1307) This + # security ID may not be assigned as the owner of this object. + It 'Should write a SetOwnerError and keep the owner' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'SetOwnerRefused' + $owner = Get-TestOwner -Path $file + (Get-Privileges | Where-Object -Property Privilege -EQ -Value 'Restore').PrivilegeState | Should -Not -Be 'Enabled' + + Set-NTFSOwner -Path $file -Account $trustedInstaller -ErrorVariable ownerErrors -ErrorAction SilentlyContinue + + $ownerErrors | Should -HaveCount 1 + $ownerErrors[0].FullyQualifiedErrorId | Should -BeLike 'SetOwnerError,*' + Get-TestOwner -Path $file | Should -Be $owner + } + } + + Context 'With -SecurityDescriptor' { + It 'Should change only the descriptor in memory until Set-NTFSSecurityDescriptor writes it' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'SetOwnerDescriptor' + $owner = Get-TestOwner -Path $file + if ($owner -eq $currentUser) { + # Only an elevated session creates items that the Administrators group owns. + Set-ItResult -Skipped -Because 'the user owns new items, and no other owner can be set without privileges' + return + } + $sd = Get-NTFSSecurityDescriptor -Path $file + + $result = @(Set-NTFSOwner -SecurityDescriptor $sd -Account $currentUser -PassThru -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0].Owner.Sid | Should -Be $currentUser + Get-TestOwner -Path $file | Should -Be $owner + Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -ErrorAction Stop + Get-TestOwner -Path $file | Should -Be $currentUser + } + } +} From 00c364672b65c331a7468ea9d966ec37e009201e Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Thu, 8 Oct 2026 08:29:18 +0000 Subject: [PATCH 05/33] chore(memory-bank): record the first part of step 1 of Phase 2 Record the new tests, the two defects that they found, and the privilege handling in Dispose since 5.0.0-rc6. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- .memory-bank/activeContext.md | 14 +++++++++++--- .memory-bank/systemPatterns.md | 6 +++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/.memory-bank/activeContext.md b/.memory-bank/activeContext.md index c4b404a..221c7ae 100644 --- a/.memory-bank/activeContext.md +++ b/.memory-bank/activeContext.md @@ -46,14 +46,22 @@ in favor of WindowsAccessControl (Decision 18). `AUTHZ_ACCESS_REPLY.Error`, and its buffers aren't initialized. #110 lists seven test follow-ups. - #34: no reply from the tester by 06:44 UTC on 2026-10-08. +- Phase 2, step 1, first part (2026-10-08, commits `82969ba` to + `ff74100`): 34 new tests for `Set-NTFSOwner`, `Test-Path2`, and + `Get-DiskSpace`, and two defects found and fixed test-first. Every + cmdlet that enables privileges left the Backup, Restore, Take Ownership, + and Security privileges enabled in the session when a later command or a + terminating error stopped the pipeline; `Test-Path2` stopped with + "Illegal characters in path" in Windows PowerShell for a path such as + `C:\a|b`. The suite (533 tests) passes elevated in both editions. ## Next step Phase 2, one step at a time, each with evidence before the next: -1. Tests for the cmdlets without tests of their own: `Test-Path2`, - `Get-DiskSpace`, `Set-NTFSOwner`, the link cmdlets, - `Get-NTFSOrphanedAudit`, and `Get-NTFSSimpleAccess`. +1. Tests for the cmdlets without tests of their own: done for + `Set-NTFSOwner`, `Test-Path2`, and `Get-DiskSpace`; open for the link + cmdlets, `Get-NTFSOrphanedAudit`, and `Get-NTFSSimpleAccess`. 2. The other parameter sets and error paths, such as the `-SecurityDescriptor` sets of the inheritance cmdlets and of `Clear-NTFSAccess`. diff --git a/.memory-bank/systemPatterns.md b/.memory-bank/systemPatterns.md index f3c0f3d..e0d32ed 100644 --- a/.memory-bank/systemPatterns.md +++ b/.memory-bank/systemPatterns.md @@ -35,7 +35,11 @@ NTFSSecurity.dll ── cmdlets ──> Security2.dll (FileSystemAccessRule2, 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`. + `$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`). - `PrivateData` switches: `EnablePrivileges`, `GetInheritedFrom`, `GetFileSystemModeProperty`, `IdentifyHardLinks`, `ShowAccountSid`. - Cmdlets accept `-Path` (alias `FullName`) or `-SecurityDescriptor`; the From 4d5c8c4ea64dc66c31015f2f1d14ddf5d907d931 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Thu, 8 Oct 2026 08:53:57 +0000 Subject: [PATCH 06/33] test: cover the link cmdlets Add 18 tests for New-NTFSHardLink, Get-NTFSHardLink, and New-NTFSSymbolicLink from the contracts of their cmdlet pages: output with and without -PassThru, relative paths, the refusal of an existing -Path and of a folder as the target of a hard link, the non-terminating errors for missing paths, and the error 1314 without the right to create symbolic links, compared by its HRESULT because the message is localized. The tests that need SeCreateSymbolicLinkPrivilege skip without it, and the test without it skips with it. Elevated and as a basic user, in Windows PowerShell 5.1 and PowerShell 7, all 20 link tests pass, and each one runs in at least one of the four configurations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- Tests/Links.Tests.ps1 | 226 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 226 insertions(+) diff --git a/Tests/Links.Tests.ps1 b/Tests/Links.Tests.ps1 index f75509a..8d69a92 100644 --- a/Tests/Links.Tests.ps1 +++ b/Tests/Links.Tests.ps1 @@ -6,6 +6,11 @@ )] param () +BeforeDiscovery { + Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force + $canCreateSymbolicLinks = Test-PrivilegeHeld -Name 'SeCreateSymbolicLinkPrivilege' +} + BeforeAll { Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force $modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\NTFSSecurity\bin\Release\NTFSSecurity.psd1' @@ -41,4 +46,225 @@ Describe 'New-NTFSHardLink' { { New-NTFSHardLink -Path $link -Target $missing -ErrorAction Stop } | Should -Throw -ExpectedMessage '*does not exist*' $link | Should -Not -Exist } + + It 'Should write nothing without -PassThru' { + $target = New-TestSandboxItem -Sandbox $sandbox -Name 'Quiet' + $link = Join-Path -Path $sandbox -ChildPath 'QuietLink.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $link + + $result = @(New-NTFSHardLink -Path $link -Target $target -ErrorAction Stop) + + $result | Should -BeNullOrEmpty + } + + It 'Should return every name of the file with -PassThru' { + $target = New-TestSandboxItem -Sandbox $sandbox -Name 'Names' + $link = Join-Path -Path $sandbox -ChildPath 'NamesLink.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $link + + $result = @(New-NTFSHardLink -Path $link -Target $target -PassThru -ErrorAction Stop) + + $result | Should -HaveCount 2 + $result | ForEach-Object -Process { $_ | Should -BeOfType [Alphaleonis.Win32.Filesystem.FileInfo] } + ($result.FullName | Sort-Object) -join '|' | Should -Be ((@($target, $link) | Sort-Object) -join '|') + $result[0].Mode | Should -Not -BeNullOrEmpty + } + + It 'Should give both names the same data' { + $target = New-TestSandboxItem -Sandbox $sandbox -Name 'Shared' + $link = Join-Path -Path $sandbox -ChildPath 'SharedLink.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $link + New-NTFSHardLink -Path $link -Target $target -ErrorAction Stop + + Set-Content -LiteralPath $link -Value 'Changed through the link' + + Get-Content -LiteralPath $target | Should -Be 'Changed through the link' + } + + It 'Should resolve relative paths against the current location' { + $target = New-TestSandboxItem -Sandbox $sandbox -Name 'Relative' + $link = Join-Path -Path $sandbox -ChildPath 'RelativeLink.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $link + + New-NTFSHardLink -Path 'RelativeLink.txt' -Target (Split-Path -Path $target -Leaf) -ErrorAction Stop + + $link | Should -Exist + } + + It 'Should refuse an existing -Path and leave it unchanged' { + $target = New-TestSandboxItem -Sandbox $sandbox -Name 'ExistingTarget' + $existing = New-TestSandboxItem -Sandbox $sandbox -Name 'Existing' + Set-Content -LiteralPath $existing -Value 'Existing' + + { New-NTFSHardLink -Path $existing -Target $target -ErrorAction Stop } | Should -Throw -ExpectedMessage '*already exist*' + + Get-Content -LiteralPath $existing | Should -Be 'Existing' + } + + It 'Should refuse a folder as -Target and create no link' { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'FolderTarget' -Directory + $link = Join-Path -Path $sandbox -ChildPath 'FolderLink.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $link + + { New-NTFSHardLink -Path $link -Target $folder -ErrorAction Stop } | Should -Throw -ExpectedMessage '*not a file*' + + $link | Should -Not -Exist + } +} + +Describe 'Get-NTFSHardLink' { + It 'Should return the file itself for a file with one name' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'Single' + + $result = @(Get-NTFSHardLink -Path $file -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0].FullName | Should -Be $file + $result[0].Mode | Should -Not -BeNullOrEmpty + } + + It 'Should return every name of a file with hard links' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'Linked' + $link = Join-Path -Path $sandbox -ChildPath 'LinkedLink.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $link + New-NTFSHardLink -Path $link -Target $file -ErrorAction Stop + + $result = @(Get-NTFSHardLink -Path $link -ErrorAction Stop) + + ($result.FullName | Sort-Object) -join '|' | Should -Be ((@($file, $link) | Sort-Object) -join '|') + } + + It 'Should take the files with more than one name from Get-ChildItem2' { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'Counted' -Directory + $file = Join-Path -Path $folder -ChildPath 'File.txt' + $link = Join-Path -Path $folder -ChildPath 'Link.txt' + $other = Join-Path -Path $folder -ChildPath 'Other.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $file, $link, $other + Set-Content -LiteralPath $file, $other -Value 'File' + New-NTFSHardLink -Path $link -Target $file -ErrorAction Stop + + $result = @(Get-ChildItem2 -Path $folder -File | Where-Object -Property HardLinkCount -GT -Value 1 | Get-NTFSHardLink -ErrorAction Stop) + + $result.FullName | Should -Not -Contain $other + @($result.FullName | Sort-Object -Unique) -join '|' | Should -Be ((@($file, $link) | Sort-Object) -join '|') + } + + It 'Should write an error for a path that does not exist and continue with the next path' { + $missing = Join-Path -Path $sandbox -ChildPath 'MissingHardLink.txt' + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'AfterMissing' + + $result = @(Get-NTFSHardLink -Path $missing, $file -ErrorVariable linkErrors -ErrorAction SilentlyContinue) + + $linkErrors | Should -HaveCount 1 + $linkErrors[0].FullyQualifiedErrorId | Should -BeLike 'FileNotFound,*' + $result.FullName | Should -Be $file + } +} + +Describe 'New-NTFSSymbolicLink' { + BeforeAll { + function Get-LinkTarget { + param ([string] $Path) + + # Windows PowerShell returns the target as an array, PowerShell 7 as a string. + @((Get-Item -LiteralPath $Path -Force).Target)[0] + } + } + + It 'Should create a link to a file that reads the data of the file' -Skip:(-not $canCreateSymbolicLinks) { + $target = New-TestSandboxItem -Sandbox $sandbox -Name 'SymbolicTarget' + Set-Content -LiteralPath $target -Value 'Target' + $link = Join-Path -Path $sandbox -ChildPath 'SymbolicFile.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $link + + $result = @(New-NTFSSymbolicLink -Path $link -Target $target -ErrorAction Stop) + + $result | Should -BeNullOrEmpty + (Get-Item -LiteralPath $link -Force).LinkType | Should -Be 'SymbolicLink' + Get-LinkTarget -Path $link | Should -Be $target + Get-Content -LiteralPath $link | Should -Be 'Target' + } + + It 'Should create a link to a folder through which the files of the folder are reachable' -Skip:(-not $canCreateSymbolicLinks) { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'SymbolicFolder' -Directory + $file = Join-Path -Path $folder -ChildPath 'File.txt' + $link = Join-Path -Path $sandbox -ChildPath 'SymbolicFolderLink' + Assert-TestSandboxPath -Sandbox $sandbox -Path $file, $link + Set-Content -LiteralPath $file -Value 'File' + + New-NTFSSymbolicLink -Path $link -Target $folder -ErrorAction Stop + + (Get-Item -LiteralPath $link -Force).Attributes.HasFlag([IO.FileAttributes]::Directory) | Should -BeTrue + Test-Path2 -Path (Join-Path -Path $link -ChildPath 'File.txt') -PathType Leaf | Should -BeTrue + } + + It 'Should return a file object for a link to a file with -PassThru' -Skip:(-not $canCreateSymbolicLinks) { + $target = New-TestSandboxItem -Sandbox $sandbox -Name 'SymbolicPassThru' + $link = Join-Path -Path $sandbox -ChildPath 'SymbolicPassThru.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $link + + $result = New-NTFSSymbolicLink -Path $link -Target $target -PassThru -ErrorAction Stop + + $result | Should -BeOfType [Alphaleonis.Win32.Filesystem.FileInfo] + $result.FullName | Should -Be $link + } + + # Before 5.0.0, the cmdlet returned a file object for a link to a folder. + It 'Should return a folder object for a link to a folder with -PassThru' -Skip:(-not $canCreateSymbolicLinks) { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'SymbolicPassThruFolder' -Directory + $link = Join-Path -Path $sandbox -ChildPath 'SymbolicPassThruFolderLink' + Assert-TestSandboxPath -Sandbox $sandbox -Path $link + + $result = New-NTFSSymbolicLink -Path $link -Target $folder -PassThru -ErrorAction Stop + + $result | Should -BeOfType [Alphaleonis.Win32.Filesystem.DirectoryInfo] + $result.FullName | Should -Be $link + } + + It 'Should store the absolute path of a relative target' -Skip:(-not $canCreateSymbolicLinks) { + $target = New-TestSandboxItem -Sandbox $sandbox -Name 'SymbolicRelative' + $link = Join-Path -Path $sandbox -ChildPath 'SymbolicRelative.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $link + + New-NTFSSymbolicLink -Path 'SymbolicRelative.txt' -Target (Split-Path -Path $target -Leaf) -ErrorAction Stop + + Get-LinkTarget -Path $link | Should -Be $target + } + + It 'Should refuse an existing -Path and leave it unchanged' -Skip:(-not $canCreateSymbolicLinks) { + $target = New-TestSandboxItem -Sandbox $sandbox -Name 'SymbolicExistingTarget' + $existing = New-TestSandboxItem -Sandbox $sandbox -Name 'SymbolicExisting' + Set-Content -LiteralPath $existing -Value 'Existing' + + { New-NTFSSymbolicLink -Path $existing -Target $target -ErrorAction Stop } | Should -Throw -ExpectedMessage '*already exist*' + + (Get-Item -LiteralPath $existing -Force).LinkType | Should -BeNullOrEmpty + Get-Content -LiteralPath $existing | Should -Be 'Existing' + } + + It 'Should write an error for a target that does not exist and create no link' { + $missing = Join-Path -Path $sandbox -ChildPath 'SymbolicMissing.txt' + $link = Join-Path -Path $sandbox -ChildPath 'SymbolicMissingLink.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $missing, $link + + New-NTFSSymbolicLink -Path $link -Target $missing -ErrorVariable linkErrors -ErrorAction SilentlyContinue + + $linkErrors | Should -HaveCount 1 + $linkErrors[0].FullyQualifiedErrorId | Should -BeLike 'CreateSymbolicLinkError,*' + Test-Path2 -Path $link | Should -BeFalse + } + + # Windows rejects the link with error 1314 without the "Create symbolic links" user right. Its message is + # localized, so the test compares the HRESULT of that error. + It 'Should fail and create no link without the right to create symbolic links' -Skip:$canCreateSymbolicLinks { + $target = New-TestSandboxItem -Sandbox $sandbox -Name 'SymbolicNoRight' + $link = Join-Path -Path $sandbox -ChildPath 'SymbolicNoRight.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $link + + $thrown = { New-NTFSSymbolicLink -Path $link -Target $target -ErrorAction Stop } | Should -Throw -PassThru + + $thrown.FullyQualifiedErrorId | Should -BeLike '*,NTFSSecurity.NewSymbolicLink' + '0x{0:X8}' -f $thrown.Exception.HResult | Should -Be '0x80070522' + Test-Path2 -Path $link | Should -BeFalse + } } From 09eb3371ac69d08d7ffb5aa3d5ac100cc5921f13 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Thu, 8 Oct 2026 08:53:58 +0000 Subject: [PATCH 07/33] docs: correct the output and Developer Mode notes of New-NTFSSymbolicLink Since 5.0.0, -PassThru returns a folder object for a link to a folder, as the OUTPUTS section and the changelog say; the description and the parameter still said a file object. The notes said that in Windows Developer Mode, accounts without the right to create symbolic links can create them. Windows allows that only to programs that request it, and the cmdlet doesn't: in the lab, with Developer Mode on, mklink created a link as an account without the right, and New-NTFSSymbolicLink of 5.0.0-rc5 failed with error 1314. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- Docs/Cmdlets/New-NTFSSymbolicLink.md | 6 +++--- NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Docs/Cmdlets/New-NTFSSymbolicLink.md b/Docs/Cmdlets/New-NTFSSymbolicLink.md index b1757ff..9770fea 100644 --- a/Docs/Cmdlets/New-NTFSSymbolicLink.md +++ b/Docs/Cmdlets/New-NTFSSymbolicLink.md @@ -25,7 +25,7 @@ The cmdlet inspects the target first and creates a file symbolic link when the t Relative paths are resolved against the current location before the link is created, which means that the link always stores an absolute target path. -By default the cmdlet produces no output. With `-PassThru` it returns a file object for the new link, including for a link that points to a folder. +By default the cmdlet produces no output. With `-PassThru` it returns an object for the new link: a file object for a link to a file, and a folder object for a link to a folder. ## EXAMPLES @@ -65,7 +65,7 @@ This command tests a path that leads through the symbolic link. It returns `$tru ### -PassThru -Indicates that the cmdlet returns an object for the new link. By default, this cmdlet produces no output. The returned object is a file object even when the link points to a folder. +Indicates that the cmdlet returns an object for the new link. By default, this cmdlet produces no output. The returned object is a file object for a link to a file and a folder object for a link to a folder. ```yaml Type: SwitchParameter @@ -132,7 +132,7 @@ With `-PassThru`, the cmdlet writes a folder object for a new link to a folder. ## NOTES -Creating a symbolic link on Windows requires the "Create symbolic links" user right, `SeCreateSymbolicLinkPrivilege`, which is granted to the Administrators group by default. Without that right, Windows rejects the operation with error 1314, "A required privilege is not held by the client", so run the cmdlet from an elevated session or grant the right to the account. On a computer that runs in Windows Developer Mode, Windows also allows accounts without that right to create symbolic links. +Creating a symbolic link on Windows requires the "Create symbolic links" user right, `SeCreateSymbolicLinkPrivilege`, which is granted to the Administrators group by default. Without that right, Windows rejects the operation with error 1314, "A required privilege is not held by the client", so run the cmdlet from an elevated session or grant the right to the account. Windows Developer Mode doesn't change this: it lets accounts without that right create symbolic links only in programs that request it, such as `mklink`, and the cmdlet doesn't. Unlike a hard link, a symbolic link is a separate file system entry that stores a path, so it can point to an item on another volume and the link and its target can be managed independently. The cmdlet still requires the target to exist at the moment the link is created. If the target is removed later, the link remains and stops resolving. diff --git a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml index 1312a20..fd2befb 100644 --- a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml +++ b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml @@ -7231,7 +7231,7 @@ PS C:\Data> Get-NTFSSecurityDescriptor The `New-NTFSSymbolicLink` cmdlet creates a symbolic link that redirects to another file or folder. `-Path` is the new link that the cmdlet creates, and `-Target` is the existing item that the link points to. Read the command as "create Path , which points to Target ". The cmdlet inspects the target first and creates a file symbolic link when the target is a file and a directory symbolic link when the target is a folder, so you do not select the link type yourself. `-Target` must exist when the link is created, and `-Path` must not exist yet, so the cmdlet never overwrites an existing item. Relative paths are resolved against the current location before the link is created, which means that the link always stores an absolute target path. - By default the cmdlet produces no output. With `-PassThru` it returns a file object for the new link, including for a link that points to a folder. + By default the cmdlet produces no output. With `-PassThru` it returns an object for the new link: a file object for a link to a file, and a folder object for a link to a folder. @@ -7263,7 +7263,7 @@ PS C:\Data> Get-NTFSSecurityDescriptor PassThru - Indicates that the cmdlet returns an object for the new link. By default, this cmdlet produces no output. The returned object is a file object even when the link points to a folder. + Indicates that the cmdlet returns an object for the new link. By default, this cmdlet produces no output. The returned object is a file object for a link to a file and a folder object for a link to a folder. SwitchParameter @@ -7277,7 +7277,7 @@ PS C:\Data> Get-NTFSSecurityDescriptor PassThru - Indicates that the cmdlet returns an object for the new link. By default, this cmdlet produces no output. The returned object is a file object even when the link points to a folder. + Indicates that the cmdlet returns an object for the new link. By default, this cmdlet produces no output. The returned object is a file object for a link to a file and a folder object for a link to a folder. SwitchParameter @@ -7341,7 +7341,7 @@ PS C:\Data> Get-NTFSSecurityDescriptor - Creating a symbolic link on Windows requires the "Create symbolic links" user right, `SeCreateSymbolicLinkPrivilege`, which is granted to the Administrators group by default. Without that right, Windows rejects the operation with error 1314, "A required privilege is not held by the client", so run the cmdlet from an elevated session or grant the right to the account. On a computer that runs in Windows Developer Mode, Windows also allows accounts without that right to create symbolic links. + Creating a symbolic link on Windows requires the "Create symbolic links" user right, `SeCreateSymbolicLinkPrivilege`, which is granted to the Administrators group by default. Without that right, Windows rejects the operation with error 1314, "A required privilege is not held by the client", so run the cmdlet from an elevated session or grant the right to the account. Windows Developer Mode doesn't change this: it lets accounts without that right create symbolic links only in programs that request it, such as `mklink`, and the cmdlet doesn't. Unlike a hard link, a symbolic link is a separate file system entry that stores a path, so it can point to an item on another volume and the link and its target can be managed independently. The cmdlet still requires the target to exist at the moment the link is created. If the target is removed later, the link remains and stops resolving. Deleting a symbolic link removes the link only and leaves the target untouched. Delete a directory symbolic link as a link rather than recursively, so that the content of the target folder is not affected. From 578042f09900dd10d49bbce6530d2c723c9f7f1a Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Thu, 8 Oct 2026 09:06:08 +0000 Subject: [PATCH 08/33] fix: disable the privileges by their current state when a cmdlet ends A cmdlet that enables the Backup, Restore, Take Ownership, and Security privileges decided which ones to disable on the states that it had read when it enabled them, and stopped at the first one that failed. When another command in the pipeline, such as Disable-Privileges, had disabled one of them, the cmdlet stopped with "Priviledge already disabled" and left the privileges after that one enabled. After an early stop, which Dispose handles since this branch, it left them enabled without any message, because PowerShell ignores exceptions thrown in Dispose; and Disable-Privileges threw that exception in Dispose on every call while the privileges were disabled. 4.2.6 already decided on the old states. DisablePrivilege now reads the current state, and the cleanup tries every privilege: in EndProcessing, a privilege that it can't disable gives a warning; in Dispose, it is ignored. The early-stop tests now pin EnablePrivileges and check that the cmdlet had enabled the privileges, so that they can't pass without testing anything. Found by the security review of fcb370e..00c3646 (findings 1 to 3). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- CHANGELOG.md | 5 ++ Docs/Concepts.md | 8 ++- NTFSSecurity/BaseCmdlets.cs | 35 +++++++++--- Tests/Privileges.Tests.ps1 | 106 +++++++++++++++++++++++++++++++++++- 4 files changed, 141 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 770d467..67f741b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -277,5 +277,10 @@ The format is based on Security privileges, which left them enabled in the session when a later command, such as `Select-Object -First`, or a terminating error stopped the pipeline early; they now disable them also then +- Fix the cmdlets that enable the privileges, which stopped with the error + "Priviledge already disabled" and left the other privileges enabled when + another command in the pipeline, such as `Disable-Privileges`, had + disabled one of them; a privilege that they can't disable now gives a + warning, and they still disable the others [Unreleased]: https://github.com/raandree/NTFSSecurity/compare/4.2.6...HEAD diff --git a/Docs/Concepts.md b/Docs/Concepts.md index d196d8e..680c0c3 100644 --- a/Docs/Concepts.md +++ b/Docs/Concepts.md @@ -189,13 +189,17 @@ session runs elevated (**Run as administrator**). The access, audit, inheritance, owner, and security descriptor cmdlets enable these privileges automatically while they run and disable the ones they enabled when they finish, also when a later command such as -`Select-Object -First` or a terminating error stops the pipeline early. If a +`Select-Object -First` or a terminating error stops the pipeline early. A +privilege that another command in the pipeline, such as `Disable-Privileges`, +has disabled in the meantime stays disabled, and a privilege that a cmdlet +can't disable when it finishes gives a warning. If a privilege cannot be enabled, the cmdlet continues without it. You can turn this behavior off with the `EnablePrivileges` module setting. Before 5.0.0, the inheritance cmdlets were an exception: they always tried to enable the privileges, and when `EnablePrivileges` was `$false`, they left them enabled. And before 5.0.0, every cmdlet left the privileges enabled in the session when -the pipeline stopped early. +the pipeline stopped early, and stopped with the error "Priviledge already +disabled" when another command in the pipeline had disabled one of them. `Enable-Privileges` enables the four privileges for the current PowerShell process until you run `Disable-Privileges` or close the session. diff --git a/NTFSSecurity/BaseCmdlets.cs b/NTFSSecurity/BaseCmdlets.cs index 9a300be..e2f2263 100644 --- a/NTFSSecurity/BaseCmdlets.cs +++ b/NTFSSecurity/BaseCmdlets.cs @@ -214,33 +214,48 @@ namespace NTFSSecurity //disable all privileges that have been enabled by this cmdlet WriteVerbose(string.Format("Disabeling all {0} enabled privileges...", enabledPrivileges.Count)); - foreach (var privilege in DisableEnabledPrivileges()) + var failed = new Dictionary(); + foreach (var privilege in DisableEnabledPrivileges(failed)) { WriteVerbose(string.Format("\t{0} disabled", privilege)); } + foreach (var failure in failed) + { + WriteDebug(string.Format("Could not disable privilege {0}. The error was: {1}", failure.Key, failure.Value.Message)); + WriteWarning(string.Format("The privilege '{0}' could not be disabled.", failure.Key)); + } WriteVerbose(string.Format("...finished")); } } // PowerShell calls Dispose also when a later command or a terminating error stops the pipeline, and then // skips EndProcessing. Before 5.0.0-rc6, the privileges that the cmdlet had enabled stayed enabled in the - // session in that case. + // session in that case. PowerShell ignores an exception from Dispose and no stream is open anymore, so a + // privilege that can't be disabled goes unreported here. public void Dispose() { - DisableEnabledPrivileges(); + DisableEnabledPrivileges(new Dictionary()); GC.SuppressFinalize(this); } - // Disables the privileges that this cmdlet enabled, once, and returns their names. - private List DisableEnabledPrivileges() + // Disables the privileges that this cmdlet enabled, once, and returns their names. A privilege that can't be + // disabled goes to failed with its error and doesn't keep the others enabled. + private List DisableEnabledPrivileges(Dictionary failed) { var disabled = new List(); if (!KeepEnabledPrivileges) { foreach (var privilege in enabledPrivileges) { - DisablePrivilege((Privilege)Enum.Parse(typeof(Privilege), privilege)); - disabled.Add(privilege); + try + { + DisablePrivilege((Privilege)Enum.Parse(typeof(Privilege), privilege)); + disabled.Add(privilege); + } + catch (Exception ex) + { + failed[privilege] = ex; + } } } @@ -270,8 +285,10 @@ namespace NTFSSecurity public void DisablePrivilege(Privilege privilege) { - //if the privilege is enabled - if (privileges.Single(p => p.Privilege == privilege).PrivilegeState == PrivilegeState.Enabled) + // The current state, not the one that the cmdlet read when it enabled the privileges: another command, also + // one in the same pipeline, can have disabled the privilege since then. Before 5.0.0-rc6, the cmdlet then + // failed with "Priviledge already disabled" and left the privileges after this one enabled. + if (privControl.GetPrivileges().Any(p => p.Privilege == privilege && p.PrivilegeState == PrivilegeState.Enabled)) privControl.DisablePrivilege(privilege); } diff --git a/Tests/Privileges.Tests.ps1 b/Tests/Privileges.Tests.ps1 index c688cf2..e75e18f 100644 --- a/Tests/Privileges.Tests.ps1 +++ b/Tests/Privileges.Tests.ps1 @@ -29,6 +29,13 @@ BeforeAll { function Get-BackupPrivilegeState { (Get-Privileges | Where-Object -Property Privilege -EQ -Value 'Backup').PrivilegeState } + + function Get-EnabledFileSystemPrivilege { + # The names of the privileges that the cmdlets enable, as far as they are enabled now + @(Get-Privileges | Where-Object -FilterScript { + $_.Privilege -in 'TakeOwnership', 'Restore', 'Backup', 'Security' -and $_.PrivilegeState -eq 'Enabled' + } | ForEach-Object -Process { $_.Privilege.ToString() }) + } } AfterAll { @@ -122,10 +129,16 @@ Describe 'Inheritance cmdlets' { Describe 'Privileges when the pipeline stops early' { BeforeAll { + # The cmdlets enable the privileges only with this setting; without it, these tests would prove nothing. + $privateData['EnablePrivileges'] = $true $files = 1..3 | ForEach-Object -Process { New-TestSandboxItem -Sandbox $sandbox -Name "Stopped$_" } $missing = Join-Path -Path $sandbox -ChildPath 'StoppedMissing.txt' } + AfterAll { + $privateData['EnablePrivileges'] = $enablePrivileges + } + BeforeEach { Disable-Privileges -ErrorAction SilentlyContinue -WarningAction SilentlyContinue } @@ -141,16 +154,24 @@ Describe 'Privileges when the pipeline stops early' { It 'Should disable the privileges after Select-Object -First stops the pipeline' -Skip:(-not $holdsPrivileges) { Get-BackupPrivilegeState | Should -Be 'Disabled' - Get-NTFSOwner -Path $files | Select-Object -First 1 | Out-Null + $stateWhileRunning = Get-NTFSOwner -Path $files | ForEach-Object -Process { Get-BackupPrivilegeState } | + Select-Object -First 1 + $stateWhileRunning | Should -Be 'Enabled' Get-BackupPrivilegeState | Should -Be 'Disabled' } It 'Should disable the privileges after a terminating error' -Skip:(-not $holdsPrivileges) { Get-BackupPrivilegeState | Should -Be 'Disabled' + $statesWhileRunning = New-Object -TypeName 'System.Collections.Generic.List[string]' - { Get-NTFSAccess -Path $missing, $files[0] -ErrorAction Stop } | Should -Throw + { + Get-NTFSAccess -Path $files[0], $missing -ErrorAction Stop | + ForEach-Object -Process { $statesWhileRunning.Add((Get-BackupPrivilegeState)) } + } | Should -Throw + $statesWhileRunning | Should -Not -BeNullOrEmpty + $statesWhileRunning | Should -Not -Contain 'Disabled' Get-BackupPrivilegeState | Should -Be 'Disabled' } @@ -160,3 +181,84 @@ Describe 'Privileges when the pipeline stops early' { Get-BackupPrivilegeState | Should -Be 'Enabled' } } + +Describe 'Privileges that another command in the pipeline changes' { + BeforeAll { + $privateData['EnablePrivileges'] = $true + $files = 1..3 | ForEach-Object -Process { New-TestSandboxItem -Sandbox $sandbox -Name "Changed$_" } + + function Disable-TakeOwnershipOnce { + # Passes the objects on and disables the Take Ownership privilege when the first one passes, as another + # command in the pipeline can. Records the state of the Backup privilege at that moment. + param ( + [Parameter(ValueFromPipeline)] + [object] + $InputObject, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [System.Collections.Generic.List[string]] + $BackupState + ) + + begin { + $first = $true + } + + process { + if ($first) { + $BackupState.Add((Get-BackupPrivilegeState)) + $null = [ProcessPrivileges.ProcessExtensions]::DisablePrivilege( + [System.Diagnostics.Process]::GetCurrentProcess(), [ProcessPrivileges.Privilege]::TakeOwnership + ) + $first = $false + } + + $InputObject + } + } + } + + AfterAll { + $privateData['EnablePrivileges'] = $enablePrivileges + } + + BeforeEach { + Disable-Privileges -ErrorAction SilentlyContinue -WarningAction SilentlyContinue + } + + AfterEach { + Disable-Privileges -ErrorAction SilentlyContinue -WarningAction SilentlyContinue + } + + # Before 5.0.0-rc6, a cmdlet decided which privileges to disable on the states that it had read when it enabled + # them. A privilege that another command had disabled since then stopped it with "Priviledge already disabled", + # and the privileges after that one in its list stayed enabled. + It 'Should disable the other privileges when another command disabled one of them' -Skip:(-not $holdsPrivileges) { + $backupState = New-Object -TypeName 'System.Collections.Generic.List[string]' + + { Get-NTFSOwner -Path $files | Disable-TakeOwnershipOnce -BackupState $backupState | Out-Null } | Should -Not -Throw + + $backupState | Should -Be 'Enabled' + Get-EnabledFileSystemPrivilege | Should -BeNullOrEmpty + } + + It 'Should disable the other privileges when another command disabled one of them and the pipeline stops early' -Skip:(-not $holdsPrivileges) { + $backupState = New-Object -TypeName 'System.Collections.Generic.List[string]' + + Get-NTFSOwner -Path $files | Disable-TakeOwnershipOnce -BackupState $backupState | Select-Object -First 1 | Out-Null + + $backupState | Should -Be 'Enabled' + Get-EnabledFileSystemPrivilege | Should -BeNullOrEmpty + } + + It 'Should not fail when Disable-Privileges runs inside the pipeline' -Skip:(-not $holdsPrivileges) { + { + Get-NTFSOwner -Path $files | + ForEach-Object -Process { Disable-Privileges -WarningAction SilentlyContinue; $_ } | + Out-Null + } | Should -Not -Throw + + Get-EnabledFileSystemPrivilege | Should -BeNullOrEmpty + } +} From b241441c6867b6d5ca38008e1e8160873431ddce Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Thu, 8 Oct 2026 09:06:21 +0000 Subject: [PATCH 09/33] fix: write why Test-Path2 returns false for a path that is not valid Since this branch, Test-Path2 writes $false for a path that Windows PowerShell rejects, such as one with a |, but it didn't say why. It now writes the reason as a debug message. Only the lookup of the item is in the try block, so that an error elsewhere in the cmdlet can't turn into $false. Found by the security review of fcb370e..00c3646 (finding 4). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- CHANGELOG.md | 3 +- Docs/Cmdlets/Test-Path2.md | 2 +- NTFSSecurity/PathCmdlets/TestPath2.cs | 45 ++++++++++++-------- NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml | 2 +- Tests/ItemCmdlets.Tests.ps1 | 16 +++++++ 5 files changed, 47 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67f741b..6d6048a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -272,7 +272,8 @@ The format is based on - Fix `Test-Path2`, which stopped with the terminating error "Illegal characters in path" in Windows PowerShell for a path with a character that Windows doesn't allow in names, such as `|`; it now returns `$false` - for such a path, as in PowerShell 7 + for such a path, as in PowerShell 7, and writes the reason as a debug + message - Fix the cmdlets that enable the Backup, Restore, Take Ownership, and Security privileges, which left them enabled in the session when a later command, such as `Select-Object -First`, or a terminating error stopped diff --git a/Docs/Cmdlets/Test-Path2.md b/Docs/Cmdlets/Test-Path2.md index bf28c32..5b47fc5 100644 --- a/Docs/Cmdlets/Test-Path2.md +++ b/Docs/Cmdlets/Test-Path2.md @@ -117,7 +117,7 @@ For each path, the cmdlet writes `$true` when the item exists and matches `-Path The cmdlet resolves paths through the AlphaFS library, which is not bound by the 260-character `MAX_PATH` limit of the Windows PowerShell file system provider. Use `Test-Path2` instead of `Test-Path` when a path can be longer than that limit. -A path that does not exist is not an error condition. The cmdlet writes `$false` and continues with the next path. This also applies to a path with a character that Windows doesn't allow in names, such as `|` or `<`; before 5.0.0, such a path stopped the cmdlet with the terminating error "Illegal characters in path" in Windows PowerShell. +A path that does not exist is not an error condition. The cmdlet writes `$false` and continues with the next path. This also applies to a path with a character that Windows doesn't allow in names, such as `|` or `<`; Windows PowerShell rejects such a path, and the cmdlet writes the reason as a debug message. Before 5.0.0, such a path stopped the cmdlet with the terminating error "Illegal characters in path" in Windows PowerShell. ## RELATED LINKS diff --git a/NTFSSecurity/PathCmdlets/TestPath2.cs b/NTFSSecurity/PathCmdlets/TestPath2.cs index b087a2b..5d7c0eb 100644 --- a/NTFSSecurity/PathCmdlets/TestPath2.cs +++ b/NTFSSecurity/PathCmdlets/TestPath2.cs @@ -45,42 +45,51 @@ namespace NTFSSecurity { foreach (var path in paths) { + FileSystemInfo item = null; try { - FileSystemInfo item; TryGetFileSystemInfo2(path, out item); - - if (item == null) - WriteObject(false); - else - { - if (PathType == TestPathType.Any) - WriteObject(true); - else if (PathType == TestPathType.Container & item is DirectoryInfo) - WriteObject(true); - else if (PathType == TestPathType.Leaf & item is FileInfo) - WriteObject(true); - else - WriteObject(false); - } } catch (System.IO.FileNotFoundException ex) { WriteError(new ErrorRecord(ex, "PathNotFound", ErrorCategory.ObjectNotFound, path)); + continue; } // In Windows PowerShell, .NET rejects a path with a character that Windows doesn't allow in names. // Such an item can't exist, so the cmdlet writes $false, as in PowerShell 7 and like Test-Path. - catch (System.ArgumentException) + catch (System.ArgumentException ex) { - WriteObject(false); + WriteInvalidPath(path, ex); + continue; } - catch (System.NotSupportedException) + catch (System.NotSupportedException ex) { + WriteInvalidPath(path, ex); + continue; + } + + if (item == null) WriteObject(false); + else + { + if (PathType == TestPathType.Any) + WriteObject(true); + else if (PathType == TestPathType.Container & item is DirectoryInfo) + WriteObject(true); + else if (PathType == TestPathType.Leaf & item is FileInfo) + WriteObject(true); + else + WriteObject(false); } } } + private void WriteInvalidPath(string path, System.Exception exception) + { + WriteDebug(string.Format("'{0}' is not a valid path: {1}", path, exception.Message)); + WriteObject(false); + } + protected override void EndProcessing() { base.EndProcessing(); diff --git a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml index fd2befb..9dec184 100644 --- a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml +++ b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml @@ -10136,7 +10136,7 @@ PS C:\> Set-NTFSSecurityDescriptor -SecurityDescriptor $sd The cmdlet resolves paths through the AlphaFS library, which is not bound by the 260-character `MAX_PATH` limit of the Windows PowerShell file system provider. Use `Test-Path2` instead of `Test-Path` when a path can be longer than that limit. - A path that does not exist is not an error condition. The cmdlet writes `$false` and continues with the next path. This also applies to a path with a character that Windows doesn't allow in names, such as `|` or `<`; before 5.0.0, such a path stopped the cmdlet with the terminating error "Illegal characters in path" in Windows PowerShell. + A path that does not exist is not an error condition. The cmdlet writes `$false` and continues with the next path. This also applies to a path with a character that Windows doesn't allow in names, such as `|` or `<`; Windows PowerShell rejects such a path, and the cmdlet writes the reason as a debug message. Before 5.0.0, such a path stopped the cmdlet with the terminating error "Illegal characters in path" in Windows PowerShell. diff --git a/Tests/ItemCmdlets.Tests.ps1 b/Tests/ItemCmdlets.Tests.ps1 index cfd91df..ae29a04 100644 --- a/Tests/ItemCmdlets.Tests.ps1 +++ b/Tests/ItemCmdlets.Tests.ps1 @@ -319,6 +319,22 @@ Describe 'Test-Path2' { $testErrors | Should -BeNullOrEmpty $result -join ',' | Should -Be 'False,True' } + + # PowerShell 7 accepts these characters and finds no item, so only Windows PowerShell rejects the path. Before + # 5.0.0-rc6, the cmdlet wrote $false for a rejected path without saying why. -Debug would prompt in Windows + # PowerShell, so the test sets the preference. + It 'Should say in a debug message why it writes $false for a path that Windows PowerShell rejects' -Skip:($PSVersionTable.PSEdition -ne 'Desktop') { + $invalid = Join-Path -Path $folder -ChildPath 'a|b' + $DebugPreference = 'Continue' + + $output = @(Test-Path2 -Path $invalid -ErrorAction Stop 5>&1) + + $messages = @($output | Where-Object -FilterScript { $_ -is [Management.Automation.DebugRecord] } | + Where-Object -Property Message -Like -Value '*is not a valid path*') + $messages | Should -HaveCount 1 + $messages[0].Message.Contains("'$invalid'") | Should -BeTrue + $output | Where-Object -FilterScript { $_ -is [bool] } | Should -BeFalse + } } Describe 'Get-DiskSpace' { From 164b2f0af30593696c56c6d9b2ba3111ab5a710a Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Thu, 8 Oct 2026 09:07:58 +0000 Subject: [PATCH 10/33] chore(memory-bank): record the link tests and the review fix round Record in activeContext the link cmdlet tests, the two corrections of the New-NTFSSymbolicLink page with the lab check of Developer Mode, the security review of fcb370e..00c3646 and its fix round, the next step, and the open question about unprivileged symbolic links. Add the milestone to progress, the cleanup rule for privileges to systemPatterns, and the way to check the lab client as an account without administrator rights to techContext. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- .memory-bank/activeContext.md | 37 ++++++++++++++++++++++++++++++---- .memory-bank/progress.md | 7 +++++++ .memory-bank/systemPatterns.md | 6 +++++- .memory-bank/techContext.md | 6 +++++- 4 files changed, 50 insertions(+), 6 deletions(-) diff --git a/.memory-bank/activeContext.md b/.memory-bank/activeContext.md index 221c7ae..f386e2e 100644 --- a/.memory-bank/activeContext.md +++ b/.memory-bank/activeContext.md @@ -53,15 +53,37 @@ in favor of WindowsAccessControl (Decision 18). and Security privileges enabled in the session when a later command or a terminating error stopped the pipeline; `Test-Path2` stopped with "Illegal characters in path" in Windows PowerShell for a path such as - `C:\a|b`. The suite (533 tests) passes elevated in both editions. + `C:\a|b`. +- Phase 2, step 1, link cmdlets (2026-10-08, `4d5c8c4`, `09eb337`): 18 new + tests for `New-NTFSHardLink`, `Get-NTFSHardLink`, and + `New-NTFSSymbolicLink` pass elevated and as a basic user in both + editions; no code defect. The page of `New-NTFSSymbolicLink` was wrong + twice: `-PassThru` returns a folder object for a link to a folder since + 5.0.0, and Windows Developer Mode doesn't help, because AlphaFS 2.2.1 + passes only the File or Directory flag to `CreateSymbolicLinkW`. Lab + check on `F1AFile1` as `NtfsLiveServerAdmin` (no administrator): + with Developer Mode on, `mklink` created a link and the cmdlet of + 5.0.0-rc5 failed with error 1314; the setting was restored. +- Security review of `fcb370e..00c3646` (`security-reviewer`, 2026-10-08): + the `Dispose` approach is sound; two Major findings, both one root cause + that 4.2.6 already had: the privilege cleanup decided on the states read + in `BeginProcessing` and stopped at the first failure. Reproduced: a + privilege that another command in the pipeline disabled left the others + enabled (silently after an early stop), and + `Get-NTFSOwner ... | ForEach-Object { Disable-Privileges; $_ }` stopped + with "Priviledge already disabled". Two Minor findings: the early-stop + tests could pass vacuously, and `Test-Path2` gave no reason for `$false`. + The maintainer chose to fix all four; fixed test-first in `578042f` and + `b241441`. The suite (555 tests) passes elevated in both editions + (534/0/21 and 504/0/51), and the changed test files pass as a basic user. ## Next step Phase 2, one step at a time, each with evidence before the next: 1. Tests for the cmdlets without tests of their own: done for - `Set-NTFSOwner`, `Test-Path2`, and `Get-DiskSpace`; open for the link - cmdlets, `Get-NTFSOrphanedAudit`, and `Get-NTFSSimpleAccess`. + `Set-NTFSOwner`, `Test-Path2`, `Get-DiskSpace`, and the link cmdlets; + open for `Get-NTFSOrphanedAudit` and `Get-NTFSSimpleAccess`. 2. The other parameter sets and error paths, such as the `-SecurityDescriptor` sets of the inheritance cmdlets and of `Clear-NTFSAccess`. @@ -71,4 +93,11 @@ Phase 2, one step at a time, each with evidence before the next: 5. A CI job as a basic user, live tests for the other cmdlets over SMB and for accounts of other forests, and a lab run. 6. Measure the coverage again, explain what remains, review, and prepare - 5.0.0-rc6. + 5.0.0-rc6. The final review covers the whole branch, including the fix + round of `578042f` and `b241441`. + +Open question for the maintainer, not planned: `New-NTFSSymbolicLink` +could request unprivileged creation with +`SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE`, so that Developer Mode +works. That is a behavior change (Decision 16) and needs a fallback for +Windows versions before 10 1703, which don't know the flag. diff --git a/.memory-bank/progress.md b/.memory-bank/progress.md index 05fc1ac..c9435e0 100644 --- a/.memory-bank/progress.md +++ b/.memory-bank/progress.md @@ -76,6 +76,13 @@ users move to WindowsAccessControl (Decision 18). 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. ## Stable capabilities diff --git a/.memory-bank/systemPatterns.md b/.memory-bank/systemPatterns.md index e0d32ed..44118f5 100644 --- a/.memory-bank/systemPatterns.md +++ b/.memory-bank/systemPatterns.md @@ -39,7 +39,11 @@ NTFSSecurity.dll ── cmdlets ──> Security2.dll (FileSystemAccessRule2, 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`). + (`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 diff --git a/.memory-bank/techContext.md b/.memory-bank/techContext.md index b7355a2..84e6aaf 100644 --- a/.memory-bank/techContext.md +++ b/.memory-bank/techContext.md @@ -199,7 +199,11 @@ source: repository evidence `-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. + 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. - 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 From 3ff9c21cec76595fe011f8393d6203c18bbf50a0 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Thu, 8 Oct 2026 09:32:32 +0000 Subject: [PATCH 11/33] test: cover Get-NTFSOrphanedAudit Add tests from the contract of its cmdlet page: the security descriptor parameter set, an inherited entry with and without -ExcludeInherited, an entry of an account that resolves, the verbose count, the error for a missing path and for a descriptor without the audit entries, and the result without the Security privilege, which the page describes as empty. The setup of the entries moves into BeforeAll, so that the -Account test no longer depends on the test before it (#110). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- Tests/Audit.Tests.ps1 | 88 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 78 insertions(+), 10 deletions(-) diff --git a/Tests/Audit.Tests.ps1 b/Tests/Audit.Tests.ps1 index 1c58430..a9bc2ef 100644 --- a/Tests/Audit.Tests.ps1 +++ b/Tests/Audit.Tests.ps1 @@ -183,24 +183,92 @@ Describe 'Add-NTFSAudit' { Describe 'Get-NTFSOrphanedAudit' { BeforeAll { $orphanedFile = New-TestSandboxItem -Sandbox $sandbox -Name 'OrphanedAudit' + $missing = Join-Path -Path $sandbox -ChildPath 'MissingOrphanedAudit.txt' } - # Before 5.0.0, the cmdlet wrote the entries of an item as one collection and ignored -Account. - It 'Should return one object per entry whose account cannot be resolved' -Skip:(-not $canReadAudit) { - foreach ($sid in 'S-1-5-21-1-2-3-1001', 'S-1-5-21-1-2-3-1002') { - Add-NTFSAudit -Path $orphanedFile -Account $sid -AccessRights ReadData -InheritanceFlags None -PropagationFlags None + Context 'With the Security privilege' { + BeforeAll { + # Two entries of accounts that don't exist and one of Everyone, which resolves. Each test reads them, so + # none depends on another one. + foreach ($sid in 'S-1-5-21-1-2-3-1001', 'S-1-5-21-1-2-3-1002', 'S-1-1-0') { + Add-NTFSAudit -Path $orphanedFile -Account $sid -AccessRights ReadData -InheritanceFlags None -PropagationFlags None -ErrorAction Stop + } + + $orphanedFolder = New-TestSandboxItem -Sandbox $sandbox -Name 'OrphanedAuditFolder' -Directory + Add-NTFSAudit -Path $orphanedFolder -Account 'S-1-5-21-1-2-3-1003' -AccessRights Delete -ErrorAction Stop + $inheritingFile = Join-Path -Path $orphanedFolder -ChildPath 'File.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $inheritingFile + Set-Content -LiteralPath $inheritingFile -Value 'File' + } + + # Before 5.0.0, the cmdlet wrote the entries of an item as one collection and ignored -Account. + It 'Should return one object per entry whose account cannot be resolved' -Skip:(-not $canReadAudit) { + $result = @(Get-NTFSOrphanedAudit -Path $orphanedFile) + + $result | Should -HaveCount 2 + $result | ForEach-Object -Process { $_ | Should -BeOfType [Security2.FileSystemAuditRule2] } + $result.Account.Sid | Should -Not -Contain 'S-1-1-0' + } + + It 'Should return only the entries of -Account' -Skip:(-not $canReadAudit) { + $result = @(Get-NTFSOrphanedAudit -Path $orphanedFile -Account 'S-1-5-21-1-2-3-1002') + + $result | Should -HaveCount 1 + $result[0].Account.Sid | Should -Be 'S-1-5-21-1-2-3-1002' + } + + It 'Should read the entries of a security descriptor' -Skip:(-not $canReadAudit) { + $result = @(Get-NTFSSecurityDescriptor -Path $orphanedFile | Get-NTFSOrphanedAudit -ErrorAction Stop) + + $result | Should -HaveCount 2 + $result | ForEach-Object -Process { $_.FullName | Should -Be $orphanedFile } + } + + It 'Should return an inherited entry, and nothing with -ExcludeInherited' -Skip:(-not $canReadAudit) { + $result = @(Get-NTFSOrphanedAudit -Path $inheritingFile -ErrorAction Stop) + $explicitResult = @(Get-NTFSOrphanedAudit -Path $inheritingFile -ExcludeInherited -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0].Account.Sid | Should -Be 'S-1-5-21-1-2-3-1003' + $result[0].IsInherited | Should -BeTrue + $explicitResult | Should -BeNullOrEmpty } - $result = @(Get-NTFSOrphanedAudit -Path $orphanedFile) + It 'Should report the number of orphaned entries of each item in a verbose message' -Skip:(-not $canReadAudit) { + $messages = @(Get-NTFSOrphanedAudit -Path $orphanedFile -Verbose 4>&1 | Where-Object -FilterScript { + $_ -is [System.Management.Automation.VerboseRecord] }) + + $messages.Message | Should -Contain "Item $orphanedFile knows about 2 orphaned SIDs in its ACL" + } + } + + It 'Should write an error for a path that does not exist and continue with the next path' { + $result = @(Get-NTFSOrphanedAudit -Path $missing, $orphanedFile -ErrorVariable orphanedErrors -ErrorAction SilentlyContinue) + + $orphanedErrors | Should -HaveCount 1 + $orphanedErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadError,*' + $orphanedErrors[0].TargetObject | Should -Be $missing + $result | ForEach-Object -Process { $_.FullName | Should -Be $orphanedFile } + } + + It 'Should write an error for a security descriptor that was read without the audit entries' { + $sd = New-Object -TypeName 'Security2.FileSystemSecurity2' -ArgumentList ( + (Get-Item2 -Path $orphanedFile), [System.Security.AccessControl.AccessControlSections]::Access + ) - $result | Should -HaveCount 2 - $result | ForEach-Object -Process { $_ | Should -BeOfType [Security2.FileSystemAuditRule2] } + $result = @($sd | Get-NTFSOrphanedAudit -ErrorVariable orphanedErrors -ErrorAction SilentlyContinue) + + $result | Should -BeNullOrEmpty + $orphanedErrors | Should -HaveCount 1 + $orphanedErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadSecurityError,*' } - It 'Should return only the entries of -Account' -Skip:(-not $canReadAudit) { - $result = @(Get-NTFSOrphanedAudit -Path $orphanedFile -Account 'S-1-5-21-1-2-3-1002') + # The cmdlet page: without the privilege, the cmdlet reads no audit entries and reports none. + It 'Should return nothing and write no error without the Security privilege' -Skip:$canReadAudit { + $result = @(Get-NTFSOrphanedAudit -Path $orphanedFile -ErrorVariable orphanedErrors -ErrorAction SilentlyContinue) - $result | Should -HaveCount 1 + $orphanedErrors | Should -BeNullOrEmpty + $result | Should -BeNullOrEmpty } } From c4b422ae97707c773e9a63e2222fd61e6cc6daad Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Thu, 8 Oct 2026 09:32:32 +0000 Subject: [PATCH 12/33] fix: report ReadData and the parent of a relative path in Get-NTFSSimpleAccess Two deviations from the cmdlet page, found by new tests from its contract: - An entry that grants only ReadData became None: the reduction tested the combined Read mask, and nothing mapped ReadData alone. .NET adds Synchronize to every allow entry, which hid it; other tools write such entries. - With -IncludeRootFolder, on by default, a relative path with a single folder name had no parent folder in the result, because the parent was taken from the path before it was resolved. The tests also cover the rights reduction, the comparison of folders with their parent, the pipeline, the current location, -ExcludeExplicit, files, and missing paths, elevated and as a basic user in both editions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- CHANGELOG.md | 4 + Docs/Cmdlets/Get-NTFSSimpleAccess.md | 2 +- .../SimpleAccessCmdlets.cs | 4 +- NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml | 2 +- .../FileSystem/SimpleFileSystemAccessRule.cs | 4 + Tests/Access.Tests.ps1 | 161 ++++++++++++++++++ 6 files changed, 174 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d6048a..871d0ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -283,5 +283,9 @@ The format is based on another command in the pipeline, such as `Disable-Privileges`, had disabled one of them; a privilege that they can't disable now gives a warning, and they still disable the others +- Fix `Get-NTFSSimpleAccess`, which showed no rights for an entry that + grants only `ReadData`, and which left out the parent folder of a + relative path with a single folder name, although `-IncludeRootFolder` is + on by default [Unreleased]: https://github.com/raandree/NTFSSecurity/compare/4.2.6...HEAD diff --git a/Docs/Cmdlets/Get-NTFSSimpleAccess.md b/Docs/Cmdlets/Get-NTFSSimpleAccess.md index 5581768..a038b81 100644 --- a/Docs/Cmdlets/Get-NTFSSimpleAccess.md +++ b/Docs/Cmdlets/Get-NTFSSimpleAccess.md @@ -190,7 +190,7 @@ When the module setting `EnablePrivileges` is `$true` (the default in the `Priva The simplified rights hide which exact rights an account holds. Use `Get-NTFSAccess` when you need the full access control entry, and `Get-NTFSEffectiveAccess` when you need the rights that result from all entries together. -Before 5.0.0, the cmdlet ignored `-Account` and `-SecurityDescriptor`, and its output had no table view. +Before 5.0.0, the cmdlet ignored `-Account` and `-SecurityDescriptor`, and its output had no table view. It also showed no rights for an entry that grants only `ReadData`, which other tools than .NET create, and it left out the parent folder of a relative path with a single folder name, such as `Data`. ## RELATED LINKS diff --git a/NTFSSecurity/SimpleAccessCmdlets/SimpleAccessCmdlets.cs b/NTFSSecurity/SimpleAccessCmdlets/SimpleAccessCmdlets.cs index 43970ec..8cb56ff 100644 --- a/NTFSSecurity/SimpleAccessCmdlets/SimpleAccessCmdlets.cs +++ b/NTFSSecurity/SimpleAccessCmdlets/SimpleAccessCmdlets.cs @@ -47,7 +47,9 @@ namespace NTFSSecurity //as this cmdlet retreives also the current working folder to show the permissions. if (includeRootFolder & isFirstFolder) { - string rootPath = System.IO.Path.GetDirectoryName(paths[0]); + // Resolved first, so that a relative path with a single folder name has the current location as its + // parent folder; before 5.0.0-rc6, such a path had no parent folder in the result. + string rootPath = System.IO.Path.GetDirectoryName(GetRelativePath(paths[0])); if (!string.IsNullOrEmpty(rootPath)) { diff --git a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml index 9dec184..8dcd09f 100644 --- a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml +++ b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml @@ -6624,7 +6624,7 @@ PS C:\Data> Get-NTFSSecurityDescriptor When the module setting `EnablePrivileges` is `$true` (the default in the `PrivateData` section of NTFSSecurity.psd1), this cmdlet tries to enable the Backup, Restore, Take Ownership, and Security privileges while it runs and disables the privileges it enabled when it finishes. These privileges are only available in an elevated session of an account that holds them, such as a member of the local Administrators group. If a privilege cannot be enabled, the cmdlet continues without it and writes a debug message. The simplified rights hide which exact rights an account holds. Use `Get-NTFSAccess` when you need the full access control entry, and `Get-NTFSEffectiveAccess` when you need the rights that result from all entries together. - Before 5.0.0, the cmdlet ignored `-Account` and `-SecurityDescriptor`, and its output had no table view. + Before 5.0.0, the cmdlet ignored `-Account` and `-SecurityDescriptor`, and its output had no table view. It also showed no rights for an entry that grants only `ReadData`, which other tools than .NET create, and it left out the parent folder of a relative path with a single folder name, such as `Data`. diff --git a/Security2/FileSystem/SimpleFileSystemAccessRule.cs b/Security2/FileSystem/SimpleFileSystemAccessRule.cs index 6c6e47c..ddbddd8 100644 --- a/Security2/FileSystem/SimpleFileSystemAccessRule.cs +++ b/Security2/FileSystem/SimpleFileSystemAccessRule.cs @@ -42,6 +42,10 @@ namespace Security2 if ((accessRights & FileSystemRights2.Read) == FileSystemRights2.Read) { result |= SimpleFileSystemAccessRights.Read; } + // An entry with ReadData alone, which other tools than .NET create, was None before 5.0.0-rc6. + if ((accessRights & FileSystemRights2.ReadData) == FileSystemRights2.ReadData) + { result |= SimpleFileSystemAccessRights.Read; } + if ((accessRights & FileSystemRights2.CreateFiles) == FileSystemRights2.CreateFiles) { result |= SimpleFileSystemAccessRights.Write; } diff --git a/Tests/Access.Tests.ps1 b/Tests/Access.Tests.ps1 index fee022f..9df8bb3 100644 --- a/Tests/Access.Tests.ps1 +++ b/Tests/Access.Tests.ps1 @@ -228,6 +228,167 @@ Describe 'Get-NTFSSimpleAccess' { $text | Should -Match 'Account\s+Access Rights\s+Type' } + + Context 'When it reduces the rights of an entry' { + BeforeAll { + # One explicit entry per case, for a SID of its own and with exactly these rights. .NET would add + # Synchronize to an allow entry, so the DACL is set in SDDL. + $rightsFolder = New-TestSandboxItem -Sandbox $sandbox -Name 'SimpleRights' -Directory + $rightsCases = @( + @{ Name = 'ReadData'; Mask = 0x1; Expected = 'Read' } + @{ Name = 'ReadAttributes'; Mask = 0x80; Expected = 'Read' } + @{ Name = 'Traverse'; Mask = 0x20; Expected = 'Read' } + @{ Name = 'ReadPermissions'; Mask = 0x20000; Expected = 'Read' } + @{ Name = 'CreateFiles'; Mask = 0x2; Expected = 'Write' } + @{ Name = 'AppendData'; Mask = 0x4; Expected = 'Write' } + @{ Name = 'WriteAttributes'; Mask = 0x100; Expected = 'Write' } + @{ Name = 'ChangePermissions'; Mask = 0x40000; Expected = 'Write' } + @{ Name = 'TakeOwnership'; Mask = 0x80000; Expected = 'Write' } + @{ Name = 'Delete'; Mask = 0x10000; Expected = 'Delete' } + @{ Name = 'DeleteSubdirectoriesAndFiles'; Mask = 0x40; Expected = 'Delete' } + @{ Name = 'Modify'; Mask = 0x1301BF; Expected = 'Read, Write, Delete' } + @{ Name = 'FullControl'; Mask = 0x1F01FF; Expected = 'Read, Write, Delete' } + ) + $rightsSid = @{} + $entries = for ($i = 0; $i -lt $rightsCases.Count; $i++) { + $sid = 'S-1-5-21-1-2-3-{0}' -f (3001 + $i) + $rightsSid[$rightsCases[$i].Name] = $sid + '(A;;0x{0:X};;;{1})' -f $rightsCases[$i].Mask, $sid + } + $acl = Get-Acl -LiteralPath $rightsFolder + $acl.SetSecurityDescriptorSddlForm(('D:{0}' -f ($entries -join '')), 'Access') + Set-Acl -LiteralPath $rightsFolder -AclObject $acl + + $rightsResult = @(Get-NTFSSimpleAccess -Path $rightsFolder -ExcludeInherited -IncludeRootFolder:$false -ErrorAction Stop) + } + + # Before 5.0.0-rc6, an entry with ReadData alone, which .NET never creates but other tools do, became None. + It 'Should reduce to ' -ForEach @( + @{ Name = 'ReadData'; Expected = 'Read' } + @{ Name = 'ReadAttributes'; Expected = 'Read' } + @{ Name = 'Traverse'; Expected = 'Read' } + @{ Name = 'ReadPermissions'; Expected = 'Read' } + @{ Name = 'CreateFiles'; Expected = 'Write' } + @{ Name = 'AppendData'; Expected = 'Write' } + @{ Name = 'WriteAttributes'; Expected = 'Write' } + @{ Name = 'ChangePermissions'; Expected = 'Write' } + @{ Name = 'TakeOwnership'; Expected = 'Write' } + @{ Name = 'Delete'; Expected = 'Delete' } + @{ Name = 'DeleteSubdirectoriesAndFiles'; Expected = 'Delete' } + @{ Name = 'Modify'; Expected = 'Read, Write, Delete' } + @{ Name = 'FullControl'; Expected = 'Read, Write, Delete' } + ) { + $entry = @($rightsResult | Where-Object -FilterScript { $_.Identity.Sid -eq $rightsSid[$Name] }) + + $entry | Should -HaveCount 1 + $entry[0].AccessRights.ToString() | Should -Be $Expected + } + } + + Context 'When it compares folders with their parent folder' { + BeforeAll { + # The parent grants Everyone ReadData to its subfolders and the user Full Control, so that a basic user + # can create the items; the child adds an entry of its own. The child is created after the DACL of the + # parent, so that it inherits only these entries. + $parent = New-TestSandboxItem -Sandbox $sandbox -Name 'SimpleParent' -Directory + $user = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value + $acl = Get-Acl -LiteralPath $parent + $acl.SetSecurityDescriptorSddlForm("D:P(A;OICI;0x120089;;;WD)(A;OICI;FA;;;BA)(A;OICI;FA;;;SY)(A;OICI;FA;;;$user)", 'Access') + Set-Acl -LiteralPath $parent -AclObject $acl + $child = Join-Path -Path $parent -ChildPath 'Child' + Assert-TestSandboxPath -Sandbox $sandbox -Path $child + New-Item -ItemType Directory -Path $child | Out-Null + $acl = Get-Acl -LiteralPath $child + $acl.AddAccessRule((New-Object -TypeName 'System.Security.AccessControl.FileSystemAccessRule' -ArgumentList ( + (New-Object -TypeName 'System.Security.Principal.SecurityIdentifier' -ArgumentList 'S-1-5-21-1-2-3-3101'), + [System.Security.AccessControl.FileSystemRights]::Modify, [System.Security.AccessControl.AccessControlType]::Allow + ))) + Set-Acl -LiteralPath $child -AclObject $acl + $childFile = Join-Path -Path $child -ChildPath 'File.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $childFile + Set-Content -LiteralPath $childFile -Value 'File' -ErrorAction Stop + } + + It 'Should report all entries of the first folder and of the following folder only what the parent does not cover' { + $result = @(Get-NTFSSimpleAccess -Path $parent, $child -IncludeRootFolder:$false -ErrorAction Stop) + + @($result | Where-Object -Property FullName -EQ -Value $parent) | Should -HaveCount 4 + $childEntries = @($result | Where-Object -Property FullName -EQ -Value $child) + $childEntries | Should -HaveCount 1 + $childEntries[0].Identity.Sid | Should -Be 'S-1-5-21-1-2-3-3101' + } + + It 'Should compare the folders from the pipeline in the same way' { + $result = @(Get-Item2 -Path $parent, $child | Get-NTFSSimpleAccess -IncludeRootFolder:$false -ErrorAction Stop) + + @($result | Where-Object -Property FullName -EQ -Value $child).Identity.Sid | Should -Be 'S-1-5-21-1-2-3-3101' + } + + It 'Should report the parent folder of the first path first by default' { + $result = @(Get-NTFSSimpleAccess -Path $child -ErrorAction Stop) + + $result[0].FullName | Should -Be $parent + @($result | Where-Object -Property FullName -EQ -Value $child).Identity.Sid | Should -Be 'S-1-5-21-1-2-3-3101' + } + + # Before 5.0.0-rc6, a relative path with a single folder name had no parent folder in the result. + It 'Should report the parent folder of a relative path first by default' { + Push-Location -LiteralPath $parent + try { + $result = @(Get-NTFSSimpleAccess -Path 'Child' -ErrorAction Stop) + } + finally { + Pop-Location + } + + $result[0].FullName | Should -Be $parent + @($result | Where-Object -Property FullName -EQ -Value $child).Identity.Sid | Should -Be 'S-1-5-21-1-2-3-3101' + } + + It 'Should use the current location without -Path' { + Push-Location -LiteralPath $child + try { + $result = @(Get-NTFSSimpleAccess -IncludeRootFolder:$false -ErrorAction Stop) + } + finally { + Pop-Location + } + + $result | ForEach-Object -Process { $_.FullName | Should -Be $child } + } + + It 'Should return only the inherited entries with -ExcludeExplicit' { + $result = @(Get-NTFSSimpleAccess -Path $child -ExcludeExplicit -IncludeRootFolder:$false -ErrorAction Stop) + + $result | Should -HaveCount 4 + $result.Identity.Sid | Should -Not -Contain 'S-1-5-21-1-2-3-3101' + } + + It 'Should skip a file without an error' { + $result = @(Get-NTFSSimpleAccess -Path $childFile -IncludeRootFolder:$false -ErrorVariable simpleErrors -ErrorAction SilentlyContinue) + + $simpleErrors | Should -BeNullOrEmpty + $result | Should -BeNullOrEmpty + } + + It 'Should report the security descriptor of a file' { + $result = @(Get-NTFSSecurityDescriptor -Path $childFile | Get-NTFSSimpleAccess -ErrorAction Stop) + + $result | Should -Not -BeNullOrEmpty + $result | ForEach-Object -Process { $_.FullName | Should -Be $childFile } + } + + It 'Should write an error for a path that does not exist and continue with the next path' { + $missing = Join-Path -Path $parent -ChildPath 'Missing' + + $result = @(Get-NTFSSimpleAccess -Path $missing, $child -IncludeRootFolder:$false -ErrorVariable simpleErrors -ErrorAction SilentlyContinue) + + $simpleErrors | Should -HaveCount 1 + $simpleErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadError,*' + $result | Should -Not -BeNullOrEmpty + $result | ForEach-Object -Process { $_.FullName | Should -Be $child } + } + } } Describe 'Remove-NTFSAccess' { From f977fec525ce6bde0749ccfed3db6e8bc43fe1bf Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Thu, 8 Oct 2026 09:50:14 +0000 Subject: [PATCH 13/33] fix: detect destination folders and name a missing destination folder in Copy-Item2 and Move-Item2 The check for an existing destination looked for a file only. For a folder whose name existed at the destination, the cmdlets failed in the middle with a CopyError or a MoveError, and Copy-Item2 could copy a part of the folder first. They now write DestinationFileAlreadyExists, as for a file. When the folder that is to contain the new item didn't exist, AlphaFS reported a DirectoryNotFoundException that named the source item, which reproduces the symptom of #21. The cmdlets now write an error that names the missing folder, with the destination as the target. Copy-Item2 no longer creates the missing folders for a folder: the workaround that creates the destination folder for AlphaFS created its parents as well, which only the prereleases of 5.0.0 did. The pages also say that -Force merges a folder into an existing folder of the same name, and that a folder can't move to another volume. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- CHANGELOG.md | 8 ++ Docs/Cmdlets/Copy-Item2.md | 6 +- Docs/Cmdlets/Move-Item2.md | 8 +- NTFSSecurity/BaseCmdlets.cs | 23 ++++++ NTFSSecurity/ItemCmdlets/CopyItem2.cs | 8 +- NTFSSecurity/ItemCmdlets/MoveItem2.cs | 8 +- NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml | 18 ++--- Tests/ItemCmdlets.Tests.ps1 | 83 ++++++++++++++++++++ 8 files changed, 144 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 871d0ed..53bc6d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -287,5 +287,13 @@ The format is based on grants only `ReadData`, and which left out the parent folder of a relative path with a single folder name, although `-IncludeRootFolder` is on by default +- Fix `Copy-Item2` and `Move-Item2`, which didn't detect a folder at the + destination, so that a copy failed in the middle after it had copied a + part of the folder, and which reported a missing destination folder with + an error that named the source item + ([#21](https://github.com/raandree/NTFSSecurity/issues/21)); they now + write `DestinationFileAlreadyExists` and an error that names the missing + folder. `Copy-Item2` no longer creates the missing folders of the + destination when it copies a folder, which the prereleases of 5.0.0 did [Unreleased]: https://github.com/raandree/NTFSSecurity/compare/4.2.6...HEAD diff --git a/Docs/Cmdlets/Copy-Item2.md b/Docs/Cmdlets/Copy-Item2.md index fd7fcaa..e272ff4 100644 --- a/Docs/Cmdlets/Copy-Item2.md +++ b/Docs/Cmdlets/Copy-Item2.md @@ -24,7 +24,7 @@ The `Copy-Item2` cmdlet copies the items in `-Path` to the location in `-Destina How `-Destination` is interpreted depends on what is already there. If the value names an existing folder, the cmdlet keeps the name of the source item and copies it into that folder. In every other case the value is the full path of the new item, which lets you copy and rename in one step. `-Destination` is resolved against the current location once, when the cmdlet starts. -Without `-Force`, the cmdlet checks whether the destination file already exists and writes a `DestinationFileAlreadyExists` error instead of overwriting it. With `-WhatIf`, it names an existing destination file in a verbose message instead; before 5.0.0, it wrote the error also with `-WhatIf`. With `-Force`, an existing file is replaced. Relative paths and the `.` and `..` notations in `-Path` are resolved against the current location, and wildcard characters are not supported. +Without `-Force`, the cmdlet checks whether a file or folder already exists at the destination and writes a `DestinationFileAlreadyExists` error instead of overwriting it or merging into it. With `-WhatIf`, it names an existing destination in a verbose message instead; before 5.0.0, it wrote the error also with `-WhatIf`. With `-Force`, an existing file is replaced, and a folder is copied into an existing folder of the same name, replacing the files that exist in both. The folder that is to contain the new item must exist; otherwise the cmdlet writes an error that names that folder. Relative paths and the `.` and `..` notations in `-Path` are resolved against the current location, and wildcard characters are not supported. The cmdlet supports `-WhatIf` and `-Confirm`, and it writes nothing to the pipeline unless you specify `-PassThru $true`. @@ -98,7 +98,7 @@ Accept wildcard characters: False ### -Force -Indicates that the cmdlet overwrites an existing destination file. Without `-Force`, an existing file causes the error `DestinationFileAlreadyExists` and the item is not copied. +Indicates that the cmdlet overwrites an existing destination file, and copies a folder into an existing folder of the same name. Without `-Force`, an existing file or folder at the destination causes the error `DestinationFileAlreadyExists` and the item is not copied. ```yaml Type: SwitchParameter @@ -192,7 +192,7 @@ Before 5.0.0, `-PassThru` also wrote the item when `-WhatIf` or a declined confi Before 5.0.0, copying a folder that contained files failed with a `CopyError` that reported a `DirectoryNotFoundException` for the first file in the folder. -If a path in `-Path` does not exist or the destination file exists and `-Force` is missing, the cmdlet writes a non-terminating error and continues with the next path. Before 5.0.0, it skipped the remaining paths that were passed in the same call. +If a path in `-Path` does not exist, a file or folder exists at the destination and `-Force` is missing, or the folder that is to contain the copy does not exist, the cmdlet writes a non-terminating error and continues with the next path. Before 5.0.0, it skipped the remaining paths that were passed in the same call. Before 5.0.0, it also didn't detect an existing destination folder, so that the copy failed in the middle with a `CopyError` after it had copied a part of the folder; it reported a missing destination folder as a `DirectoryNotFoundException` that named the source item; and, in the prereleases of 5.0.0, it created the missing folders of the destination for a folder. ## RELATED LINKS diff --git a/Docs/Cmdlets/Move-Item2.md b/Docs/Cmdlets/Move-Item2.md index 82549dd..a71a067 100644 --- a/Docs/Cmdlets/Move-Item2.md +++ b/Docs/Cmdlets/Move-Item2.md @@ -24,7 +24,7 @@ The `Move-Item2` cmdlet moves the items in `-Path` to the location in `-Destinat How `-Destination` is interpreted depends on what is already there. If the value names an existing folder, the cmdlet keeps the name of the source item and moves it into that folder. In every other case the value is the full path of the new item, which lets you move and rename in one step, or rename an item in place. `-Destination` is resolved against the current location once, when the cmdlet starts. -Without `-Force`, the cmdlet checks whether the destination file already exists and writes a `DestinationFileAlreadyExists` error instead of overwriting it; the move itself then runs with the `CopyAllowed` option, which allows a file to move to a different volume. With `-WhatIf`, the cmdlet names an existing destination file in a verbose message instead; before 5.0.0, it wrote the error also with `-WhatIf`. With `-Force`, the move runs with the `ReplaceExisting` option and overwrites an existing destination item. +Without `-Force`, the cmdlet checks whether a file or folder already exists at the destination and writes a `DestinationFileAlreadyExists` error instead of overwriting it; the move itself then runs with the `CopyAllowed` option, which allows a file to move to a different volume. With `-WhatIf`, the cmdlet names an existing destination in a verbose message instead; before 5.0.0, it wrote the error also with `-WhatIf`. With `-Force`, the move runs with the `ReplaceExisting` option and overwrites an existing destination item. The folder that is to contain the moved item must exist; otherwise the cmdlet writes an error that names that folder. The cmdlet supports `-WhatIf` and `-Confirm`, and it writes nothing to the pipeline unless you specify `-PassThru $true`. @@ -98,7 +98,7 @@ Accept wildcard characters: False ### -Force -Indicates that the cmdlet replaces an existing destination item. Without `-Force`, an existing destination file causes the error `DestinationFileAlreadyExists` and the item is not moved. +Indicates that the cmdlet replaces an existing destination item. Without `-Force`, an existing file or folder at the destination causes the error `DestinationFileAlreadyExists` and the item is not moved. ```yaml Type: SwitchParameter @@ -190,9 +190,9 @@ With `-PassThru $true` the cmdlet returns a folder object for each folder that i Before 5.0.0, `-PassThru` also wrote the item when `-WhatIf` or a declined confirmation skipped the operation. -The cmdlet chooses between two mutually exclusive move options. Without `-Force` it moves with `CopyAllowed`, which permits a file to cross volume boundaries because Windows then copies and deletes it. With `-Force` it moves with `ReplaceExisting`, which overwrites the destination but does not request `CopyAllowed`, so a move across volumes can fail when `-Force` is specified. +The cmdlet chooses between two mutually exclusive move options. Without `-Force` it moves with `CopyAllowed`, which permits a file to cross volume boundaries because Windows then copies and deletes it. With `-Force` it moves with `ReplaceExisting`, which overwrites the destination but does not request `CopyAllowed`, so a move across volumes can fail when `-Force` is specified. A folder can't move to another volume: the cmdlet writes a `MoveError` and leaves the folder in place, so copy it with `Copy-Item2` and remove it with `Remove-Item2` instead. -If a path in `-Path` does not exist or the destination file exists and `-Force` is missing, the cmdlet writes a non-terminating error and continues with the next path. Before 5.0.0, it skipped the remaining paths that were passed in the same call. +If a path in `-Path` does not exist, a file or folder exists at the destination and `-Force` is missing, or the folder that is to contain the moved item does not exist, the cmdlet writes a non-terminating error and continues with the next path. Before 5.0.0, it skipped the remaining paths that were passed in the same call. Before 5.0.0, it also reported an existing destination folder as a `MoveError`, and a missing destination folder as a `DirectoryNotFoundException` that named the source item ([#21](https://github.com/raandree/NTFSSecurity/issues/21)). ## RELATED LINKS diff --git a/NTFSSecurity/BaseCmdlets.cs b/NTFSSecurity/BaseCmdlets.cs index e2f2263..d5a3646 100644 --- a/NTFSSecurity/BaseCmdlets.cs +++ b/NTFSSecurity/BaseCmdlets.cs @@ -149,6 +149,29 @@ namespace NTFSSecurity } #endregion + #region WriteMissingDestinationFolderError + /// + /// Writes an error that names the folder of a destination path when that folder doesn't exist. Before + /// 5.0.0-rc6, AlphaFS reported such a destination as the source path that could not be found (#21), and + /// Copy-Item2 created the missing folders for a folder. + /// + /// The full path of the item that the operation would create. + /// The error ID of the cmdlet for a failed operation. + /// Whether the folder is missing and the error was written. + protected bool WriteMissingDestinationFolderError(string destinationPath, string errorId) + { + var folder = Alphaleonis.Win32.Filesystem.Path.GetDirectoryName(destinationPath.TrimEnd('\\')); + if (string.IsNullOrEmpty(folder) || Alphaleonis.Win32.Filesystem.Directory.Exists(folder)) + { + return false; + } + + var exception = new System.IO.DirectoryNotFoundException(string.Format("The destination folder '{0}' does not exist.", folder)); + WriteError(new ErrorRecord(exception, errorId, ErrorCategory.ObjectNotFound, destinationPath)); + return true; + } + #endregion + #region InvokeAsOwner /// /// Takes ownership of the item, runs the action, and restores the previous owner on every exit path. diff --git a/NTFSSecurity/ItemCmdlets/CopyItem2.cs b/NTFSSecurity/ItemCmdlets/CopyItem2.cs index 322f2b7..fdbe5bb 100644 --- a/NTFSSecurity/ItemCmdlets/CopyItem2.cs +++ b/NTFSSecurity/ItemCmdlets/CopyItem2.cs @@ -88,7 +88,8 @@ namespace NTFSSecurity actualDestination = destination; } - var destinationExists = !force && File.Exists(actualDestination); + // A folder at the destination counts as well; before 5.0.0-rc6, only a file did. + var destinationExists = !force && (File.Exists(actualDestination) || Directory.Exists(actualDestination)); // Report a conflict only for an operation that runs; -WhatIf names it in a verbose message (#108). if (!ShouldProcess(resolvedPath, item is FileInfo ? "Copy File" : "Copy Directory")) @@ -107,6 +108,11 @@ namespace NTFSSecurity continue; } + if (WriteMissingDestinationFolderError(actualDestination, "CopyError")) + { + continue; + } + try { FileSystemInfo copy = null; diff --git a/NTFSSecurity/ItemCmdlets/MoveItem2.cs b/NTFSSecurity/ItemCmdlets/MoveItem2.cs index e941e37..52ea1d6 100644 --- a/NTFSSecurity/ItemCmdlets/MoveItem2.cs +++ b/NTFSSecurity/ItemCmdlets/MoveItem2.cs @@ -88,7 +88,8 @@ namespace NTFSSecurity actualDestination = destination; } - var destinationExists = !force && File.Exists(actualDestination); + // A folder at the destination counts as well; before 5.0.0-rc6, only a file did. + var destinationExists = !force && (File.Exists(actualDestination) || Directory.Exists(actualDestination)); // Report a conflict only for an operation that runs; -WhatIf names it in a verbose message (#108). if (!ShouldProcess(resolvedPath, item is FileInfo ? "Move File" : "Move Directory")) @@ -107,6 +108,11 @@ namespace NTFSSecurity continue; } + if (WriteMissingDestinationFolderError(actualDestination, "MoveError")) + { + continue; + } + try { if (item is FileInfo) diff --git a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml index 8dcd09f..5276fae 100644 --- a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml +++ b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml @@ -1997,7 +1997,7 @@ PS C:\> Set-NTFSSecurityDescriptor -SecurityDescriptor $sd The `Copy-Item2` cmdlet copies the items in `-Path` to the location in `-Destination`. It is the long-path counterpart of the built-in `Copy-Item` cmdlet: it works through the AlphaFS library (`Alphaleonis.Win32.Filesystem`), so source and destination may be longer than the 260-character `MAX_PATH` limit. How `-Destination` is interpreted depends on what is already there. If the value names an existing folder, the cmdlet keeps the name of the source item and copies it into that folder. In every other case the value is the full path of the new item, which lets you copy and rename in one step. `-Destination` is resolved against the current location once, when the cmdlet starts. - Without `-Force`, the cmdlet checks whether the destination file already exists and writes a `DestinationFileAlreadyExists` error instead of overwriting it. With `-WhatIf`, it names an existing destination file in a verbose message instead; before 5.0.0, it wrote the error also with `-WhatIf`. With `-Force`, an existing file is replaced. Relative paths and the `.` and `..` notations in `-Path` are resolved against the current location, and wildcard characters are not supported. + Without `-Force`, the cmdlet checks whether a file or folder already exists at the destination and writes a `DestinationFileAlreadyExists` error instead of overwriting it or merging into it. With `-WhatIf`, it names an existing destination in a verbose message instead; before 5.0.0, it wrote the error also with `-WhatIf`. With `-Force`, an existing file is replaced, and a folder is copied into an existing folder of the same name, replacing the files that exist in both. The folder that is to contain the new item must exist; otherwise the cmdlet writes an error that names that folder. Relative paths and the `.` and `..` notations in `-Path` are resolved against the current location, and wildcard characters are not supported. The cmdlet supports `-WhatIf` and `-Confirm`, and it writes nothing to the pipeline unless you specify `-PassThru $true`. @@ -2041,7 +2041,7 @@ PS C:\> Set-NTFSSecurityDescriptor -SecurityDescriptor $sd Force - Indicates that the cmdlet overwrites an existing destination file. Without `-Force`, an existing file causes the error `DestinationFileAlreadyExists` and the item is not copied. + Indicates that the cmdlet overwrites an existing destination file, and copies a folder into an existing folder of the same name. Without `-Force`, an existing file or folder at the destination causes the error `DestinationFileAlreadyExists` and the item is not copied. SwitchParameter @@ -2102,7 +2102,7 @@ PS C:\> Set-NTFSSecurityDescriptor -SecurityDescriptor $sd Force - Indicates that the cmdlet overwrites an existing destination file. Without `-Force`, an existing file causes the error `DestinationFileAlreadyExists` and the item is not copied. + Indicates that the cmdlet overwrites an existing destination file, and copies a folder into an existing folder of the same name. Without `-Force`, an existing file or folder at the destination causes the error `DestinationFileAlreadyExists` and the item is not copied. SwitchParameter @@ -2189,7 +2189,7 @@ PS C:\> Set-NTFSSecurityDescriptor -SecurityDescriptor $sd `Copy-Item2` copies through the AlphaFS library (`Alphaleonis.Win32.Filesystem`), which is why it handles source and destination paths that exceed the 260-character `MAX_PATH` limit of the built-in `Copy-Item` cmdlet. Before 5.0.0, `-PassThru` also wrote the item when `-WhatIf` or a declined confirmation skipped the operation. Before 5.0.0, copying a folder that contained files failed with a `CopyError` that reported a `DirectoryNotFoundException` for the first file in the folder. - If a path in `-Path` does not exist or the destination file exists and `-Force` is missing, the cmdlet writes a non-terminating error and continues with the next path. Before 5.0.0, it skipped the remaining paths that were passed in the same call. + If a path in `-Path` does not exist, a file or folder exists at the destination and `-Force` is missing, or the folder that is to contain the copy does not exist, the cmdlet writes a non-terminating error and continues with the next path. Before 5.0.0, it skipped the remaining paths that were passed in the same call. Before 5.0.0, it also didn't detect an existing destination folder, so that the copy failed in the middle with a `CopyError` after it had copied a part of the folder; it reported a missing destination folder as a `DirectoryNotFoundException` that named the source item; and, in the prereleases of 5.0.0, it created the missing folders of the destination for a folder. @@ -6784,7 +6784,7 @@ PS C:\Data> Get-NTFSSecurityDescriptor The `Move-Item2` cmdlet moves the items in `-Path` to the location in `-Destination`. It is the long-path counterpart of the built-in `Move-Item` cmdlet: it works through the AlphaFS library (`Alphaleonis.Win32.Filesystem`), so source and destination may be longer than the 260-character `MAX_PATH` limit. Files and folders can both be moved, and a folder is moved with everything it contains. How `-Destination` is interpreted depends on what is already there. If the value names an existing folder, the cmdlet keeps the name of the source item and moves it into that folder. In every other case the value is the full path of the new item, which lets you move and rename in one step, or rename an item in place. `-Destination` is resolved against the current location once, when the cmdlet starts. - Without `-Force`, the cmdlet checks whether the destination file already exists and writes a `DestinationFileAlreadyExists` error instead of overwriting it; the move itself then runs with the `CopyAllowed` option, which allows a file to move to a different volume. With `-WhatIf`, the cmdlet names an existing destination file in a verbose message instead; before 5.0.0, it wrote the error also with `-WhatIf`. With `-Force`, the move runs with the `ReplaceExisting` option and overwrites an existing destination item. + Without `-Force`, the cmdlet checks whether a file or folder already exists at the destination and writes a `DestinationFileAlreadyExists` error instead of overwriting it; the move itself then runs with the `CopyAllowed` option, which allows a file to move to a different volume. With `-WhatIf`, the cmdlet names an existing destination in a verbose message instead; before 5.0.0, it wrote the error also with `-WhatIf`. With `-Force`, the move runs with the `ReplaceExisting` option and overwrites an existing destination item. The folder that is to contain the moved item must exist; otherwise the cmdlet writes an error that names that folder. The cmdlet supports `-WhatIf` and `-Confirm`, and it writes nothing to the pipeline unless you specify `-PassThru $true`. @@ -6828,7 +6828,7 @@ PS C:\Data> Get-NTFSSecurityDescriptor Force - Indicates that the cmdlet replaces an existing destination item. Without `-Force`, an existing destination file causes the error `DestinationFileAlreadyExists` and the item is not moved. + Indicates that the cmdlet replaces an existing destination item. Without `-Force`, an existing file or folder at the destination causes the error `DestinationFileAlreadyExists` and the item is not moved. SwitchParameter @@ -6889,7 +6889,7 @@ PS C:\Data> Get-NTFSSecurityDescriptor Force - Indicates that the cmdlet replaces an existing destination item. Without `-Force`, an existing destination file causes the error `DestinationFileAlreadyExists` and the item is not moved. + Indicates that the cmdlet replaces an existing destination item. Without `-Force`, an existing file or folder at the destination causes the error `DestinationFileAlreadyExists` and the item is not moved. SwitchParameter @@ -6975,8 +6975,8 @@ PS C:\Data> Get-NTFSSecurityDescriptor `Move-Item2` moves through the AlphaFS library (`Alphaleonis.Win32.Filesystem`), which is why it handles source and destination paths that exceed the 260-character `MAX_PATH` limit of the built-in `Move-Item` cmdlet. Before 5.0.0, `-PassThru` also wrote the item when `-WhatIf` or a declined confirmation skipped the operation. - The cmdlet chooses between two mutually exclusive move options. Without `-Force` it moves with `CopyAllowed`, which permits a file to cross volume boundaries because Windows then copies and deletes it. With `-Force` it moves with `ReplaceExisting`, which overwrites the destination but does not request `CopyAllowed`, so a move across volumes can fail when `-Force` is specified. - If a path in `-Path` does not exist or the destination file exists and `-Force` is missing, the cmdlet writes a non-terminating error and continues with the next path. Before 5.0.0, it skipped the remaining paths that were passed in the same call. + The cmdlet chooses between two mutually exclusive move options. Without `-Force` it moves with `CopyAllowed`, which permits a file to cross volume boundaries because Windows then copies and deletes it. With `-Force` it moves with `ReplaceExisting`, which overwrites the destination but does not request `CopyAllowed`, so a move across volumes can fail when `-Force` is specified. A folder can't move to another volume: the cmdlet writes a `MoveError` and leaves the folder in place, so copy it with `Copy-Item2` and remove it with `Remove-Item2` instead. + If a path in `-Path` does not exist, a file or folder exists at the destination and `-Force` is missing, or the folder that is to contain the moved item does not exist, the cmdlet writes a non-terminating error and continues with the next path. Before 5.0.0, it skipped the remaining paths that were passed in the same call. Before 5.0.0, it also reported an existing destination folder as a `MoveError`, and a missing destination folder as a `DirectoryNotFoundException` that named the source item ( #21 (https://github.com/raandree/NTFSSecurity/issues/21)). diff --git a/Tests/ItemCmdlets.Tests.ps1 b/Tests/ItemCmdlets.Tests.ps1 index ae29a04..835badb 100644 --- a/Tests/ItemCmdlets.Tests.ps1 +++ b/Tests/ItemCmdlets.Tests.ps1 @@ -185,6 +185,89 @@ Describe 'Copy-Item2, Move-Item2, and Remove-Item2 with several paths' { $result.FullName | Should -Be (Join-Path -Path $destination -ChildPath 'First.txt') } + + It 'Move-Item2 -PassThru should return a folder at its new location' { + $sourceFolder = Join-Path -Path $folder -ChildPath 'MovedFolder' + Assert-TestSandboxPath -Sandbox $sandbox -Path $sourceFolder + New-Item -ItemType Directory -Path $sourceFolder | Out-Null + + $result = Move-Item2 -Path $sourceFolder -Destination $destination -PassThru $true + + $result | Should -BeOfType [Alphaleonis.Win32.Filesystem.DirectoryInfo] + $result.FullName | Should -Be (Join-Path -Path $destination -ChildPath 'MovedFolder') + $sourceFolder | Should -Not -Exist + } + + # Before 5.0.0-rc6, the check for an existing destination looked for a file only. For a folder whose name existed + # in the destination, the cmdlets failed in the middle with a CopyError or a MoveError, and Copy-Item2 could copy + # a part of the folder. + It '<_> should write DestinationFileAlreadyExists for a folder that exists at the destination and change nothing' -ForEach @('Copy-Item2', 'Move-Item2') { + $sourceFolder = Join-Path -Path $folder -ChildPath 'Conflict' + $existingFolder = Join-Path -Path $destination -ChildPath 'Conflict' + Assert-TestSandboxPath -Sandbox $sandbox -Path $sourceFolder, $existingFolder + New-Item -ItemType Directory -Path $sourceFolder, $existingFolder | Out-Null + Set-Content -LiteralPath (Join-Path -Path $sourceFolder -ChildPath 'A.txt') -Value 'New' + Set-Content -LiteralPath (Join-Path -Path $sourceFolder -ChildPath 'B.txt') -Value 'New' + Set-Content -LiteralPath (Join-Path -Path $existingFolder -ChildPath 'A.txt') -Value 'Existing' + + & $_ -Path $sourceFolder -Destination $destination -ErrorVariable itemErrors -ErrorAction SilentlyContinue + + $itemErrors | Should -HaveCount 1 + $itemErrors[0].FullyQualifiedErrorId | Should -BeLike 'DestinationFileAlreadyExists,*' + $itemErrors[0].TargetObject | Should -Be $existingFolder + Get-Content -LiteralPath (Join-Path -Path $existingFolder -ChildPath 'A.txt') | Should -Be 'Existing' + Join-Path -Path $existingFolder -ChildPath 'B.txt' | Should -Not -Exist + Join-Path -Path $sourceFolder -ChildPath 'A.txt' | Should -Exist + } + + 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' + Assert-TestSandboxPath -Sandbox $sandbox -Path $sourceFolder, $existingFolder + New-Item -ItemType Directory -Path $sourceFolder, $existingFolder | Out-Null + Set-Content -LiteralPath (Join-Path -Path $sourceFolder -ChildPath 'A.txt') -Value 'New' + Set-Content -LiteralPath (Join-Path -Path $sourceFolder -ChildPath 'B.txt') -Value 'New' + Set-Content -LiteralPath (Join-Path -Path $existingFolder -ChildPath 'A.txt') -Value 'Existing' + Set-Content -LiteralPath (Join-Path -Path $existingFolder -ChildPath 'C.txt') -Value 'Existing' + + Copy-Item2 -Path $sourceFolder -Destination $destination -Force -ErrorVariable itemErrors -ErrorAction SilentlyContinue + + $itemErrors | Should -BeNullOrEmpty + Get-Content -LiteralPath (Join-Path -Path $existingFolder -ChildPath 'A.txt') | Should -Be 'New' + Get-Content -LiteralPath (Join-Path -Path $existingFolder -ChildPath 'B.txt') | Should -Be 'New' + Get-Content -LiteralPath (Join-Path -Path $existingFolder -ChildPath 'C.txt') | Should -Be 'Existing' + } + + # Before 5.0.0-rc6, the error named the source item as the path that wasn't found, also when the folder of the + # destination was missing (#21). + It ' should name the missing folder of the destination for a and change nothing' -ForEach @( + @{ Command = 'Copy-Item2'; Kind = 'file'; ErrorId = 'CopyError' } + @{ Command = 'Copy-Item2'; Kind = 'folder'; ErrorId = 'CopyError' } + @{ Command = 'Move-Item2'; Kind = 'file'; ErrorId = 'MoveError' } + @{ Command = 'Move-Item2'; Kind = 'folder'; ErrorId = 'MoveError' } + ) { + $source = $first + if ($Kind -eq 'folder') { + $source = Join-Path -Path $folder -ChildPath 'SourceFolder' + Assert-TestSandboxPath -Sandbox $sandbox -Path $source + New-Item -ItemType Directory -Path $source | Out-Null + Set-Content -LiteralPath (Join-Path -Path $source -ChildPath 'Inner.txt') -Value 'Inner' + } + + $missingFolder = Join-Path -Path $folder -ChildPath 'MissingFolder' + $target = Join-Path -Path $missingFolder -ChildPath 'Item' + Assert-TestSandboxPath -Sandbox $sandbox -Path $missingFolder, $target + + & $Command -Path $source -Destination $target -ErrorVariable itemErrors -ErrorAction SilentlyContinue + + $itemErrors | Should -HaveCount 1 + $itemErrors[0].FullyQualifiedErrorId | Should -BeLike "$ErrorId,*" + $itemErrors[0].Exception.Message | Should -BeLike "*'$missingFolder'*" + $itemErrors[0].TargetObject | Should -Be $target + $source | Should -Exist + $missingFolder | Should -Not -Exist + } + # Before 5.0.0, -PassThru wrote the item also when -WhatIf skipped the operation. It '<_> should write nothing with -PassThru and -WhatIf' -ForEach @('Copy-Item2', 'Move-Item2', 'Remove-Item2') { $parameters = @{ Path = $first; PassThru = $true; WhatIf = $true } From a324484ef04f36f7b44055e2ab53be9ed9acdf8a Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Thu, 8 Oct 2026 09:57:18 +0000 Subject: [PATCH 14/33] fix: write non-terminating errors from the hard link cmdlets on shares and for folders Windows can't list the names of a file on a network share and answers with (50) The request is not supported. The new live tests found that Get-NTFSHardLink then stopped with a terminating error, so that it skipped the remaining paths, and that New-NTFSHardLink -PassThru did so after it had created the link. A folder stopped Get-NTFSHardLink the same way. Both cmdlets now write a non-terminating GetHardLinkError and go on. The tests reach the sandbox over the administrative share of its drive, which behaves like the share of a file server, and skip where that share isn't available, such as for a basic user. The pages describe the limit on shares. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- CHANGELOG.md | 5 ++ Docs/Cmdlets/Get-NTFSHardLink.md | 4 +- Docs/Cmdlets/New-NTFSHardLink.md | 2 + NTFSSecurity/LinkCmdlets/GetHardLink.cs | 16 +++++- NTFSSecurity/LinkCmdlets/NewHardLink.cs | 15 +++++- NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml | 4 +- Tests/Links.Tests.ps1 | 55 ++++++++++++++++++++ 7 files changed, 97 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53bc6d7..c820fc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -295,5 +295,10 @@ The format is based on write `DestinationFileAlreadyExists` and an error that names the missing folder. `Copy-Item2` no longer creates the missing folders of the destination when it copies a folder, which the prereleases of 5.0.0 did +- Fix `Get-NTFSHardLink`, which stopped for all remaining paths at a folder + and at a file on a network share, where Windows can't list the names of + a file, and `New-NTFSHardLink -PassThru`, which stopped with a + terminating error on a share after it had created the link; both now + write a non-terminating `GetHardLinkError` [Unreleased]: https://github.com/raandree/NTFSSecurity/compare/4.2.6...HEAD diff --git a/Docs/Cmdlets/Get-NTFSHardLink.md b/Docs/Cmdlets/Get-NTFSHardLink.md index 5febacc..b1aefef 100644 --- a/Docs/Cmdlets/Get-NTFSHardLink.md +++ b/Docs/Cmdlets/Get-NTFSHardLink.md @@ -23,7 +23,7 @@ On an NTFS volume, a file is a block of data that one or more directory entries, A file that has only one name returns a single object. A file that has additional hard links returns one object per name, which lets you find all the places on the volume from which the same data is reachable. The file system reports the links relative to the root of the volume, and the cmdlet combines them with the root of the path you specify, so the result contains full paths. All hard links of a file are always on the same volume as the file. -`-Path` must point to a file. A folder causes an error, because NTFS does not support hard links to folders. If you omit `-Path`, the cmdlet falls back to the current location, which is a folder and therefore produces the same error, so always pass the path of a file. +`-Path` must point to a file. A folder causes a non-terminating `GetHardLinkError`, because NTFS does not support hard links to folders, and the cmdlet continues with the next path. If you omit `-Path`, the cmdlet falls back to the current location, which is a folder and therefore produces the same error, so always pass the path of a file. The parameter accepts an array of paths and takes pipeline input by value and by property name through its `FullName` alias. `Get-ChildItem2` adds a `HardLinkCount` property to each file as long as the `IdentifyHardLinks` entry in the `PrivateData` section of the module manifest is `$true`, which lets you select the files that have more than one name before you resolve them. @@ -102,6 +102,8 @@ The cmdlet never writes folder objects, because it rejects folders with the erro Hard links exist only within a single NTFS volume. Every object that this cmdlet returns therefore refers to a path on the volume of the file that you passed in. +Windows can't list the names of a file on a network share, also when the share lies on an NTFS volume of the file server. For such a file the cmdlet writes a non-terminating `GetHardLinkError` with the message "The request is not supported" and continues with the next path; run the cmdlet on the file server itself instead. Before 5.0.0, a file on a network share and a folder stopped the cmdlet with a terminating error, so that it skipped the remaining paths. + Because all hard links of a file share the same data, they also share the file content, the file size, and the time stamps. The security descriptor is stored with the file as well, so changing permissions through one name changes them for every name. The cmdlet resolves paths through the AlphaFS library and therefore also works with paths that exceed the 260-character `MAX_PATH` limit. diff --git a/Docs/Cmdlets/New-NTFSHardLink.md b/Docs/Cmdlets/New-NTFSHardLink.md index 184f5a7..3bcf7df 100644 --- a/Docs/Cmdlets/New-NTFSHardLink.md +++ b/Docs/Cmdlets/New-NTFSHardLink.md @@ -134,6 +134,8 @@ The cmdlet never writes folder objects, because hard links are supported for fil Windows supports hard links only for files on the same NTFS volume. A link that points to a file on another volume, or a target on a file system that does not implement hard links, cannot be created. +The cmdlet creates hard links on a network share as well, but Windows can't list the names of a file there. With `-PassThru` on a share, the cmdlet creates the link and writes a non-terminating `GetHardLinkError` with the message "The request is not supported" instead of the objects. Before 5.0.0, it stopped with a terminating error after it had created the link. + The cmdlet does not overwrite anything. If `-Path` already exists, or if `-Target` is missing or is a folder, the cmdlet reports an error and leaves the file system unchanged. Because all names of a file share the same data, the number of hard links is a property of the file, not of an individual name. Use `Get-NTFSHardLink` to list them, and delete a link with `Remove-Item2` or `Remove-Item`, which removes only that name as long as other names remain. diff --git a/NTFSSecurity/LinkCmdlets/GetHardLink.cs b/NTFSSecurity/LinkCmdlets/GetHardLink.cs index 7f96b1a..3804e7b 100644 --- a/NTFSSecurity/LinkCmdlets/GetHardLink.cs +++ b/NTFSSecurity/LinkCmdlets/GetHardLink.cs @@ -48,8 +48,12 @@ namespace NTFSSecurity //access the path to make sure it exists and is a file var item = GetFileSystemInfo2(path); + // An error for this path only; before 5.0.0-rc6, a folder stopped the cmdlet for all paths. if (item is DirectoryInfo) - throw new ArgumentException("The item must be a file"); + { + WriteError(new ErrorRecord(new ArgumentException("The item must be a file"), "GetHardLinkError", ErrorCategory.InvalidArgument, path)); + continue; + } var links = File.EnumerateHardlinks(item.FullName); @@ -64,6 +68,16 @@ namespace NTFSSecurity { WriteError(new ErrorRecord(ex, "FileNotFound", ErrorCategory.ObjectNotFound, path)); } + // Windows can't list the names of a file on a network share: (50) The request is not supported. + // Before 5.0.0-rc6, this stopped the cmdlet for all paths. + catch (System.IO.IOException ex) + { + WriteError(new ErrorRecord(ex, "GetHardLinkError", ErrorCategory.ReadError, path)); + } + catch (UnauthorizedAccessException ex) + { + WriteError(new ErrorRecord(ex, "GetHardLinkError", ErrorCategory.PermissionDenied, path)); + } } } diff --git a/NTFSSecurity/LinkCmdlets/NewHardLink.cs b/NTFSSecurity/LinkCmdlets/NewHardLink.cs index 3ce89c6..93ad344 100644 --- a/NTFSSecurity/LinkCmdlets/NewHardLink.cs +++ b/NTFSSecurity/LinkCmdlets/NewHardLink.cs @@ -1,5 +1,7 @@ using Alphaleonis.Win32.Filesystem; using System; +using System.Collections.Generic; +using System.Linq; using System.Management.Automation; namespace NTFSSecurity @@ -74,7 +76,18 @@ namespace NTFSSecurity if (passThru) { - var links = File.EnumerateHardlinks(path); + IEnumerable links; + try + { + links = File.EnumerateHardlinks(path).ToList(); + } + // Windows can't list the names of a file on a network share: (50) The request is not supported. + // The link exists; before 5.0.0-rc6, this stopped the cmdlet with a terminating error. + catch (System.IO.IOException ex) + { + WriteError(new ErrorRecord(ex, "GetHardLinkError", ErrorCategory.ReadError, path)); + return; + } foreach (var link in links) { diff --git a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml index 5276fae..85576db 100644 --- a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml +++ b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml @@ -5224,7 +5224,7 @@ PS C:\> Get-NTFSAudit -SecurityDescriptor $sd On an NTFS volume, a file is a block of data that one or more directory entries, called hard links, refer to. The `Get-NTFSHardLink` cmdlet asks the file system for every hard link of the file that `-Path` points to and writes a file object for each of them, including the name that you passed in. A file that has only one name returns a single object. A file that has additional hard links returns one object per name, which lets you find all the places on the volume from which the same data is reachable. The file system reports the links relative to the root of the volume, and the cmdlet combines them with the root of the path you specify, so the result contains full paths. All hard links of a file are always on the same volume as the file. - `-Path` must point to a file. A folder causes an error, because NTFS does not support hard links to folders. If you omit `-Path`, the cmdlet falls back to the current location, which is a folder and therefore produces the same error, so always pass the path of a file. + `-Path` must point to a file. A folder causes a non-terminating `GetHardLinkError`, because NTFS does not support hard links to folders, and the cmdlet continues with the next path. If you omit `-Path`, the cmdlet falls back to the current location, which is a folder and therefore produces the same error, so always pass the path of a file. The parameter accepts an array of paths and takes pipeline input by value and by property name through its `FullName` alias. `Get-ChildItem2` adds a `HardLinkCount` property to each file as long as the `IdentifyHardLinks` entry in the `PrivateData` section of the module manifest is `$true`, which lets you select the files that have more than one name before you resolve them. @@ -5289,6 +5289,7 @@ PS C:\> Get-NTFSAudit -SecurityDescriptor $sd Hard links exist only within a single NTFS volume. Every object that this cmdlet returns therefore refers to a path on the volume of the file that you passed in. + Windows can't list the names of a file on a network share, also when the share lies on an NTFS volume of the file server. For such a file the cmdlet writes a non-terminating `GetHardLinkError` with the message "The request is not supported" and continues with the next path; run the cmdlet on the file server itself instead. Before 5.0.0, a file on a network share and a folder stopped the cmdlet with a terminating error, so that it skipped the remaining paths. Because all hard links of a file share the same data, they also share the file content, the file size, and the time stamps. The security descriptor is stored with the file as well, so changing permissions through one name changes them for every name. The cmdlet resolves paths through the AlphaFS library and therefore also works with paths that exceed the 260-character `MAX_PATH` limit. @@ -7160,6 +7161,7 @@ PS C:\Data> Get-NTFSSecurityDescriptor Windows supports hard links only for files on the same NTFS volume. A link that points to a file on another volume, or a target on a file system that does not implement hard links, cannot be created. + The cmdlet creates hard links on a network share as well, but Windows can't list the names of a file there. With `-PassThru` on a share, the cmdlet creates the link and writes a non-terminating `GetHardLinkError` with the message "The request is not supported" instead of the objects. Before 5.0.0, it stopped with a terminating error after it had created the link. The cmdlet does not overwrite anything. If `-Path` already exists, or if `-Target` is missing or is a folder, the cmdlet reports an error and leaves the file system unchanged. Because all names of a file share the same data, the number of hard links is a property of the file, not of an individual name. Use `Get-NTFSHardLink` to list them, and delete a link with `Remove-Item2` or `Remove-Item`, which removes only that name as long as other names remain. Before 5.0.0, the error for a missing `-Target` said "The target path exist", the opposite of the cause. diff --git a/Tests/Links.Tests.ps1 b/Tests/Links.Tests.ps1 index 8d69a92..43f1d0b 100644 --- a/Tests/Links.Tests.ps1 +++ b/Tests/Links.Tests.ps1 @@ -9,6 +9,10 @@ param () BeforeDiscovery { Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force $canCreateSymbolicLinks = Test-PrivilegeHeld -Name 'SeCreateSymbolicLinkPrivilege' + # The administrative share of the drive of the sandboxes reaches them over SMB, like a share of a file server. + $tempPath = [IO.Path]::GetTempPath() + $canUseAdminShare = (Test-IsElevated) -and + (Test-Path -LiteralPath ('\\localhost\{0}$\' -f $tempPath.Substring(0, 1)) -ErrorAction SilentlyContinue) } BeforeAll { @@ -17,6 +21,13 @@ BeforeAll { Import-Module -Name $modulePath -Force -ErrorAction Stop $sandbox = New-TestSandbox -Name 'Links' Push-Location -LiteralPath $sandbox + + function ConvertTo-AdminSharePath { + # The path of a sandbox item on the administrative share of its drive, such as \\localhost\C$\... + param ([string] $Path) + + '\\localhost\{0}${1}' -f $Path.Substring(0, 1), $Path.Substring(2) + } } AfterAll { @@ -110,6 +121,21 @@ Describe 'New-NTFSHardLink' { $link | Should -Not -Exist } + + # Windows can't list the names of a file on a network share. Before 5.0.0-rc6, the cmdlet stopped with a + # terminating error after it had created the link. + It 'Should create the link on a network share and write an error for -PassThru, which cannot list the names there' -Skip:(-not $canUseAdminShare) { + $target = New-TestSandboxItem -Sandbox $sandbox -Name 'ShareTarget' + $link = Join-Path -Path $sandbox -ChildPath 'ShareLink.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $link + + $result = @(New-NTFSHardLink -Path (ConvertTo-AdminSharePath -Path $link) -Target (ConvertTo-AdminSharePath -Path $target) -PassThru -ErrorVariable linkErrors -ErrorAction SilentlyContinue) + + $link | Should -Exist + $linkErrors | Should -HaveCount 1 + $linkErrors[0].FullyQualifiedErrorId | Should -BeLike 'GetHardLinkError,*' + $result | Should -BeNullOrEmpty + } } Describe 'Get-NTFSHardLink' { @@ -159,6 +185,35 @@ Describe 'Get-NTFSHardLink' { $linkErrors[0].FullyQualifiedErrorId | Should -BeLike 'FileNotFound,*' $result.FullName | Should -Be $file } + + # Before 5.0.0-rc6, a folder stopped the cmdlet with a terminating error, so that it skipped the remaining paths. + It 'Should write an error for a folder and continue with the next path' { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'HardLinkFolder' -Directory + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'AfterFolder' + + $result = @(Get-NTFSHardLink -Path $folder, $file -ErrorVariable linkErrors -ErrorAction SilentlyContinue) + + $linkErrors | Should -HaveCount 1 + $linkErrors[0].FullyQualifiedErrorId | Should -BeLike 'GetHardLinkError,*' + $linkErrors[0].TargetObject | Should -Be $folder + $linkErrors[0].Exception.Message | Should -Be 'The item must be a file' + $result.FullName | Should -Be $file + } + + # Windows can't list the names of a file on a network share. Before 5.0.0-rc6, the cmdlet stopped with a + # terminating error, so that it skipped the remaining paths. + It 'Should write an error for a file on a network share and continue with the next path' -Skip:(-not $canUseAdminShare) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'ShareFile' + $other = New-TestSandboxItem -Sandbox $sandbox -Name 'AfterShare' + $sharePath = ConvertTo-AdminSharePath -Path $file + + $result = @(Get-NTFSHardLink -Path $sharePath, $other -ErrorVariable linkErrors -ErrorAction SilentlyContinue) + + $linkErrors | Should -HaveCount 1 + $linkErrors[0].FullyQualifiedErrorId | Should -BeLike 'GetHardLinkError,*' + $linkErrors[0].TargetObject | Should -Be $sharePath + $result.FullName | Should -Be $other + } } Describe 'New-NTFSSymbolicLink' { From 75b00991904d162e8b049788cae9976c64f30231 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Thu, 8 Oct 2026 10:02:47 +0000 Subject: [PATCH 15/33] fix: read the item for Set-NTFSSecurityDescriptor -PassThru outside the write retry The cmdlet read the item again for -PassThru inside the try block that retries a denied write as the owner (R5 of the review of #113). A read that was denied after a successful write therefore started another attempt of the write and ended in a WriteSdError, and a write that needed the ownership retry wrote no object at all. The read now follows the write and its retry, and a failed read is a ReadSecurityError. The page also says what happens when setting the previous owner back fails. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- CHANGELOG.md | 4 ++ Docs/Cmdlets/Set-NTFSSecurityDescriptor.md | 4 +- .../SetSecurityDescriptor.cs | 20 ++++++--- NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml | 4 +- Tests/SecurityDescriptor.Tests.ps1 | 44 +++++++++++++++++++ 5 files changed, 67 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c820fc4..89733d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -300,5 +300,9 @@ The format is based on a file, and `New-NTFSHardLink -PassThru`, which stopped with a terminating error on a share after it had created the link; both now write a non-terminating `GetHardLinkError` +- Fix `-PassThru` of `Set-NTFSSecurityDescriptor`, which returned nothing + for a descriptor that the cmdlet wrote as the owner, and which turned a + failed read after a successful write into another attempt of the write + and a `WriteSdError`; it now writes a `ReadSecurityError` for that read [Unreleased]: https://github.com/raandree/NTFSSecurity/compare/4.2.6...HEAD diff --git a/Docs/Cmdlets/Set-NTFSSecurityDescriptor.md b/Docs/Cmdlets/Set-NTFSSecurityDescriptor.md index 17f583a..1251fb2 100644 --- a/Docs/Cmdlets/Set-NTFSSecurityDescriptor.md +++ b/Docs/Cmdlets/Set-NTFSSecurityDescriptor.md @@ -23,9 +23,9 @@ The `Set-NTFSSecurityDescriptor` cmdlet writes a `Security2.FileSystemSecurity2` Each descriptor remembers the item it was read from, and the cmdlet writes it back to exactly that item. There is no parameter that redirects the write to a different path. The cmdlet writes only the sections of the descriptor that changed since it was read or last written, such as the DACL after `Add-NTFSAccess`, and leaves the other sections of the item as they are, so a descriptor that you did not change writes nothing. With `-Verbose`, the cmdlet names the sections that it writes, or says that it writes nothing. Before 5.0.0, the cmdlet wrote every section that it had read, also an unchanged owner, which failed with error 1307, "This security ID may not be assigned as the owner of this object", when the account may not assign that owner, such as on some file servers. -The cmdlet produces no output unless you use `-PassThru`, which reads the item again after the write and returns a new `FileSystemSecurity2` object that reflects what is now stored on disk. Descriptors can be passed as an array or through the pipeline, and each one is processed on its own. +The cmdlet produces no output unless you use `-PassThru`, which reads the item again after the write and returns a new `FileSystemSecurity2` object that reflects what is now stored on disk. If that read fails, for example because the written descriptor denies the account the right to read it, the cmdlet writes a non-terminating `ReadSecurityError`; the descriptor is written all the same. Descriptors can be passed as an array or through the pipeline, and each one is processed on its own. -When the write fails because access is denied, the cmdlet takes ownership of the item with the account of the current session, writes the descriptor, and restores the previous owner, also when that write fails. A descriptor that sets a new owner keeps it; before 5.0.0, the cmdlet set the previous owner back over it. If the write fails as well, the cmdlet writes a non-terminating error and continues with the next descriptor. Windows checks each section separately: changing the access control list requires the Change Permissions right on the item, changing the owner requires the Take Ownership right or the Take Ownership privilege, assigning ownership to another account requires the Restore privilege, and writing audit entries requires the Security privilege. +When the write fails because access is denied, the cmdlet takes ownership of the item with the account of the current session, writes the descriptor, and restores the previous owner, also when that write fails. A descriptor that sets a new owner keeps it; before 5.0.0, the cmdlet set the previous owner back over it. If setting the previous owner back fails, the cmdlet writes a non-terminating `RestoreOwnerError`, and the account of the session stays the owner of the item. If the write fails as well, the cmdlet writes a non-terminating error and continues with the next descriptor. Windows checks each section separately: changing the access control list requires the Change Permissions right on the item, changing the owner requires the Take Ownership right or the Take Ownership privilege, assigning ownership to another account requires the Restore privilege, and writing audit entries requires the Security privilege. Before 5.0.0, `-PassThru` returned nothing for a descriptor that the cmdlet wrote as the owner, and a failed read for `-PassThru` started another attempt of the write and ended in a `WriteSdError`, although the write had succeeded. ## EXAMPLES diff --git a/NTFSSecurity/SecurityDescriptorCmdlets/SetSecurityDescriptor.cs b/NTFSSecurity/SecurityDescriptorCmdlets/SetSecurityDescriptor.cs index a7e1d53..9b6f299 100644 --- a/NTFSSecurity/SecurityDescriptorCmdlets/SetSecurityDescriptor.cs +++ b/NTFSSecurity/SecurityDescriptorCmdlets/SetSecurityDescriptor.cs @@ -52,11 +52,6 @@ namespace NTFSSecurity } sd.WriteChanges(); - - if (passThru) - { - WriteObject(new FileSystemSecurity2(sd.Item)); - } } catch (UnauthorizedAccessException) { @@ -73,6 +68,21 @@ namespace NTFSSecurity catch (Exception ex) { WriteError(new ErrorRecord(ex, "WriteSdError", ErrorCategory.WriteError, sd.Item)); + continue; + } + + // After the write and outside its retry: before 5.0.0-rc6, a write that needed ownership wrote no + // object, and a denied read started a retry of the write and ended in a WriteSdError. + if (passThru) + { + try + { + WriteObject(new FileSystemSecurity2(sd.Item)); + } + catch (Exception ex) + { + WriteError(new ErrorRecord(ex, "ReadSecurityError", ErrorCategory.ReadError, sd.Item)); + } } } } diff --git a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml index 85576db..9f406d2 100644 --- a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml +++ b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml @@ -9886,8 +9886,8 @@ PS C:\> Set-NTFSSecurityDescriptor -SecurityDescriptor $sd The `Set-NTFSSecurityDescriptor` cmdlet writes a `Security2.FileSystemSecurity2` object to the file system. It is the final step of the security descriptor workflow: `Get-NTFSSecurityDescriptor` reads a descriptor into memory, cmdlets such as `Add-NTFSAccess`, `Remove-NTFSAccess`, `Set-NTFSOwner`, and `Disable-NTFSAccessInheritance` change that copy through their `-SecurityDescriptor` parameter, and this cmdlet applies all of those changes in a single write. Each descriptor remembers the item it was read from, and the cmdlet writes it back to exactly that item. There is no parameter that redirects the write to a different path. The cmdlet writes only the sections of the descriptor that changed since it was read or last written, such as the DACL after `Add-NTFSAccess`, and leaves the other sections of the item as they are, so a descriptor that you did not change writes nothing. With `-Verbose`, the cmdlet names the sections that it writes, or says that it writes nothing. Before 5.0.0, the cmdlet wrote every section that it had read, also an unchanged owner, which failed with error 1307, "This security ID may not be assigned as the owner of this object", when the account may not assign that owner, such as on some file servers. - The cmdlet produces no output unless you use `-PassThru`, which reads the item again after the write and returns a new `FileSystemSecurity2` object that reflects what is now stored on disk. Descriptors can be passed as an array or through the pipeline, and each one is processed on its own. - When the write fails because access is denied, the cmdlet takes ownership of the item with the account of the current session, writes the descriptor, and restores the previous owner, also when that write fails. A descriptor that sets a new owner keeps it; before 5.0.0, the cmdlet set the previous owner back over it. If the write fails as well, the cmdlet writes a non-terminating error and continues with the next descriptor. Windows checks each section separately: changing the access control list requires the Change Permissions right on the item, changing the owner requires the Take Ownership right or the Take Ownership privilege, assigning ownership to another account requires the Restore privilege, and writing audit entries requires the Security privilege. + The cmdlet produces no output unless you use `-PassThru`, which reads the item again after the write and returns a new `FileSystemSecurity2` object that reflects what is now stored on disk. If that read fails, for example because the written descriptor denies the account the right to read it, the cmdlet writes a non-terminating `ReadSecurityError`; the descriptor is written all the same. Descriptors can be passed as an array or through the pipeline, and each one is processed on its own. + When the write fails because access is denied, the cmdlet takes ownership of the item with the account of the current session, writes the descriptor, and restores the previous owner, also when that write fails. A descriptor that sets a new owner keeps it; before 5.0.0, the cmdlet set the previous owner back over it. If setting the previous owner back fails, the cmdlet writes a non-terminating `RestoreOwnerError`, and the account of the session stays the owner of the item. If the write fails as well, the cmdlet writes a non-terminating error and continues with the next descriptor. Windows checks each section separately: changing the access control list requires the Change Permissions right on the item, changing the owner requires the Take Ownership right or the Take Ownership privilege, assigning ownership to another account requires the Restore privilege, and writing audit entries requires the Security privilege. Before 5.0.0, `-PassThru` returned nothing for a descriptor that the cmdlet wrote as the owner, and a failed read for `-PassThru` started another attempt of the write and ended in a `WriteSdError`, although the write had succeeded. diff --git a/Tests/SecurityDescriptor.Tests.ps1 b/Tests/SecurityDescriptor.Tests.ps1 index 60152c9..e77adce 100644 --- a/Tests/SecurityDescriptor.Tests.ps1 +++ b/Tests/SecurityDescriptor.Tests.ps1 @@ -208,6 +208,50 @@ Describe 'Set-NTFSSecurityDescriptor' { $setErrors | Should -BeNullOrEmpty (Get-Acl -LiteralPath $file).GetOwner($sidType).Value | Should -Be 'S-1-5-32-544' } + + # Before 5.0.0-rc6, the cmdlet wrote no object with -PassThru when it had to take ownership for the write. + It 'Should return the written descriptor with -PassThru also when it took ownership for the write' -Skip:(-not $canAssignAnyOwner) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'RetryPassThru' + Add-TestDenyRule -Sandbox $sandbox -Path $file -Rights @{ $currentUser = 'ChangePermissions' } + Set-TestOwner -Sandbox $sandbox -Path $file -Sid $trustedInstaller + $sd = Get-NTFSSecurityDescriptor -Path $file + Add-NTFSAccess -SecurityDescriptor $sd -Account 'Everyone' -AccessRights ReadData + $sd.SecurityDescriptor.SetOwner((New-Object -TypeName 'System.Security.Principal.SecurityIdentifier' -ArgumentList 'S-1-5-32-544')) + + $result = @(Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -PassThru -ErrorVariable setErrors -ErrorAction SilentlyContinue) + + $setErrors | Should -BeNullOrEmpty + $result | Should -HaveCount 1 + $result[0].FullName | Should -Be $file + $result[0].SecurityDescriptor.GetOwner($sidType).Value | Should -Be 'S-1-5-32-544' + } + } + + Context 'When the written descriptor denies reading it again' { + BeforeAll { + $privateData['EnablePrivileges'] = $false + } + + AfterAll { + $privateData['EnablePrivileges'] = $enablePrivileges + } + + # Before 5.0.0-rc6, the cmdlet read the item again for -PassThru inside the block that retries a denied write, + # so that a denied read started an ownership retry and ended in a WriteSdError, although the write succeeded. + It 'Should write the descriptor and report a read error for -PassThru, not a write error' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'PassThruDenied' + $sd = Get-NTFSSecurityDescriptor -Path $file + # A deny entry for OWNER RIGHTS replaces the right of the owner to read the security descriptor. + Add-NTFSAccess -SecurityDescriptor $sd -Account 'S-1-3-4' -AccessRights ReadPermissions -AccessType Deny -AppliesTo ThisFolderOnly + + $result = @(Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -PassThru -ErrorVariable setErrors -ErrorAction SilentlyContinue) + + $result | Should -BeNullOrEmpty + $setErrors | Should -HaveCount 1 + $setErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadSecurityError,*' + # Get-Acl of an elevated Windows PowerShell still reads the item, so .NET checks that the entry was written. + { [System.IO.File]::GetAccessControl($file) } | Should -Throw + } } } From 25bce535dc01690dd94acc1e51f9697a4ae4e0a7 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Thu, 8 Oct 2026 10:11:25 +0000 Subject: [PATCH 16/33] test: check the written deny entry with .NET in both editions PowerShell 7 has no static File.GetAccessControl, because .NET Core made it an extension method, so the assertion of the -PassThru test passed there for the wrong reason: the method was missing. The test now reads with FileInfo.GetAccessControl or FileSystemAclExtensions and expects an UnauthorizedAccessException in both editions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- Tests/SecurityDescriptor.Tests.ps1 | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/Tests/SecurityDescriptor.Tests.ps1 b/Tests/SecurityDescriptor.Tests.ps1 index e77adce..3fa8050 100644 --- a/Tests/SecurityDescriptor.Tests.ps1 +++ b/Tests/SecurityDescriptor.Tests.ps1 @@ -249,8 +249,23 @@ Describe 'Set-NTFSSecurityDescriptor' { $result | Should -BeNullOrEmpty $setErrors | Should -HaveCount 1 $setErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadSecurityError,*' - # Get-Acl of an elevated Windows PowerShell still reads the item, so .NET checks that the entry was written. - { [System.IO.File]::GetAccessControl($file) } | Should -Throw + # Get-Acl of an elevated Windows PowerShell still reads the item, so .NET checks that the entry was written; + # .NET Core has the method as an extension method. + $info = New-Object -TypeName 'System.IO.FileInfo' -ArgumentList $file + $denied = $null + try { + if ($PSVersionTable.PSEdition -eq 'Desktop') { + $null = $info.GetAccessControl() + } + else { + $null = [System.IO.FileSystemAclExtensions]::GetAccessControl($info) + } + } + catch { + $denied = $_.Exception.GetBaseException() + } + + $denied | Should -BeOfType [System.UnauthorizedAccessException] } } } From db7387f17200b60bed0128e60ac55747c17cfe95 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Thu, 8 Oct 2026 10:11:25 +0000 Subject: [PATCH 17/33] fix: report a denied read of Get-NTFSOrphanedAccess as ReadSecurityError Add tests for the error handling that the cmdlets with -Path share: a path that doesn't exist (16 cmdlets, and 6 audit cmdlets with the Security privilege), an item whose owner may not read its permissions (6 cmdlets), and an item whose owner may not change them, which the 6 cmdlets that write the DACL handle by taking ownership. Each error belongs to its path only, and the cmdlet goes on with the next one. The tests found one defect: Get-NTFSOrphanedAccess reported an item that it couldn't read as an AddAceError with the category WriteError. It now writes a ReadSecurityError, like Get-NTFSAccess. They also show that the take-ownership retry works without privileges when the account holds the Take Ownership right and may assign the previous owner, and that it can't help a denied read, because reading the owner needs the same right. Concepts and five cmdlet pages said that the retry needs the privileges; they now describe both, and that Windows removes the OWNER RIGHTS entries when the owner changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- CHANGELOG.md | 3 + Docs/Cmdlets/Add-NTFSAccess.md | 2 +- Docs/Cmdlets/Clear-NTFSAccess.md | 2 +- Docs/Cmdlets/Get-NTFSAccess.md | 2 +- Docs/Cmdlets/Get-NTFSOrphanedAccess.md | 2 +- Docs/Cmdlets/Remove-NTFSAccess.md | 2 +- Docs/Concepts.md | 8 +- .../AccessCmdlets/GetOrphanedAccess.cs | 3 +- NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml | 10 +- Tests/PathErrors.Tests.ps1 | 232 ++++++++++++++++++ 10 files changed, 254 insertions(+), 12 deletions(-) create mode 100644 Tests/PathErrors.Tests.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 89733d2..33a14d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -304,5 +304,8 @@ The format is based on for a descriptor that the cmdlet wrote as the owner, and which turned a failed read after a successful write into another attempt of the write and a `WriteSdError`; it now writes a `ReadSecurityError` for that read +- Fix `Get-NTFSOrphanedAccess`, which reported an item that it couldn't + read as an `AddAceError`; it now writes a `ReadSecurityError`, like + `Get-NTFSAccess` [Unreleased]: https://github.com/raandree/NTFSSecurity/compare/4.2.6...HEAD diff --git a/Docs/Cmdlets/Add-NTFSAccess.md b/Docs/Cmdlets/Add-NTFSAccess.md index 8d79939..d30e4c6 100644 --- a/Docs/Cmdlets/Add-NTFSAccess.md +++ b/Docs/Cmdlets/Add-NTFSAccess.md @@ -288,7 +288,7 @@ With `-PassThru`, the cmdlet writes all access control entries, explicit and inh When the module setting `EnablePrivileges` is `$true` (the default in the `PrivateData` section of NTFSSecurity.psd1), this cmdlet tries to enable the Backup, Restore, Take Ownership, and Security privileges while it runs and disables the privileges it enabled when it finishes. These privileges are only available in an elevated session of an account that holds them, such as a member of the local Administrators group. If a privilege cannot be enabled, the cmdlet continues without it and writes a debug message. -If the ACL of an item cannot be written because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. Changing the owner of an item requires the Take Ownership and Restore privileges, so this fallback only succeeds in an elevated session of an account that holds them. +If the ACL of an item cannot be written because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. That fallback only succeeds when the account can take ownership of the item, through the Take Ownership right on the item or the Take Ownership privilege, and can set the previous owner back, which needs the Restore privilege unless that owner is the account itself or one of its groups. When the owner changes, Windows removes the entries for OWNER RIGHTS of the item. In the `Path` parameter sets, the cmdlet reads and writes only the DACL of the item and leaves its owner, its group, and its SACL as they are. Before 5.0.0, it also wrote the owner back, which failed with error 1307, "This security ID may not be assigned as the owner of this object", when the account may not assign that owner, such as on some file servers. In an elevated session, it could also store the inherited entries of the item as explicit entries. diff --git a/Docs/Cmdlets/Clear-NTFSAccess.md b/Docs/Cmdlets/Clear-NTFSAccess.md index 863c82c..075d73d 100644 --- a/Docs/Cmdlets/Clear-NTFSAccess.md +++ b/Docs/Cmdlets/Clear-NTFSAccess.md @@ -143,7 +143,7 @@ The cmdlet writes nothing. Use `Get-NTFSAccess` to inspect the result. When the module setting `EnablePrivileges` is `$true` (the default in the `PrivateData` section of NTFSSecurity.psd1), this cmdlet tries to enable the Backup, Restore, Take Ownership, and Security privileges while it runs and disables the privileges it enabled when it finishes. These privileges are only available in an elevated session of an account that holds them, such as a member of the local Administrators group. If a privilege cannot be enabled, the cmdlet continues without it and writes a debug message. -If the ACL of an item cannot be written because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. Changing the owner of an item requires the Take Ownership and Restore privileges, so this fallback only succeeds in an elevated session of an account that holds them. +If the ACL of an item cannot be written because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. That fallback only succeeds when the account can take ownership of the item, through the Take Ownership right on the item or the Take Ownership privilege, and can set the previous owner back, which needs the Restore privilege unless that owner is the account itself or one of its groups. When the owner changes, Windows removes the entries for OWNER RIGHTS of the item. In the `Path` parameter set, the cmdlet reads and writes only the DACL of the item and leaves its owner, its group, and its SACL as they are. Before 5.0.0, it also wrote the owner back, which failed with error 1307, "This security ID may not be assigned as the owner of this object", when the account may not assign that owner, such as on some file servers. diff --git a/Docs/Cmdlets/Get-NTFSAccess.md b/Docs/Cmdlets/Get-NTFSAccess.md index 5e407db..26ff37b 100644 --- a/Docs/Cmdlets/Get-NTFSAccess.md +++ b/Docs/Cmdlets/Get-NTFSAccess.md @@ -180,7 +180,7 @@ One object per access control entry, with the account, the rights, the access ty When the module setting `EnablePrivileges` is `$true` (the default in the `PrivateData` section of NTFSSecurity.psd1), this cmdlet tries to enable the Backup, Restore, Take Ownership, and Security privileges while it runs and disables the privileges it enabled when it finishes. These privileges are only available in an elevated session of an account that holds them, such as a member of the local Administrators group. If a privilege cannot be enabled, the cmdlet continues without it and writes a debug message. -If the ACL of an item cannot be read because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. Changing the owner of an item requires the Take Ownership and Restore privileges, so this fallback only succeeds in an elevated session of an account that holds them. +If the ACL of an item cannot be read because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. Reading the owner, which that fallback needs first, requires the same Read Permissions right as reading the ACL, so the cmdlet then writes a non-terminating `ReadSecurityError` and continues with the next item. Entries whose account cannot be translated into a name are returned with their SID. Use `Get-NTFSOrphanedAccess` to list only those entries. diff --git a/Docs/Cmdlets/Get-NTFSOrphanedAccess.md b/Docs/Cmdlets/Get-NTFSOrphanedAccess.md index 4207c87..aa3b8d8 100644 --- a/Docs/Cmdlets/Get-NTFSOrphanedAccess.md +++ b/Docs/Cmdlets/Get-NTFSOrphanedAccess.md @@ -172,7 +172,7 @@ One object per orphaned access control entry. The `Account` property holds the u When the module setting `EnablePrivileges` is `$true` (the default in the `PrivateData` section of NTFSSecurity.psd1), this cmdlet tries to enable the Backup, Restore, Take Ownership, and Security privileges while it runs and disables the privileges it enabled when it finishes. These privileges are only available in an elevated session of an account that holds them, such as a member of the local Administrators group. If a privilege cannot be enabled, the cmdlet continues without it and writes a debug message. -If the ACL of an item cannot be read because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. Changing the owner of an item requires the Take Ownership and Restore privileges, so this fallback only succeeds in an elevated session of an account that holds them. +If the ACL of an item cannot be read because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. Reading the owner, which that fallback needs first, requires the same Read Permissions right as reading the ACL, so the cmdlet then writes a non-terminating `ReadSecurityError` and continues with the next item. Before 5.0.0, it reported that error as an `AddAceError`. Before 5.0.0, the cmdlet ignored `-Account` and `-SecurityDescriptor`, and after a path whose ACL could not be read, it returned the orphaned entries of the previous item again. diff --git a/Docs/Cmdlets/Remove-NTFSAccess.md b/Docs/Cmdlets/Remove-NTFSAccess.md index c82dfab..86e8d5f 100644 --- a/Docs/Cmdlets/Remove-NTFSAccess.md +++ b/Docs/Cmdlets/Remove-NTFSAccess.md @@ -300,7 +300,7 @@ With `-PassThru`, the cmdlet writes all access control entries, explicit and inh When the module setting `EnablePrivileges` is `$true` (the default in the `PrivateData` section of NTFSSecurity.psd1), this cmdlet tries to enable the Backup, Restore, Take Ownership, and Security privileges while it runs and disables the privileges it enabled when it finishes. These privileges are only available in an elevated session of an account that holds them, such as a member of the local Administrators group. If a privilege cannot be enabled, the cmdlet continues without it and writes a debug message. -If the ACL of an item cannot be written because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. Changing the owner of an item requires the Take Ownership and Restore privileges, so this fallback only succeeds in an elevated session of an account that holds them. +If the ACL of an item cannot be written because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. That fallback only succeeds when the account can take ownership of the item, through the Take Ownership right on the item or the Take Ownership privilege, and can set the previous owner back, which needs the Restore privilege unless that owner is the account itself or one of its groups. When the owner changes, Windows removes the entries for OWNER RIGHTS of the item. Removing rights from an entry that does not exist is not an error; the cmdlet leaves the ACL unchanged. diff --git a/Docs/Concepts.md b/Docs/Concepts.md index 680c0c3..e63abbc 100644 --- a/Docs/Concepts.md +++ b/Docs/Concepts.md @@ -209,7 +209,13 @@ state. When reading or changing an item fails with an access-denied error, most of these cmdlets make the current user the owner of the item, retry, and then restore the previous owner, also when the retry fails. Before 5.0.0, a failed -retry left the current user as the owner. This requires the privileges above. +retry left the current user as the owner. Taking ownership needs the Take +Ownership right on the item or the Take Ownership privilege, and setting the +previous owner back needs the Restore privilege unless that owner is the user +or one of its groups. When the owner of an item changes, Windows removes its +entries for OWNER RIGHTS, so the retry removes such entries as well. For +reading, the retry doesn't help: the cmdlet must read the owner first, which +needs the same right as reading the permissions. Reading or changing audit entries always requires the Security privilege. Without it, the audit cmdlets fail, and `Get-NTFSEffectiveAccess` warns that diff --git a/NTFSSecurity/AccessCmdlets/GetOrphanedAccess.cs b/NTFSSecurity/AccessCmdlets/GetOrphanedAccess.cs index 94ad30c..14ecb9d 100644 --- a/NTFSSecurity/AccessCmdlets/GetOrphanedAccess.cs +++ b/NTFSSecurity/AccessCmdlets/GetOrphanedAccess.cs @@ -55,7 +55,8 @@ namespace NTFSSecurity } catch (Exception ex2) { - this.WriteError(new ErrorRecord(ex2, "AddAceError", ErrorCategory.WriteError, path)); + // A read error; before 5.0.0-rc6, it was reported as an AddAceError. + this.WriteError(new ErrorRecord(ex2, "ReadSecurityError", ErrorCategory.ReadError, path)); continue; } } diff --git a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml index 9f406d2..9caecdc 100644 --- a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml +++ b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml @@ -719,7 +719,7 @@ When the module setting `EnablePrivileges` is `$true` (the default in the `PrivateData` section of NTFSSecurity.psd1), this cmdlet tries to enable the Backup, Restore, Take Ownership, and Security privileges while it runs and disables the privileges it enabled when it finishes. These privileges are only available in an elevated session of an account that holds them, such as a member of the local Administrators group. If a privilege cannot be enabled, the cmdlet continues without it and writes a debug message. - If the ACL of an item cannot be written because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. Changing the owner of an item requires the Take Ownership and Restore privileges, so this fallback only succeeds in an elevated session of an account that holds them. + If the ACL of an item cannot be written because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. That fallback only succeeds when the account can take ownership of the item, through the Take Ownership right on the item or the Take Ownership privilege, and can set the previous owner back, which needs the Restore privilege unless that owner is the account itself or one of its groups. When the owner changes, Windows removes the entries for OWNER RIGHTS of the item. In the `Path` parameter sets, the cmdlet reads and writes only the DACL of the item and leaves its owner, its group, and its SACL as they are. Before 5.0.0, it also wrote the owner back, which failed with error 1307, "This security ID may not be assigned as the owner of this object", when the account may not assign that owner, such as on some file servers. In an elevated session, it could also store the inherited entries of the item as explicit entries. @@ -1711,7 +1711,7 @@ PS C:\> Set-NTFSSecurityDescriptor -SecurityDescriptor $sd When the module setting `EnablePrivileges` is `$true` (the default in the `PrivateData` section of NTFSSecurity.psd1), this cmdlet tries to enable the Backup, Restore, Take Ownership, and Security privileges while it runs and disables the privileges it enabled when it finishes. These privileges are only available in an elevated session of an account that holds them, such as a member of the local Administrators group. If a privilege cannot be enabled, the cmdlet continues without it and writes a debug message. - If the ACL of an item cannot be written because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. Changing the owner of an item requires the Take Ownership and Restore privileges, so this fallback only succeeds in an elevated session of an account that holds them. + If the ACL of an item cannot be written because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. That fallback only succeeds when the account can take ownership of the item, through the Take Ownership right on the item or the Take Ownership privilege, and can set the previous owner back, which needs the Restore privilege unless that owner is the account itself or one of its groups. When the owner changes, Windows removes the entries for OWNER RIGHTS of the item. In the `Path` parameter set, the cmdlet reads and writes only the DACL of the item and leaves its owner, its group, and its SACL as they are. Before 5.0.0, it also wrote the owner back, which failed with error 1307, "This security ID may not be assigned as the owner of this object", when the account may not assign that owner, such as on some file servers. @@ -4585,7 +4585,7 @@ PS C:\> Disable-Privileges When the module setting `EnablePrivileges` is `$true` (the default in the `PrivateData` section of NTFSSecurity.psd1), this cmdlet tries to enable the Backup, Restore, Take Ownership, and Security privileges while it runs and disables the privileges it enabled when it finishes. These privileges are only available in an elevated session of an account that holds them, such as a member of the local Administrators group. If a privilege cannot be enabled, the cmdlet continues without it and writes a debug message. - If the ACL of an item cannot be read because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. Changing the owner of an item requires the Take Ownership and Restore privileges, so this fallback only succeeds in an elevated session of an account that holds them. + If the ACL of an item cannot be read because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. Reading the owner, which that fallback needs first, requires the same Read Permissions right as reading the ACL, so the cmdlet then writes a non-terminating `ReadSecurityError` and continues with the next item. Entries whose account cannot be translated into a name are returned with their SID. Use `Get-NTFSOrphanedAccess` to list only those entries. Before 5.0.0, after a path whose ACL could not be read, the cmdlet returned the entries of the previous item again. For the root of a drive, such as `C:`, or of a volume, such as `\?\Volume{GUID}`, the cmdlets that read and change security use the root folder of the volume, like Explorer, `icacls`, and `Get-Acl`. Before 5.0.0, they read and changed the security descriptor of the drive itself, a device object with other entries. @@ -5741,7 +5741,7 @@ PS C:\> Get-NTFSAudit -SecurityDescriptor $sd When the module setting `EnablePrivileges` is `$true` (the default in the `PrivateData` section of NTFSSecurity.psd1), this cmdlet tries to enable the Backup, Restore, Take Ownership, and Security privileges while it runs and disables the privileges it enabled when it finishes. These privileges are only available in an elevated session of an account that holds them, such as a member of the local Administrators group. If a privilege cannot be enabled, the cmdlet continues without it and writes a debug message. - If the ACL of an item cannot be read because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. Changing the owner of an item requires the Take Ownership and Restore privileges, so this fallback only succeeds in an elevated session of an account that holds them. + If the ACL of an item cannot be read because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. Reading the owner, which that fallback needs first, requires the same Read Permissions right as reading the ACL, so the cmdlet then writes a non-terminating `ReadSecurityError` and continues with the next item. Before 5.0.0, it reported that error as an `AddAceError`. Before 5.0.0, the cmdlet ignored `-Account` and `-SecurityDescriptor`, and after a path whose ACL could not be read, it returned the orphaned entries of the previous item again. @@ -8425,7 +8425,7 @@ PS C:\Data> Get-NTFSSecurityDescriptor When the module setting `EnablePrivileges` is `$true` (the default in the `PrivateData` section of NTFSSecurity.psd1), this cmdlet tries to enable the Backup, Restore, Take Ownership, and Security privileges while it runs and disables the privileges it enabled when it finishes. These privileges are only available in an elevated session of an account that holds them, such as a member of the local Administrators group. If a privilege cannot be enabled, the cmdlet continues without it and writes a debug message. - If the ACL of an item cannot be written because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. Changing the owner of an item requires the Take Ownership and Restore privileges, so this fallback only succeeds in an elevated session of an account that holds them. + If the ACL of an item cannot be written because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. That fallback only succeeds when the account can take ownership of the item, through the Take Ownership right on the item or the Take Ownership privilege, and can set the previous owner back, which needs the Restore privilege unless that owner is the account itself or one of its groups. When the owner changes, Windows removes the entries for OWNER RIGHTS of the item. Removing rights from an entry that does not exist is not an error; the cmdlet leaves the ACL unchanged. In the `Path` parameter sets, the cmdlet writes only the DACL of the item and leaves its owner, its group, and its SACL as they are. Before 5.0.0, it could also write the owner back, which failed with error 1307, "This security ID may not be assigned as the owner of this object", when the account may not assign that owner, such as on some file servers. An entry with a generic right, such as `GenericAll`, can be removed, for example by piping it from `Get-NTFSAccess`. Windows keeps generic rights in the inherit-only entries of folders. Before 5.0.0, the cmdlet failed for such an entry with the error "The value '269484032' is not valid for this usage of the type FileSystemRights". diff --git a/Tests/PathErrors.Tests.ps1 b/Tests/PathErrors.Tests.ps1 new file mode 100644 index 0000000..3202c8e --- /dev/null +++ b/Tests/PathErrors.Tests.ps1 @@ -0,0 +1,232 @@ +<# + Tests the error handling that the cmdlets with -Path share, with the module built in NTFSSecurity\bin\Release on + files in a sandbox folder: a path that doesn't exist, an item whose owner may not read its permissions, and an item + whose owner may not change its permissions, which the cmdlets that write the DACL handle by taking ownership. Each + error belongs to its path only, and the cmdlet continues with the next one. The tests turn the module setting + EnablePrivileges off, so that an elevated session meets the same denials as a basic user, and restore it. +#> +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Pester shares variables between blocks.' +)] +param () + +BeforeDiscovery { + Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force + $holdsSecurityPrivilege = Test-PrivilegeHeld -Name 'SeSecurityPrivilege' + $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. + $auditEntry = @{ Account = 'S-1-1-0'; AccessRights = 'ReadData'; InheritanceFlags = 'None'; PropagationFlags = 'None' } + + # Output: whether the cmdlet writes an object for the next path, an existing file. + $missingPathCases = @( + @{ Command = 'Get-NTFSAccess'; Parameters = @{}; ErrorId = 'ReadFileError'; Output = $true } + @{ Command = 'Add-NTFSAccess'; Parameters = $readEntry; ErrorId = 'ReadFileError'; Output = $false } + @{ Command = 'Remove-NTFSAccess'; Parameters = $readEntry; ErrorId = 'ReadFileError'; Output = $false } + @{ Command = 'Clear-NTFSAccess'; Parameters = @{}; ErrorId = 'ReadFileError'; Output = $false } + @{ Command = 'Get-NTFSEffectiveAccess'; Parameters = @{ Account = 'S-1-1-0' }; ErrorId = 'ReadFileError'; Output = $true } + @{ Command = 'Get-NTFSOrphanedAccess'; Parameters = @{}; ErrorId = 'ReadFileError'; Output = $false } + @{ Command = 'Get-NTFSInheritance'; Parameters = @{}; ErrorId = 'ReadFileError'; Output = $true } + @{ Command = 'Enable-NTFSAccessInheritance'; Parameters = @{}; ErrorId = 'ReadFileError'; Output = $false } + @{ Command = 'Disable-NTFSAccessInheritance'; Parameters = @{}; ErrorId = 'ReadFileError'; Output = $false } + @{ Command = 'Set-NTFSInheritance'; Parameters = @{ AccessInheritanceEnabled = $true }; ErrorId = 'ReadFileError'; Output = $false } + @{ Command = 'Get-NTFSOwner'; Parameters = @{}; ErrorId = 'ReadFileError'; Output = $true } + @{ Command = 'Set-NTFSOwner'; Parameters = @{ Account = $currentUser }; ErrorId = 'ReadFileError'; Output = $false } + @{ Command = 'Get-NTFSSecurityDescriptor'; Parameters = @{}; ErrorId = 'ReadFileError'; Output = $true } + @{ Command = 'Get-Item2'; Parameters = @{}; ErrorId = 'FileNotFound'; Output = $true } + @{ Command = 'Get-FileHash2'; Parameters = @{}; ErrorId = 'ReadFileError'; Output = $true } + @{ Command = 'Get-NTFSHardLink'; Parameters = @{}; ErrorId = 'FileNotFound'; Output = $true } + ) + + # The audit cmdlets need the Security privilege for the next path. + $missingPathAuditCases = @( + @{ Command = 'Get-NTFSAudit'; Parameters = @{} } + @{ Command = 'Add-NTFSAudit'; Parameters = $auditEntry } + @{ Command = 'Remove-NTFSAudit'; Parameters = $auditEntry } + @{ Command = 'Clear-NTFSAudit'; Parameters = @{} } + @{ Command = 'Enable-NTFSAuditInheritance'; Parameters = @{} } + @{ Command = 'Disable-NTFSAuditInheritance'; Parameters = @{} } + ) + + $deniedReadCases = @( + @{ Command = 'Get-NTFSAccess'; Parameters = @{}; Output = $true } + @{ Command = 'Get-NTFSEffectiveAccess'; Parameters = @{ Account = 'S-1-1-0' }; Output = $true } + @{ Command = 'Get-NTFSOrphanedAccess'; Parameters = @{}; Output = $false } + @{ Command = 'Get-NTFSInheritance'; Parameters = @{}; Output = $true } + @{ Command = 'Get-NTFSOwner'; Parameters = @{}; Output = $true } + @{ Command = 'Get-NTFSSecurityDescriptor'; Parameters = @{}; Output = $true } + ) +} + +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 'PathErrors' + Push-Location -LiteralPath $sandbox + + $privateData = (Get-Module -Name NTFSSecurity).PrivateData + $enablePrivileges = $privateData['EnablePrivileges'] + $privateData['EnablePrivileges'] = $false + $sidType = [System.Security.Principal.SecurityIdentifier] + + function Get-TestMissingPath { + $path = Join-Path -Path $sandbox -ChildPath ('Missing-{0}' -f [guid]::NewGuid().ToString('N').Substring(0, 8)) + Assert-TestSandboxPath -Sandbox $sandbox -Path $path + $path + } + + function Get-TestAcl { + # .NET, because Get-Acl of an elevated Windows PowerShell reads also items that deny reading their permissions. + # .NET Core has the method as an extension method. + param ([string] $Path) + + $info = New-Object -TypeName 'System.IO.FileInfo' -ArgumentList $Path + if ($PSVersionTable.PSEdition -eq 'Desktop') { + $info.GetAccessControl() + } + else { + [System.IO.FileSystemAclExtensions]::GetAccessControl($info) + } + } +} + +AfterAll { + $privateData['EnablePrivileges'] = $enablePrivileges + Pop-Location + Remove-TestSandbox -Sandbox $sandbox + Remove-Module -Name NTFSSecurity -Force -ErrorAction SilentlyContinue +} + +Describe 'A path that does not exist' { + It ' should write a for it and continue with the next path' -ForEach $missingPathCases { + $missing = Get-TestMissingPath + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'Next' + + $output = @(& $Command -Path $missing, $file @Parameters -ErrorVariable pathErrors -ErrorAction SilentlyContinue -WarningAction SilentlyContinue) + + $pathErrors | Should -HaveCount 1 + $pathErrors[0].FullyQualifiedErrorId | Should -BeLike "$ErrorId,*" + $pathErrors[0].TargetObject | Should -Be $missing + if ($Output) { + $output | Should -Not -BeNullOrEmpty + } + } + + It ' should write a ReadFileError for it and continue with the next path' -ForEach $missingPathAuditCases -Skip:(-not $holdsSecurityPrivilege) { + $privateData['EnablePrivileges'] = $true + try { + $missing = Get-TestMissingPath + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'NextAudit' + + & $Command -Path $missing, $file @Parameters -ErrorVariable pathErrors -ErrorAction SilentlyContinue | Out-Null + } + finally { + $privateData['EnablePrivileges'] = $false + } + + $pathErrors | Should -HaveCount 1 + $pathErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadFileError,*' + $pathErrors[0].TargetObject | Should -Be $missing + } +} + +Describe 'An item whose owner may not read its permissions' { + # A deny entry for OWNER RIGHTS replaces the right of the owner to read the security descriptor. Taking ownership + # can't help: the cmdlet must read the owner first, which needs the same right. Before 5.0.0-rc6, + # Get-NTFSOrphanedAccess reported this as an AddAceError. + It ' should write a ReadSecurityError for it and continue with the next path' -ForEach $deniedReadCases { + $blocked = New-TestSandboxItem -Sandbox $sandbox -Name 'Unreadable' + Block-TestReadPermission -Sandbox $sandbox -Path $blocked + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'NextReadable' + + $output = @(& $Command -Path $blocked, $file @Parameters -ErrorVariable pathErrors -ErrorAction SilentlyContinue -WarningAction SilentlyContinue) + + $pathErrors | Should -HaveCount 1 + $pathErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadSecurityError,*' + $pathErrors[0].TargetObject | Should -Be $blocked + if ($Output) { + $output | Should -Not -BeNullOrEmpty + } + } +} + +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. + BeforeEach { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'Unchangeable' + $acl = Get-TestAcl -Path $file + $owner = $acl.GetOwner($sidType).Value + } + + It 'Add-NTFSAccess should take ownership, add the entry, and set the owner back' { + Add-TestDenyRule -Sandbox $sandbox -Path $file -Rights @{ 'S-1-3-4' = 'ChangePermissions' } + + Add-NTFSAccess -Path $file -Account 'S-1-1-0' -AccessRights ReadData -ErrorVariable changeErrors -ErrorAction SilentlyContinue + + $changeErrors | Should -BeNullOrEmpty + $acl = Get-TestAcl -Path $file + $acl.GetOwner($sidType).Value | Should -Be $owner + @($acl.GetAccessRules($true, $false, $sidType) | Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-1-0' }) | Should -HaveCount 1 + @($acl.GetAccessRules($true, $false, $sidType) | Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-3-4' }) | Should -BeNullOrEmpty + } + + It 'Remove-NTFSAccess should take ownership, remove the entry, and set the owner back' { + Add-NTFSAccess -Path $file -Account 'S-1-1-0' -AccessRights ReadData + Add-TestDenyRule -Sandbox $sandbox -Path $file -Rights @{ 'S-1-3-4' = 'ChangePermissions' } + + Remove-NTFSAccess -Path $file -Account 'S-1-1-0' -AccessRights ReadData -ErrorVariable changeErrors -ErrorAction SilentlyContinue + + $changeErrors | Should -BeNullOrEmpty + $acl = Get-TestAcl -Path $file + $acl.GetOwner($sidType).Value | Should -Be $owner + @($acl.GetAccessRules($true, $false, $sidType) | Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-1-0' }) | Should -BeNullOrEmpty + } + + It 'Clear-NTFSAccess should take ownership, remove the explicit entries, and set the owner back' { + Add-NTFSAccess -Path $file -Account 'S-1-1-0' -AccessRights ReadData + Add-TestDenyRule -Sandbox $sandbox -Path $file -Rights @{ 'S-1-3-4' = 'ChangePermissions' } + + Clear-NTFSAccess -Path $file -ErrorVariable changeErrors -ErrorAction SilentlyContinue + + $changeErrors | Should -BeNullOrEmpty + $acl = Get-TestAcl -Path $file + $acl.GetOwner($sidType).Value | Should -Be $owner + @($acl.GetAccessRules($true, $false, $sidType)) | Should -BeNullOrEmpty + } + + It 'Disable-NTFSAccessInheritance should take ownership, protect the DACL, and set the owner back' { + Add-TestDenyRule -Sandbox $sandbox -Path $file -Rights @{ 'S-1-3-4' = 'ChangePermissions' } + + Disable-NTFSAccessInheritance -Path $file -ErrorVariable changeErrors -ErrorAction SilentlyContinue + + $changeErrors | Should -BeNullOrEmpty + $acl = Get-TestAcl -Path $file + $acl.GetOwner($sidType).Value | Should -Be $owner + $acl.AreAccessRulesProtected | Should -BeTrue + } + + It 'Enable-NTFSAccessInheritance should take ownership, let the DACL inherit, and set the owner back' { + Disable-NTFSAccessInheritance -Path $file + Add-TestDenyRule -Sandbox $sandbox -Path $file -Rights @{ 'S-1-3-4' = 'ChangePermissions' } + + Enable-NTFSAccessInheritance -Path $file -ErrorVariable changeErrors -ErrorAction SilentlyContinue + + $changeErrors | Should -BeNullOrEmpty + $acl = Get-TestAcl -Path $file + $acl.GetOwner($sidType).Value | Should -Be $owner + $acl.AreAccessRulesProtected | Should -BeFalse + } + + It 'Set-NTFSInheritance should take ownership, protect the DACL, and set the owner back' { + Add-TestDenyRule -Sandbox $sandbox -Path $file -Rights @{ 'S-1-3-4' = 'ChangePermissions' } + + Set-NTFSInheritance -Path $file -AccessInheritanceEnabled $false -ErrorVariable changeErrors -ErrorAction SilentlyContinue + + $changeErrors | Should -BeNullOrEmpty + $acl = Get-TestAcl -Path $file + $acl.GetOwner($sidType).Value | Should -Be $owner + $acl.AreAccessRulesProtected | Should -BeTrue + } +} From c8b728ecbce5b2e087e7aaccbdacff241d2bb37f Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Thu, 8 Oct 2026 10:12:49 +0000 Subject: [PATCH 18/33] test: cover the SecurityDescriptor parameter sets without tests Clear-NTFSAccess, Disable-NTFSAccessInheritance, Enable-NTFSAccessInheritance, Clear-NTFSAudit, and Enable-NTFSAuditInheritance change a descriptor of Get-NTFSSecurityDescriptor in memory only, and the item changes when Set-NTFSSecurityDescriptor writes it. Get-NTFSAccess, Get-NTFSOwner, and Get-NTFSAudit return for a descriptor what they return for its path. All pass elevated and as a basic user in both editions; the audit tests skip without the Security privilege. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- Tests/SecurityDescriptorSets.Tests.ps1 | 146 +++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 Tests/SecurityDescriptorSets.Tests.ps1 diff --git a/Tests/SecurityDescriptorSets.Tests.ps1 b/Tests/SecurityDescriptorSets.Tests.ps1 new file mode 100644 index 0000000..361591f --- /dev/null +++ b/Tests/SecurityDescriptorSets.Tests.ps1 @@ -0,0 +1,146 @@ +<# + Tests the SecurityDescriptor parameter sets that no other test file covers, with the module built in + NTFSSecurity\bin\Release on files in a sandbox folder. The cmdlets change a descriptor of Get-NTFSSecurityDescriptor + in memory only, and the item changes when Set-NTFSSecurityDescriptor writes the descriptor; the cmdlets that read + return what their Path parameter set returns. Tests of audit entries need the Security privilege and skip without + it. +#> +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Pester shares variables between blocks.' +)] +param () + +BeforeDiscovery { + Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force + $holdsSecurityPrivilege = Test-PrivilegeHeld -Name 'SeSecurityPrivilege' +} + +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 'SecurityDescriptorSets' + Push-Location -LiteralPath $sandbox + $sidType = [System.Security.Principal.SecurityIdentifier] + + function Get-ExplicitAccessCount { + param ([System.Security.AccessControl.FileSystemSecurity] $Acl) + + @($Acl.GetAccessRules($true, $false, $sidType)).Count + } +} + +AfterAll { + Pop-Location + Remove-TestSandbox -Sandbox $sandbox + Remove-Module -Name NTFSSecurity -Force -ErrorAction SilentlyContinue +} + +Describe 'Cmdlets that change a security descriptor in memory' { + It 'Clear-NTFSAccess should remove the explicit access entries of the descriptor' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'ClearAccess' + Add-NTFSAccess -Path $file -Account 'S-1-1-0' -AccessRights ReadData + $sd = Get-NTFSSecurityDescriptor -Path $file + + Clear-NTFSAccess -SecurityDescriptor $sd -ErrorAction Stop + + Get-ExplicitAccessCount -Acl $sd.SecurityDescriptor | Should -Be 0 + Get-ExplicitAccessCount -Acl (Get-Acl -LiteralPath $file) | Should -Be 1 + Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -ErrorAction Stop + Get-ExplicitAccessCount -Acl (Get-Acl -LiteralPath $file) | Should -Be 0 + } + + It 'Disable-NTFSAccessInheritance should protect the DACL of the descriptor and keep the inherited entries' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'DisableAccess' + $inheritedCount = @((Get-Acl -LiteralPath $file).GetAccessRules($false, $true, $sidType)).Count + $sd = Get-NTFSSecurityDescriptor -Path $file + + Disable-NTFSAccessInheritance -SecurityDescriptor $sd -ErrorAction Stop + + $sd.SecurityDescriptor.AreAccessRulesProtected | Should -BeTrue + (Get-Acl -LiteralPath $file).AreAccessRulesProtected | Should -BeFalse + Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -ErrorAction Stop + $acl = Get-Acl -LiteralPath $file + $acl.AreAccessRulesProtected | Should -BeTrue + Get-ExplicitAccessCount -Acl $acl | Should -Be $inheritedCount + } + + It 'Enable-NTFSAccessInheritance should let the DACL of the descriptor inherit' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'EnableAccess' + Disable-NTFSAccessInheritance -Path $file -RemoveInheritedAccessRules + $sd = Get-NTFSSecurityDescriptor -Path $file + + Enable-NTFSAccessInheritance -SecurityDescriptor $sd -ErrorAction Stop + + $sd.SecurityDescriptor.AreAccessRulesProtected | Should -BeFalse + (Get-Acl -LiteralPath $file).AreAccessRulesProtected | Should -BeTrue + Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -ErrorAction Stop + $acl = Get-Acl -LiteralPath $file + $acl.AreAccessRulesProtected | Should -BeFalse + @($acl.GetAccessRules($false, $true, $sidType)) | Should -Not -BeNullOrEmpty + } + + It 'Clear-NTFSAudit should remove the explicit audit entries of the descriptor' -Skip:(-not $holdsSecurityPrivilege) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'ClearAudit' + Add-NTFSAudit -Path $file -Account 'S-1-1-0' -AccessRights ReadData -InheritanceFlags None -PropagationFlags None + $sd = Get-NTFSSecurityDescriptor -Path $file + + Clear-NTFSAudit -SecurityDescriptor $sd -ErrorAction Stop + + @($sd.SecurityDescriptor.GetAuditRules($true, $false, $sidType)) | Should -BeNullOrEmpty + @(Get-NTFSAudit -Path $file -ExcludeInherited) | Should -HaveCount 1 + Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -ErrorAction Stop + @(Get-NTFSAudit -Path $file -ExcludeInherited) | Should -BeNullOrEmpty + } + + It 'Enable-NTFSAuditInheritance should let the SACL of the descriptor inherit' -Skip:(-not $holdsSecurityPrivilege) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'EnableAudit' + Disable-NTFSAuditInheritance -Path $file + $sd = Get-NTFSSecurityDescriptor -Path $file + $sd.SecurityDescriptor.AreAuditRulesProtected | Should -BeTrue + + Enable-NTFSAuditInheritance -SecurityDescriptor $sd -ErrorAction Stop + + $sd.SecurityDescriptor.AreAuditRulesProtected | Should -BeFalse + (Get-NTFSInheritance -Path $file).AuditInheritanceEnabled | Should -BeFalse + Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -ErrorAction Stop + (Get-NTFSInheritance -Path $file).AuditInheritanceEnabled | Should -BeTrue + } +} + +Describe 'Cmdlets that read a security descriptor in memory' { + BeforeAll { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'Read' + Add-NTFSAccess -Path $file -Account 'S-1-1-0' -AccessRights ReadData + } + + It 'Get-NTFSAccess should return the entries that it returns for the path' { + $expected = @(Get-NTFSAccess -Path $file | ForEach-Object -Process { '{0}|{1}|{2}' -f $_.Account.Sid, $_.AccessRights, $_.IsInherited }) + + $result = @(Get-NTFSSecurityDescriptor -Path $file | Get-NTFSAccess -ErrorAction Stop) + + $result | Should -Not -BeNullOrEmpty + ($result | ForEach-Object -Process { '{0}|{1}|{2}' -f $_.Account.Sid, $_.AccessRights, $_.IsInherited }) -join ';' | Should -Be ($expected -join ';') + $result | ForEach-Object -Process { $_.FullName | Should -Be $file } + } + + It 'Get-NTFSOwner should return the owner that it returns for the path' { + $expected = (Get-NTFSOwner -Path $file).Owner.Sid + + $result = @(Get-NTFSSecurityDescriptor -Path $file | Get-NTFSOwner -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0].Owner.Sid | Should -Be $expected + $result[0].FullName | Should -Be $file + } + + It 'Get-NTFSAudit should return the audit entries that it returns for the path' -Skip:(-not $holdsSecurityPrivilege) { + Add-NTFSAudit -Path $file -Account 'S-1-1-0' -AccessRights ReadData -InheritanceFlags None -PropagationFlags None + $expected = @(Get-NTFSAudit -Path $file | ForEach-Object -Process { '{0}|{1}|{2}' -f $_.Account.Sid, $_.AccessRights, $_.AuditFlags }) + + $result = @(Get-NTFSSecurityDescriptor -Path $file | Get-NTFSAudit -ErrorAction Stop) + + $result | Should -HaveCount 1 + ($result | ForEach-Object -Process { '{0}|{1}|{2}' -f $_.Account.Sid, $_.AccessRights, $_.AuditFlags }) -join ';' | Should -Be ($expected -join ';') + } +} From bbac9ac289ad35739c1a4dccce33fa9ca2339a37 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Thu, 8 Oct 2026 10:19:50 +0000 Subject: [PATCH 19/33] test: close the follow-ups of #110 that tests can reach - Remove-NTFSAccess and Remove-NTFSAudit with -RemoveSpecific are tested also with -Path, not only with -SecurityDescriptor. - Copy-Item2 and Move-Item2 name the destination of a folder in their verbose message, not only of a file. - The Enable-Privileges count compares with the privileges of the token, so that it passes as a basic user, whose token holds one; the tests of -PassThru restore the privilege states that they found. - The type name comparison of Get-FileHash2 is exact, the inherited-entry counts must be greater than zero, and the braces test (#3) checks the verbose message that raised the FormatException. - Remove-TestSandbox removes paths longer than 260 characters in Windows PowerShell, through the \\?\ prefix and rd, so the long-path test of Test-Path2 no longer cleans up itself; after a failed setup, it returns instead of stopping AfterAll with a binding error. Not reachable by a test: a second path whose SACL read fails while the Security privilege is enabled; a declined -Confirm takes the code path of -WhatIf. The tests that need a session without privileges get the CI run as a basic user. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- Tests/Access.Tests.ps1 | 26 ++++++++++++++++-- Tests/Audit.Tests.ps1 | 21 +++++++++++++++ Tests/Inheritance.Tests.ps1 | 2 ++ Tests/ItemCmdlets.Tests.ps1 | 23 +++++++++++----- Tests/OutputTypes.Tests.ps1 | 22 ++++++++++++--- Tests/SecurityDescriptorSets.Tests.ps1 | 1 + Tests/TestHelpers.Tests.ps1 | 22 +++++++++++++++ Tests/TestHelpers.psm1 | 37 ++++++++++++++++++++++---- 8 files changed, 137 insertions(+), 17 deletions(-) diff --git a/Tests/Access.Tests.ps1 b/Tests/Access.Tests.ps1 index 9df8bb3..6c34196 100644 --- a/Tests/Access.Tests.ps1 +++ b/Tests/Access.Tests.ps1 @@ -152,13 +152,15 @@ Describe 'Get-NTFSEffectiveAccess' { } Describe 'Get-NTFSOrphanedAccess' { - # Before 5.0.0, a path with braces stopped the cmdlet with a FormatException (#3). + # Before 5.0.0, a path with braces stopped the cmdlet with a FormatException (#3), which the verbose message raised. It 'Should read a folder whose name contains braces' { $braces = Join-Path -Path $sandbox -ChildPath ('{{Braces}}-{0}' -f [guid]::NewGuid().ToString('N').Substring(0, 8)) Assert-TestSandboxPath -Sandbox $sandbox -Path $braces [IO.Directory]::CreateDirectory($braces) | Out-Null - { Get-NTFSOrphanedAccess -Path $braces -ErrorAction Stop } | Should -Not -Throw + $messages = @(Get-NTFSOrphanedAccess -Path $braces -Verbose -ErrorAction Stop 4>&1) + + $messages.Message | Should -Contain "Item $braces knows about 0 orphaned SIDs in its ACL" } BeforeAll { @@ -549,6 +551,26 @@ Describe 'Remove-NTFSAccess' { $rule | Should -Not -BeNullOrEmpty $rule.FileSystemRights.HasFlag([System.Security.AccessControl.FileSystemRights]::ReadData) | Should -BeFalse } + + It 'Should keep an entry that does not match exactly, given the path' { + Add-NTFSAccess -Path $removeFolder -Account 'Everyone' -AccessRights Modify + + Remove-NTFSAccess -Path $removeFolder -Account 'Everyone' -AccessRights ReadData -RemoveSpecific -ErrorAction Stop + + $rules = @((Get-Acl -LiteralPath $removeFolder).GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-1-0' }) + $rules | Should -HaveCount 1 + $rules[0].FileSystemRights.HasFlag([System.Security.AccessControl.FileSystemRights]::Modify) | Should -BeTrue + } + + It 'Should remove an entry that matches exactly, given the path' { + Add-NTFSAccess -Path $removeFolder -Account 'Everyone' -AccessRights Modify + + Remove-NTFSAccess -Path $removeFolder -Account 'Everyone' -AccessRights Modify -RemoveSpecific -ErrorAction Stop + + (Get-Acl -LiteralPath $removeFolder).GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-1-0' } | Should -BeNullOrEmpty + } } } diff --git a/Tests/Audit.Tests.ps1 b/Tests/Audit.Tests.ps1 index a9bc2ef..d7dae3a 100644 --- a/Tests/Audit.Tests.ps1 +++ b/Tests/Audit.Tests.ps1 @@ -316,6 +316,25 @@ Describe 'Remove-NTFSAudit' { Get-EveryoneAuditRule | Should -BeNullOrEmpty } + + It 'Should keep an audit entry that does not match exactly, given the path' { + Add-NTFSAudit -Path $removeFolder -Account 'Everyone' -AccessRights Modify + + Remove-NTFSAudit -Path $removeFolder -Account 'Everyone' -AccessRights ReadData -RemoveSpecific -ErrorAction Stop + + $entries = @(Get-NTFSAudit -Path $removeFolder -ExcludeInherited | Where-Object -FilterScript { $_.Account.Sid -eq 'S-1-1-0' }) + $entries | Should -HaveCount 1 + $entries[0].AccessRights.ToString() | Should -BeLike '*Modify*' + } + + It 'Should remove an audit entry that matches exactly, given the path' { + Add-NTFSAudit -Path $removeFolder -Account 'Everyone' -AccessRights Modify + + Remove-NTFSAudit -Path $removeFolder -Account 'Everyone' -AccessRights Modify -RemoveSpecific -ErrorAction Stop + + Get-NTFSAudit -Path $removeFolder -ExcludeInherited | Where-Object -FilterScript { $_.Account.Sid -eq 'S-1-1-0' } | + Should -BeNullOrEmpty + } } Context 'With -PassThru' { @@ -415,6 +434,8 @@ Describe 'Clear-NTFSAudit' { ) $audit.SecurityDescriptor.GetSecurityDescriptorSddlForm('Audit') | Should -BeNullOrEmpty $inheritedCount = @((Get-Acl -LiteralPath $file).GetAccessRules($false, $true, $sidType)).Count + # Without inherited entries, the test couldn't see them copied as explicit ones (#110). + $inheritedCount | Should -BeGreaterThan 0 Clear-NTFSAudit -Path $file -ErrorVariable clearErrors -ErrorAction SilentlyContinue diff --git a/Tests/Inheritance.Tests.ps1 b/Tests/Inheritance.Tests.ps1 index 102df95..50d3fce 100644 --- a/Tests/Inheritance.Tests.ps1 +++ b/Tests/Inheritance.Tests.ps1 @@ -127,6 +127,8 @@ Describe 'Set-NTFSInheritance' { $file = New-TestSandboxItem -Sandbox $sandbox -Name 'KeepAccess' Assert-TestSandboxPath -Sandbox $sandbox -Path $file $inheritedCount = @((Get-Acl -LiteralPath $file).Access | Where-Object -Property IsInherited).Count + # Without inherited entries, the test couldn't see them kept as explicit ones (#110). + $inheritedCount | Should -BeGreaterThan 0 Set-NTFSInheritance -Path $file -AccessInheritanceEnabled $false diff --git a/Tests/ItemCmdlets.Tests.ps1 b/Tests/ItemCmdlets.Tests.ps1 index 835badb..ba6c75d 100644 --- a/Tests/ItemCmdlets.Tests.ps1 +++ b/Tests/ItemCmdlets.Tests.ps1 @@ -160,6 +160,20 @@ Describe 'Copy-Item2, Move-Item2, and Remove-Item2 with several paths' { $messages.Message | Should -Contain ("File '{0}' {1} to '{2}'" -f $first, $Verb, $target) } + It ' should name the destination of a folder in the verbose message' -ForEach @( + @{ Command = 'Copy-Item2'; Verb = 'copied' } + @{ Command = 'Move-Item2'; Verb = 'moved' } + ) { + $sourceFolder = Join-Path -Path $folder -ChildPath 'VerboseFolder' + Assert-TestSandboxPath -Sandbox $sandbox -Path $sourceFolder + New-Item -ItemType Directory -Path $sourceFolder | Out-Null + $target = Join-Path -Path $destination -ChildPath 'VerboseFolder' + + $messages = & $Command -Path $sourceFolder -Destination $destination -Verbose 4>&1 + + $messages.Message | Should -Contain ("Directory '{0}' {1} to '{2}'" -f $sourceFolder, $Verb, $target) + } + # With -PassThru, both cmdlets return the item at the destination, as their pages say. It 'Copy-Item2 -PassThru should return the copy' { $result = Copy-Item2 -Path $first -Destination $destination -PassThru $true @@ -381,14 +395,9 @@ Describe 'Test-Path2' { $long = Join-Path -Path $longRoot -ChildPath (('A' * 100), ('B' * 100), ('C' * 100) -join '\') Add-Type -Path (Join-Path -Path (Get-Module -Name NTFSSecurity).ModuleBase -ChildPath 'AlphaFS.dll') [Alphaleonis.Win32.Filesystem.Directory]::CreateDirectory($long) | Out-Null - try { - $long.Length | Should -BeGreaterThan 260 + $long.Length | Should -BeGreaterThan 260 - Test-Path2 -Path $long -PathType Container -ErrorAction Stop | Should -BeTrue - } finally { - # Remove-TestSandbox can't delete paths longer than 260 characters (#110). - [Alphaleonis.Win32.Filesystem.Directory]::Delete($longRoot, $true) - } + Test-Path2 -Path $long -PathType Container -ErrorAction Stop | Should -BeTrue } # Before 5.0.0-rc6, a path with a character that Windows doesn't allow in file names stopped the cmdlet with a diff --git a/Tests/OutputTypes.Tests.ps1 b/Tests/OutputTypes.Tests.ps1 index 0286333..e6bf35c 100644 --- a/Tests/OutputTypes.Tests.ps1 +++ b/Tests/OutputTypes.Tests.ps1 @@ -56,20 +56,36 @@ Describe 'Declared output types' { $result = Get-FileHash2 -Path $file - $result.PSObject.TypeNames[0] | Should -Be @((Get-Command -Name Get-FileHash2).OutputType.Name)[0] + $result.PSObject.TypeNames[0] | Should -BeExactly @((Get-Command -Name Get-FileHash2).OutputType.Name)[0] } } Describe 'Privilege cmdlets with -PassThru' { + BeforeAll { + # The tests enable and disable the privileges of the test process; AfterAll restores the states they had (#110). + $fileSystemPrivileges = 'TakeOwnership', 'Restore', 'Backup', 'Security' + $enabledBefore = @(Get-Privileges | Where-Object -FilterScript { + $_.Privilege.ToString() -in $fileSystemPrivileges -and $_.PrivilegeState -eq 'Enabled' + } | ForEach-Object -Process { $_.Privilege }) + } + AfterEach { Disable-Privileges -ErrorAction SilentlyContinue -WarningAction SilentlyContinue } - # Before 5.0.0, -PassThru wrote the privileges as one collection. + AfterAll { + $process = [System.Diagnostics.Process]::GetCurrentProcess() + foreach ($privilege in $enabledBefore) { + $null = [ProcessPrivileges.ProcessExtensions]::EnablePrivilege($process, $privilege) + } + } + + # Before 5.0.0, -PassThru wrote the privileges as one collection. The access token of a basic user holds one + # privilege only, so the test compares with the privileges that the token holds. It 'Enable-Privileges should write one object per privilege' { $result = @(Enable-Privileges -PassThru -ErrorAction SilentlyContinue) - $result.Count | Should -BeGreaterThan 1 + $result | Should -HaveCount @(Get-Privileges).Count $result | ForEach-Object -Process { $_ | Should -BeOfType [ProcessPrivileges.PrivilegeAndAttributes] } } diff --git a/Tests/SecurityDescriptorSets.Tests.ps1 b/Tests/SecurityDescriptorSets.Tests.ps1 index 361591f..a8d4a8a 100644 --- a/Tests/SecurityDescriptorSets.Tests.ps1 +++ b/Tests/SecurityDescriptorSets.Tests.ps1 @@ -53,6 +53,7 @@ Describe 'Cmdlets that change a security descriptor in memory' { It 'Disable-NTFSAccessInheritance should protect the DACL of the descriptor and keep the inherited entries' { $file = New-TestSandboxItem -Sandbox $sandbox -Name 'DisableAccess' $inheritedCount = @((Get-Acl -LiteralPath $file).GetAccessRules($false, $true, $sidType)).Count + $inheritedCount | Should -BeGreaterThan 0 $sd = Get-NTFSSecurityDescriptor -Path $file Disable-NTFSAccessInheritance -SecurityDescriptor $sd -ErrorAction Stop diff --git a/Tests/TestHelpers.Tests.ps1 b/Tests/TestHelpers.Tests.ps1 index 0b569c8..0969633 100644 --- a/Tests/TestHelpers.Tests.ps1 +++ b/Tests/TestHelpers.Tests.ps1 @@ -134,6 +134,28 @@ Describe 'Test helpers' { It 'Should not change the ACL of the target of a junction' { (Get-Acl -LiteralPath $target).Sddl | Should -BeExactly $targetSddl } + + # Before 5.0.0-rc6, the teardown couldn't remove paths longer than 260 characters in Windows PowerShell (#110). + It 'Should remove a sandbox with a path longer than 260 characters' { + $longSandbox = New-TestSandbox -Name 'Helpers' + $long = Join-Path -Path $longSandbox -ChildPath (('A' * 100), ('B' * 100), ('C' * 100) -join '\') + Assert-TestSandboxPath -Sandbox $longSandbox -Path $long + # The \\?\ prefix lets Windows PowerShell create the path. + [IO.Directory]::CreateDirectory('\\?\' + $long) | Out-Null + [IO.File]::WriteAllText(('\\?\' + $long + '\File.txt'), 'Long') + $long.Length | Should -BeGreaterThan 260 + + Remove-TestSandbox -Sandbox $longSandbox + + $longSandbox | Should -Not -Exist + } + + # Before 5.0.0-rc6, an AfterAll after a failed setup stopped with a binding error that hid the error of the setup. + It 'Should do nothing for a sandbox that a failed setup did not create: <_>' -ForEach @('$null', 'empty string') { + $value = if ($_ -eq '$null') { $null } else { '' } + + { Remove-TestSandbox -Sandbox $value } | Should -Not -Throw + } } Context 'Set-TestOwner' { diff --git a/Tests/TestHelpers.psm1 b/Tests/TestHelpers.psm1 index e956790..277a485 100644 --- a/Tests/TestHelpers.psm1 +++ b/Tests/TestHelpers.psm1 @@ -87,10 +87,18 @@ function Remove-TestSandbox { [CmdletBinding()] param ( [Parameter(Mandatory)] + [AllowNull()] + [AllowEmptyString()] [string] $Sandbox ) + # A setup that failed before New-TestSandbox returned leaves nothing to remove. Before 5.0.0-rc6, the binding error + # hid the error of the setup (#110). + if ([string]::IsNullOrEmpty($Sandbox)) { + return + } + Assert-TestSandboxPath -Sandbox $Sandbox -Path $Sandbox if (-not (Test-Path -LiteralPath $Sandbox)) { return @@ -100,17 +108,20 @@ function Remove-TestSandbox { # the caller uses ErrorAction Stop. Such items can still be deleted through the rights on their folder. $ErrorActionPreference = 'Continue' + # The prefix lets .NET in Windows PowerShell reach paths longer than 260 characters (#110). + $longPathPrefix = '\\?\' + # Windows PowerShell 5.1 and icacls /T follow directory links, so the links go first. A folder that denies # listing its content gets its own ACL reset, without /T, before it is listed. $pending = New-Object -TypeName 'System.Collections.Generic.Stack[string]' - $pending.Push($Sandbox) + $pending.Push($longPathPrefix + $Sandbox) while ($pending.Count -gt 0) { $folder = $pending.Pop() try { $entries = [IO.Directory]::GetFileSystemEntries($folder) } catch { - & icacls.exe $folder /reset /C /Q *> $null + & icacls.exe $folder.Substring($longPathPrefix.Length) /reset /C /Q *> $null $entries = [IO.Directory]::GetFileSystemEntries($folder) } foreach ($entry in $entries) { @@ -129,10 +140,26 @@ function Remove-TestSandbox { } } & icacls.exe $Sandbox /reset /T /C /Q *> $null - Get-ChildItem -LiteralPath $Sandbox -Recurse -Force | ForEach-Object -Process { - $_.Attributes = [IO.FileAttributes]::Normal + Get-ChildItem -LiteralPath $Sandbox -Recurse -Force -ErrorAction SilentlyContinue | ForEach-Object -Process { + # Windows PowerShell returns items below paths longer than 260 characters that it can't change; rd removes them. + try { + $_.Attributes = [IO.FileAttributes]::Normal + } + catch { + Write-Verbose -Message "Keeping the attributes of '$($_.FullName)': $($_.Exception.Message)" + } + } + Remove-Item -LiteralPath $Sandbox -Recurse -Force -ErrorAction SilentlyContinue + if (Test-Path -LiteralPath $Sandbox) { + # Windows PowerShell can't remove paths longer than 260 characters; rd can with the prefix, and the links are + # gone already. + & cmd.exe /d /c ('rd /s /q "{0}{1}"' -f $longPathPrefix, $Sandbox) *> $null } - Remove-Item -LiteralPath $Sandbox -Recurse -Force + + if (Test-Path -LiteralPath $Sandbox) { + Write-Error -Message "The sandbox '$Sandbox' could not be removed." + } + try { # Fails while another sandbox exists, also one of a test run in parallel [IO.Directory]::Delete($script:sandboxRoot, $false) From 61b5ab2f56bcbdcabf4ee20689973e1696b45361 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Thu, 8 Oct 2026 10:46:31 +0000 Subject: [PATCH 20/33] test: expect the folder error of Get-NTFSHardLink as non-terminating Since a324484, Get-NTFSHardLink writes a non-terminating GetHardLinkError for a folder. The current-location test still used -ErrorAction SilentlyContinue and so no longer saw the error; -ErrorAction Stop turns it into the exception that the test expects. The first full-suite run as a basic user found it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- Tests/Owner.Tests.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Tests/Owner.Tests.ps1 b/Tests/Owner.Tests.ps1 index 0ddc8ae..8767460 100644 --- a/Tests/Owner.Tests.ps1 +++ b/Tests/Owner.Tests.ps1 @@ -99,8 +99,9 @@ Describe 'Current location' { Should -Not -Throw } + # The error for the folder is non-terminating since 5.0.0-rc6, so -ErrorAction Stop turns it into the exception. It 'Get-NTFSHardLink should report the folder of the current location, not a NullReferenceException' { - { Invoke-WithShadowedPwd -Command { Get-NTFSHardLink -ErrorAction SilentlyContinue } } | + { Invoke-WithShadowedPwd -Command { Get-NTFSHardLink -ErrorAction Stop } } | Should -Throw -ExpectedMessage '*must be a file*' } } From 8f5da8b33de450fcbc8e9e770292216b867678db Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Thu, 8 Oct 2026 10:46:31 +0000 Subject: [PATCH 21/33] test: write the deny entries of the test helper without Set-Acl Set-Acl compares AreAuditRulesProtected of the new descriptor with AreAccessRulesProtected of the item (FileSystemSecurity.cs in PowerShell), so it also writes the audit section of an item whose DACL is protected. Without the Security privilege that fails with PrivilegeNotHeldException: after Disable-NTFSAccessInheritance, Add-TestDenyRule added nothing, and the take-ownership test of PathErrors.Tests.ps1 failed as a basic user. With the privilege, Set-Acl wrote all sections and dropped the audit entries of the item. Add-TestDenyRule now writes only the DACL with SetAccessControl. Two regression tests cover both effects; each failed before the change in its configuration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- Tests/TestHelpers.Tests.ps1 | 37 +++++++++++++++++++++++++++++++++++++ Tests/TestHelpers.psm1 | 11 ++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/Tests/TestHelpers.Tests.ps1 b/Tests/TestHelpers.Tests.ps1 index 0969633..1daf2f4 100644 --- a/Tests/TestHelpers.Tests.ps1 +++ b/Tests/TestHelpers.Tests.ps1 @@ -11,6 +11,8 @@ BeforeDiscovery { Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force # Assigning an owner other than the user or one of its groups needs the Restore privilege. $canAssignAnyOwner = Test-PrivilegeHeld -Name 'SeRestorePrivilege' + # Reading and writing audit entries needs the Security privilege. + $holdsSecurityPrivilege = Test-PrivilegeHeld -Name 'SeSecurityPrivilege' } BeforeAll { @@ -212,6 +214,41 @@ Describe 'Test helpers' { $rules[0].AccessControlType | Should -Be 'Deny' } + # Set-Acl also writes the audit section of an item whose DACL is protected, which fails without the Security + # privilege. + It 'Should add a deny entry to an item whose DACL is protected' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'Protected' + & icacls.exe $file /inheritance:d *> $null + $LASTEXITCODE | Should -Be 0 + + Add-TestDenyRule -Sandbox $sandbox -Path $file -Rights @{ 'S-1-5-32-546' = 'ReadData' } + + $acl = Get-Acl -LiteralPath $file + $acl.AreAccessRulesProtected | Should -BeTrue + $rules = @($acl.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-5-32-546' }) + $rules | Should -HaveCount 1 + $rules[0].AccessControlType | Should -Be 'Deny' + } + + # With the Security privilege, Set-Acl writes all sections, so the audit entries of the item would be lost. + It 'Should keep the audit entries of the item' -Skip:(-not $holdsSecurityPrivilege) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'Audited' + $auditAcl = Get-Acl -LiteralPath $file -Audit + $auditAcl.AddAuditRule((New-Object -TypeName 'System.Security.AccessControl.FileSystemAuditRule' -ArgumentList ( + (New-Object -TypeName 'System.Security.Principal.SecurityIdentifier' -ArgumentList 'S-1-1-0'), + [System.Security.AccessControl.FileSystemRights]::Delete, + [System.Security.AccessControl.AuditFlags]::Success + ))) + Set-Acl -LiteralPath $file -AclObject $auditAcl + + Add-TestDenyRule -Sandbox $sandbox -Path $file -Rights @{ 'S-1-5-32-546' = 'ReadData' } + + $auditRules = @((Get-Acl -LiteralPath $file -Audit).GetAuditRules($true, $false, [System.Security.Principal.SecurityIdentifier])) + $auditRules | Should -HaveCount 1 + $auditRules[0].IdentityReference.Value | Should -Be 'S-1-1-0' + } + It 'Should refuse an item outside the sandbox' { { Add-TestDenyRule -Sandbox $sandbox -Path "$sandbox-Other\File.txt" -Rights @{ 'S-1-5-32-546' = 'ReadData' } } | Should -Throw -ExpectedMessage 'Refusing to change*' diff --git a/Tests/TestHelpers.psm1 b/Tests/TestHelpers.psm1 index 277a485..6a15057 100644 --- a/Tests/TestHelpers.psm1 +++ b/Tests/TestHelpers.psm1 @@ -277,6 +277,7 @@ function Add-TestDenyRule { ) Assert-TestSandboxPath -Sandbox $Sandbox -Path $Path + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop $acl = Get-Acl -LiteralPath $Path foreach ($sid in $Rights.Keys) { $identity = New-Object -TypeName 'System.Security.Principal.SecurityIdentifier' -ArgumentList $sid @@ -286,7 +287,15 @@ function Add-TestDenyRule { ) $acl.AddAccessRule($rule) } - Set-Acl -LiteralPath $Path -AclObject $acl + # Not Set-Acl: it compares AreAuditRulesProtected with AreAccessRulesProtected, so it also writes the audit + # section of an item whose DACL is protected, which fails without the Security privilege. With the privilege, it + # writes all sections and drops the audit entries. SetAccessControl writes only the DACL, the section that changed. + if ($PSVersionTable.PSEdition -eq 'Core') { + [System.IO.FileSystemAclExtensions]::SetAccessControl($item, $acl) + } + else { + $item.SetAccessControl($acl) + } } function Set-TestOwner { From 0c3324e915690f4704fbef2462510faac3c4154a Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Thu, 8 Oct 2026 10:46:32 +0000 Subject: [PATCH 22/33] ci: run the tests as a basic user in both editions Invoke-TestsAsBasicUser.ps1 derives a token of the SAFER level Normal User from the token of the runner, as runas /trustlevel:0x20000 does, and runs Invoke-Tests.ps1 with it in the same edition. The tests that skip in the elevated session of the runner, because they need a session without the privileges of an administrator, now run in CI as well. The contributor page explains how to run the script locally. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- .github/scripts/Invoke-TestsAsBasicUser.ps1 | 206 ++++++++++++++++++++ .github/workflows/ci.yml | 12 ++ Docs/Contributing/02-Writing.md | 13 +- 3 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 .github/scripts/Invoke-TestsAsBasicUser.ps1 diff --git a/.github/scripts/Invoke-TestsAsBasicUser.ps1 b/.github/scripts/Invoke-TestsAsBasicUser.ps1 new file mode 100644 index 0000000..34f48f2 --- /dev/null +++ b/.github/scripts/Invoke-TestsAsBasicUser.ps1 @@ -0,0 +1,206 @@ +<# +.SYNOPSIS + Runs the Pester tests as a basic user, without the privileges and the Administrators group of the current account. + +.DESCRIPTION + Some tests need a session without the Security, Restore, or Create Symbolic Link privilege, and skip in an elevated + session such as that of a GitHub runner. This script derives a token of the SAFER level Normal User from the token + of the current process, as runas /trustlevel:0x20000 does: the Administrators group is deny-only, and only the + privilege to bypass traverse checking stays. It starts Invoke-Tests.ps1 in the same PowerShell edition with that + token, waits for it, prints its output, and fails if the tests failed. The job summary of GitHub Actions gets the + 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. + +.PARAMETER Title + Specifies the heading of the test results in the job summary. + +.EXAMPLE + .\.github\scripts\Invoke-TestsAsBasicUser.ps1 -ResultPath TestResults\WindowsPowerShell-BasicUser.xml -Title 'Windows PowerShell 5.1 as a basic user' + + Runs the tests in Windows PowerShell 5.1 as a basic user. +#> +[CmdletBinding()] +param ( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $ResultPath, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $Title +) + +$ErrorActionPreference = 'Stop' + +Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; + +public static class NTFSSecurityBasicUserProcess +{ + private const uint SaferScopeIdUser = 2; + private const uint SaferLevelIdNormalUser = 0x20000; + private const uint SaferLevelOpen = 1; + private const uint CreateNoWindow = 0x08000000; + private const uint Infinite = 0xFFFFFFFF; + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct StartupInfo + { + public int cb; + public string lpReserved; + public string lpDesktop; + public string lpTitle; + public int dwX; + public int dwY; + public int dwXSize; + public int dwYSize; + public int dwXCountChars; + public int dwYCountChars; + public int dwFillAttribute; + public int dwFlags; + public short wShowWindow; + public short cbReserved2; + public IntPtr lpReserved2; + public IntPtr hStdInput; + public IntPtr hStdOutput; + public IntPtr hStdError; + } + + [StructLayout(LayoutKind.Sequential)] + private struct ProcessInformation + { + public IntPtr hProcess; + public IntPtr hThread; + public int dwProcessId; + public int dwThreadId; + } + + [DllImport("advapi32.dll", SetLastError = true)] + private static extern bool SaferCreateLevel(uint scopeId, uint levelId, uint openFlags, out IntPtr levelHandle, IntPtr reserved); + + [DllImport("advapi32.dll", SetLastError = true)] + private static extern bool SaferComputeTokenFromLevel(IntPtr levelHandle, IntPtr inAccessToken, out IntPtr outAccessToken, uint flags, IntPtr reserved); + + [DllImport("advapi32.dll", SetLastError = true)] + private static extern bool SaferCloseLevel(IntPtr levelHandle); + + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern bool CreateProcessAsUser(IntPtr token, string applicationName, string commandLine, IntPtr processAttributes, IntPtr threadAttributes, bool inheritHandles, uint creationFlags, IntPtr environment, string currentDirectory, ref StartupInfo startupInfo, out ProcessInformation processInformation); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetExitCodeProcess(IntPtr process, out uint exitCode); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(IntPtr handle); + + // Starts the command line with a token of the SAFER level Normal User, waits for it, and returns its exit code. + public static int Run(string applicationName, string commandLine, string currentDirectory) + { + IntPtr level; + if (!SaferCreateLevel(SaferScopeIdUser, SaferLevelIdNormalUser, SaferLevelOpen, out level, IntPtr.Zero)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + IntPtr token = IntPtr.Zero; + try + { + if (!SaferComputeTokenFromLevel(level, IntPtr.Zero, out token, 0, IntPtr.Zero)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + var startupInfo = new StartupInfo(); + startupInfo.cb = Marshal.SizeOf(typeof(StartupInfo)); + ProcessInformation processInformation; + if (!CreateProcessAsUser(token, applicationName, commandLine, IntPtr.Zero, IntPtr.Zero, false, CreateNoWindow, IntPtr.Zero, currentDirectory, ref startupInfo, out processInformation)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + try + { + WaitForSingleObject(processInformation.hProcess, Infinite); + uint exitCode; + if (!GetExitCodeProcess(processInformation.hProcess, out exitCode)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + return (int)exitCode; + } + finally + { + CloseHandle(processInformation.hThread); + CloseHandle(processInformation.hProcess); + } + } + finally + { + if (token != IntPtr.Zero) + { + CloseHandle(token); + } + + SaferCloseLevel(level); + } + } +} +'@ + +$repositoryPath = (Resolve-Path -LiteralPath (Join-Path -Path $PSScriptRoot -ChildPath '..\..')).ProviderPath +$resultFullPath = [IO.Path]::GetFullPath((Join-Path -Path $repositoryPath -ChildPath $ResultPath)) +$resultFolder = Split-Path -Path $resultFullPath -Parent +if (-not (Test-Path -LiteralPath $resultFolder)) { + New-Item -ItemType Directory -Path $resultFolder | Out-Null +} + +$runFolder = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath ('NTFSSecurity.BasicUser-{0}' -f [guid]::NewGuid().ToString('N').Substring(0, 8)) +New-Item -ItemType Directory -Path $runFolder | Out-Null +$logPath = Join-Path -Path $runFolder -ChildPath 'Output.log' +$summaryPath = Join-Path -Path $runFolder -ChildPath 'Summary.md' +# In the temp folder of the account, which the restricted token can write, unlike maybe the folder of the repository +$runResultPath = Join-Path -Path $runFolder -ChildPath 'Result.xml' + +$executable = (Get-Process -Id $PID).Path +$testScript = Join-Path -Path $PSScriptRoot -ChildPath 'Invoke-Tests.ps1' +# cmd redirects the output of the tests, which have no console of their own. +$commandLine = 'cmd.exe /d /s /c ""{0}" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "{1}" -ResultPath "{2}" -Title "{3}" > "{4}" 2>&1"' -f + $executable, $testScript, $runResultPath, $Title.Replace('"', "'"), $logPath + +$stepSummary = $env:GITHUB_STEP_SUMMARY +$env:GITHUB_STEP_SUMMARY = $summaryPath +try { + "Running the tests as a basic user: $commandLine" + $exitCode = [NTFSSecurityBasicUserProcess]::Run((Join-Path -Path $env:SystemRoot -ChildPath 'System32\cmd.exe'), $commandLine, $repositoryPath) +} +finally { + $env:GITHUB_STEP_SUMMARY = $stepSummary +} + +if (Test-Path -LiteralPath $logPath) { + Get-Content -LiteralPath $logPath +} + +if ($stepSummary -and (Test-Path -LiteralPath $summaryPath)) { + $encoding = New-Object -TypeName 'System.Text.UTF8Encoding' -ArgumentList $false + [IO.File]::AppendAllText($stepSummary, [IO.File]::ReadAllText($summaryPath), $encoding) +} + +if (Test-Path -LiteralPath $runResultPath) { + Copy-Item -LiteralPath $runResultPath -Destination $resultFullPath -Force +} + +Remove-Item -LiteralPath $runFolder -Recurse -Force +if ($exitCode -ne 0) { + throw "The tests as a basic user failed with exit code $exitCode." +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc25332..e2c63d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,6 +121,18 @@ jobs: shell: pwsh run: ./.github/scripts/Invoke-Tests.ps1 -ResultPath TestResults/PowerShell7.xml -Title 'PowerShell 7' + # Without the privileges and the Administrators group of the runner account, so the tests that an elevated + # session skips run as well + - name: Run the tests in Windows PowerShell 5.1 as a basic user + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + shell: powershell + run: .\.github\scripts\Invoke-TestsAsBasicUser.ps1 -ResultPath TestResults\WindowsPowerShell-BasicUser.xml -Title 'Windows PowerShell 5.1 as a basic user' + + - name: Run the tests in PowerShell 7 as a basic user + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + shell: pwsh + run: ./.github/scripts/Invoke-TestsAsBasicUser.ps1 -ResultPath TestResults/PowerShell7-BasicUser.xml -Title 'PowerShell 7 as a basic user' + - name: Upload the test results if: ${{ !cancelled() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/Docs/Contributing/02-Writing.md b/Docs/Contributing/02-Writing.md index 7da169a..8cec86f 100644 --- a/Docs/Contributing/02-Writing.md +++ b/Docs/Contributing/02-Writing.md @@ -119,7 +119,8 @@ Before you open a pull request, check the following: - The Pester tests in `Tests` pass. They test the module in `NTFSSecurity\bin\Release`, for example that `Get-Help` shows every page, and the conversion to the wiki. The CI workflow runs them in Windows - PowerShell 5.1 and in PowerShell 7 with Pester 5.7.1: + PowerShell 5.1 and in PowerShell 7 with Pester 5.7.1, in the elevated + session of the runner and again as a basic user: ```powershell Install-Module -Name Pester -RequiredVersion 5.7.1 -SkipPublisherCheck @@ -127,6 +128,16 @@ Before you open a pull request, check the following: Invoke-Pester -Path .\Tests -Output Detailed ``` + A test that needs a privilege skips without it, and a test that needs a + session without the privileges of an administrator skips in an elevated + session. To run the tests as a basic user from an elevated session, as the + CI workflow does, use `Invoke-TestsAsBasicUser.ps1`: + + ```powershell + .\.github\scripts\Invoke-TestsAsBasicUser.ps1 ` + -ResultPath TestResults\BasicUser.xml -Title 'As a basic user' + ``` + The [live tests](../../Tests/Lab/README.md) in `Tests\Lab` need a lab with a file server and domain accounts. Without one, they skip all their tests, and the CI workflow doesn't run them. From 9e6af75b5e26d72eddc0eb8b3112233eaed15475 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Thu, 8 Oct 2026 10:46:32 +0000 Subject: [PATCH 23/33] test: extend the live tests in the lab to all cmdlet groups Add the cases 4b (orphaned audit entry), 5 (owner), 6 (audit inheritance, Clear-NTFSAudit, Get-NTFSInheritance), 7 (item cmdlets on the share), 8 (link cmdlets on the share), and 9 (Get-NTFSSimpleAccess), and the accounts of three other domains and forests (-ForeignDomainController). The fixture writes the folders with SetAccessControl instead of Set-Acl, which also wrote an empty SACL and dropped the inherited audit entries. The delegated account expects the denial of Get-NTFSEffectiveAccess -ServerName, which the file server answers only for its administrators and the members of Access Control Assistance Operators. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- Tests/Lab/Invoke-NTFSSecurityLabTest.ps1 | 188 +++++++++- Tests/Lab/NTFSSecurity.Live.Tests.ps1 | 417 +++++++++++++++++++++++ Tests/Lab/README.md | 25 +- 3 files changed, 612 insertions(+), 18 deletions(-) diff --git a/Tests/Lab/Invoke-NTFSSecurityLabTest.ps1 b/Tests/Lab/Invoke-NTFSSecurityLabTest.ps1 index c8adcad..e5a0eae 100644 --- a/Tests/Lab/Invoke-NTFSSecurityLabTest.ps1 +++ b/Tests/Lab/Invoke-NTFSSecurityLabTest.ps1 @@ -25,6 +25,11 @@ .PARAMETER Client The computer that runs the tests. Its domain must be the domain of the domain controller. +.PARAMETER ForeignDomainController + Domain controllers of other domains or forests, one per domain. The script creates the account NtfsLiveForeign in + each of their domains, and the tests grant it access to share folders by SID and by name. The domains need a trust + with the domain of the file server. Pass an empty array to leave these tests out. + .PARAMETER Version The versions of NTFSSecurity on the PowerShell Gallery to test, such as 5.0.0-rc4. @@ -75,6 +80,11 @@ param ( [string] $Client = 'F1AFile1', + [Parameter()] + [AllowEmptyCollection()] + [string[]] + $ForeignDomainController = @('F1BDC1', 'F2DC1', 'F3DC1'), + [Parameter(ParameterSetName = 'Test')] [ValidatePattern('^\d+\.\d+\.\d+(-[0-9A-Za-z]+)?$')] [string[]] @@ -120,6 +130,9 @@ $roleAccounts = [ordered]@{ } $subjectAccount = 'NtfsLiveSubject' $orphanAccount = 'NtfsLiveOrphan' +$foreignAccount = 'NtfsLiveForeign' +# The rights that the entries of the foreign accounts grant on the folder of case 9, by position +$foreignRights = 'ReadAndExecute', 'Modify', 'Write' $localGroupName = 'NtfsLiveLocal' $groupMembers = @{ NtfsLiveDelegates = @('NtfsLiveDelegate') @@ -410,6 +423,40 @@ $removeOrphanScript = { } } +# Runs on a domain controller of another domain or forest: the account that the tests of case 9 grant access to. +$foreignAccountScript = { + param ($OrganizationalUnitName, $Name, [securestring] $Password) + + $ErrorActionPreference = 'Stop' + Import-Module -Name ActiveDirectory + $domain = Get-ADDomain + $server = $domain.PDCEmulator + $path = 'OU={0},{1}' -f $OrganizationalUnitName, $domain.DistinguishedName + if (-not (Get-ADOrganizationalUnit -LDAPFilter "(ou=$OrganizationalUnitName)" -SearchBase $domain.DistinguishedName -SearchScope OneLevel -Server $server)) { + New-ADOrganizationalUnit -Name $OrganizationalUnitName -Path $domain.DistinguishedName -ProtectedFromAccidentalDeletion $false -Server $server + } + + $user = Get-ADUser -LDAPFilter "(sAMAccountName=$Name)" -Server $server + if ($user -and $user.DistinguishedName -notlike "*,$path") { + throw "The account '$Name' exists outside '$path'." + } + + if ($user) { + Set-ADAccountPassword -Identity $user -Reset -NewPassword $Password -Server $server + Enable-ADAccount -Identity $user -Server $server + } + else { + $user = New-ADUser -Name $Name -SamAccountName $Name -UserPrincipalName "$Name@$($domain.DNSRoot)" -Path $path -AccountPassword $Password -Enabled $true -PasswordNeverExpires $true -Server $server -PassThru + } + + [pscustomobject]@{ + DomainName = $domain.DNSRoot + Name = '{0}\{1}' -f $domain.NetBIOSName, $Name + UserPrincipalName = '{0}@{1}' -f $Name, $domain.DNSRoot + Sid = $user.SID.Value + } +} + # Runs on the file server once: the local group, the members of Administrators, the share, and the tools folder. $fileServerSetupScript = { param ($ShareName, $ShareLocalPath, $PayloadPath, $LocalGroupName, $SubjectSid, $AdministratorSid, $DelegatesSid) @@ -495,7 +542,7 @@ $clientSetupScript = { # Runs on the file server for each run: creates the folders of the cases below the share and returns what the # configuration needs. Each case and operation gets its own folder, so that the tests don't depend on each other. $fixtureScript = { - param ($HelperScript, $ShareLocalPath, $RunId, $Sid, $SubjectPrincipalName, $LongPathSegment) + param ($HelperScript, $ShareLocalPath, $RunId, $Sid, $SubjectPrincipalName, $LongPathSegment, $ForeignAccount) $ErrorActionPreference = 'Stop' . ([scriptblock]::Create($HelperScript)) @@ -521,7 +568,7 @@ $fixtureScript = { 'PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Runs in the fixture that the script confirmed.' )] param ([string] $RelativePath, [object[]] $AccessRule = @(), [switch] $Protected, [switch] $RemoveInherited, - [switch] $Audit, [switch] $LegacyDacl) + [switch] $Audit, [switch] $LegacyDacl, [switch] $InheritableAudit, [switch] $ProtectedAudit) $path = Join-Path -Path $root -ChildPath $RelativePath $null = New-Item -ItemType Directory -Path $path -Force @@ -535,12 +582,26 @@ $fixtureScript = { $acl.AddAccessRule($rule) } - Set-Acl -LiteralPath $path -AclObject $acl - if ($Audit) { + # Not Set-Acl: in Windows PowerShell, it also writes an empty, protected SACL, which drops the audit entries + # that the folder inherits. SetAccessControl writes only the sections that changed. + [System.IO.Directory]::SetAccessControl($path, $acl) + if ($Audit -or $InheritableAudit -or $ProtectedAudit) { $auditAcl = Get-Acl -LiteralPath $path -Audit $everyone = New-Object -TypeName 'System.Security.Principal.SecurityIdentifier' -ArgumentList 'S-1-1-0' - $auditAcl.AddAuditRule((New-Object -TypeName 'System.Security.AccessControl.FileSystemAuditRule' -ArgumentList $everyone, 'Delete', 'None', 'None', 'Success')) - Set-Acl -LiteralPath $path -AclObject $auditAcl + if ($Audit) { + $auditAcl.AddAuditRule((New-Object -TypeName 'System.Security.AccessControl.FileSystemAuditRule' -ArgumentList $everyone, 'Delete', 'None', 'None', 'Success')) + } + + # An entry that the subfolders created afterwards inherit + if ($InheritableAudit) { + $auditAcl.AddAuditRule((New-Object -TypeName 'System.Security.AccessControl.FileSystemAuditRule' -ArgumentList $everyone, 'Delete', 'ContainerInherit, ObjectInherit', 'None', 'Failure')) + } + + if ($ProtectedAudit) { + $auditAcl.SetAuditRuleProtection($true, $false) + } + + [System.IO.Directory]::SetAccessControl($path, $auditAcl) } # Set-Acl adds the auto-inherit flag, so the DACL is stored again without it at the end. @@ -595,6 +656,11 @@ $fixtureScript = { # Case 4: an entry for the account that the domain controller deletes next, and a file that inherits it. $orphanPath = New-FixtureFolder -RelativePath 'Case4\OrphanedAccess' -AccessRule (New-AccessRule -Sid $Sid.Orphan -Rights 'Modify') Set-Content -LiteralPath (Join-Path -Path $orphanPath -ChildPath 'File.txt') -Value 'Orphan' + $orphanAuditPath = New-FixtureFolder -RelativePath 'Case4\OrphanedAudit' -AccessRule $delegatesFullControl + $orphanAuditAcl = Get-Acl -LiteralPath $orphanAuditPath -Audit + $orphanIdentity = New-Object -TypeName 'System.Security.Principal.SecurityIdentifier' -ArgumentList $Sid.Orphan + $orphanAuditAcl.AddAuditRule((New-Object -TypeName 'System.Security.AccessControl.FileSystemAuditRule' -ArgumentList $orphanIdentity, 'Delete', 'None', 'None', 'Success')) + [System.IO.Directory]::SetAccessControl($orphanAuditPath, $orphanAuditAcl) # A file whose path on the share is longer than 260 characters, created by PowerShell 7, which handles such paths. $longPath = New-FixtureFolder -RelativePath 'LongPath' @@ -609,11 +675,68 @@ $fixtureScript = { Set-Content -LiteralPath (Join-Path -Path $whatIfPath -ChildPath 'Source.txt') -Value 'Source' -NoNewline Set-Content -LiteralPath (Join-Path -Path $whatIfPath -ChildPath 'Destination.txt') -Value 'Destination' -NoNewline + # Case 5: the owner cmdlets, and case 6: the audit inheritance cmdlets and Clear-NTFSAudit below a folder whose + # audit entry the subfolders inherit, on folders that Administrators own and the delegated group fully controls. + foreach ($role in 'Admin', 'ServerAdmin', 'Delegate') { + foreach ($operation in 'GetOwner', 'TakeOwnership', 'AssignOwner') { + $null = New-FixtureFolder -RelativePath "Case5\$role\$operation" -AccessRule $delegatesFullControl + } + + $null = New-FixtureFolder -RelativePath "Case6\$role" -AccessRule $delegatesFullControl -InheritableAudit + $null = New-FixtureFolder -RelativePath "Case6\$role\DisableAuditInheritance" + $null = New-FixtureFolder -RelativePath "Case6\$role\EnableAuditInheritance" -ProtectedAudit + $null = New-FixtureFolder -RelativePath "Case6\$role\ClearAudit" -Audit + $null = New-FixtureFolder -RelativePath "Case6\$role\GetInheritance" -Protected + } + + # Case 7: the item cmdlets in a folder that the delegated group fully controls. + $itemsPath = New-FixtureFolder -RelativePath 'Case7\Items' -AccessRule $delegatesFullControl + foreach ($name in 'Source', 'Move', 'Remove') { + Set-Content -LiteralPath (Join-Path -Path $itemsPath -ChildPath "$name.txt") -Value $name -NoNewline + } + + $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 + + # 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 + $null = New-Item -ItemType Directory -Path (Join-Path -Path $linksPath -ChildPath 'TargetFolder') + + # Case 9: a subfolder with an entry of its own for Get-NTFSSimpleAccess, and the accounts of other domains. + $null = New-FixtureFolder -RelativePath 'Case9\Simple' -AccessRule $delegatesFullControl + $null = New-FixtureFolder -RelativePath 'Case9\Simple\Child' -AccessRule (New-AccessRule -Sid $Sid.Subject -Rights 'Modify') + $foreignPath = New-FixtureFolder -RelativePath 'Case9\Foreign' -AccessRule @( + foreach ($account in $ForeignAccount) { + New-AccessRule -Sid $account.Sid -Rights $account.Rights + } + ) + $null = New-FixtureFolder -RelativePath 'Case9\ForeignAdd' -AccessRule $delegatesFullControl + $null = New-FixtureFolder -RelativePath 'Case9\ForeignRemove' -AccessRule @( + foreach ($account in $ForeignAccount) { + New-AccessRule -Sid $account.Sid -Rights 'ReadAndExecute' + } + ) + + # The rights that the file server's own token of each foreign account gets on the folder, like case 3. A token + # that the file server can't create is reported as -1, which fails only the effective-access test of the account. + $foreignDescriptor = Get-LabSecurityDescriptor -Path $foreignPath + $foreignEffectiveRights = @{} + foreach ($account in $ForeignAccount) { + try { + $foreignEffectiveRights[$account.Sid] = Get-LabGrantedRight -Descriptor $foreignDescriptor -Sid @(Get-LabTokenSid -UserPrincipalName $account.UserPrincipalName) + } + catch { + $foreignEffectiveRights[$account.Sid] = -1L + } + } + [pscustomobject]@{ - ServerPath = $root - FileServerRights = $fileServerRights - EffectiveAccessSddl = $effectiveDescriptor.GetSddlForm('All') - LongPath = $longRelativePath + ServerPath = $root + FileServerRights = $fileServerRights + EffectiveAccessSddl = $effectiveDescriptor.GetSddlForm('All') + LongPath = $longRelativePath + ForeignEffectiveRights = $foreignEffectiveRights } } @@ -778,6 +901,21 @@ if (@($machines | ForEach-Object -Process { $_.DomainName } | Select-Object -Uni throw 'The domain controller, the file server, and the client must belong to one domain.' } +foreach ($name in $ForeignDomainController) { + $machine = Get-LabVM -ComputerName $name + if (-not $machine) { + throw "The lab '$LabName' has no machine '$name'." + } + + if ($machine.DomainName -eq $machines[0].DomainName) { + throw "The foreign domain controller '$name' must belong to another domain than the file server." + } +} + +if (@($ForeignDomainController | ForEach-Object -Process { (Get-LabVM -ComputerName $_).DomainName } | Select-Object -Unique).Count -ne @($ForeignDomainController).Count) { + throw 'Each foreign domain controller must belong to a domain of its own.' +} + $helperScript = Get-Content -LiteralPath (Join-Path -Path $PSScriptRoot -ChildPath 'NTFSSecurity.LabHelpers.ps1') -Raw $labCommand = @{ NoDisplay = $true @@ -794,6 +932,10 @@ if ($RemoveFixture) { $null = Invoke-LabCommand -ComputerName $FileServer -ActivityName 'Remove the share and the folders' -ScriptBlock $removeFileServerScript -ArgumentList $shareName, $shareLocalPath, $payloadPath, $localGroupName, $accountSids @labCommand $null = Invoke-LabCommand -ComputerName $Client -ActivityName 'Remove the members and the folder' -ScriptBlock $removeClientScript -ArgumentList $payloadPath, $accountSids @labCommand $null = Invoke-LabCommand -ComputerName $DomainController -ActivityName 'Remove the accounts' -ScriptBlock $removeAccountScript -ArgumentList $organizationalUnitName @labCommand + foreach ($computer in $ForeignDomainController) { + $null = Invoke-LabCommand -ComputerName $computer -ActivityName 'Remove the account of another domain' -ScriptBlock $removeAccountScript -ArgumentList $organizationalUnitName @labCommand + } + Write-LabProgress "Removed the live tests from the lab '$LabName'." return } @@ -837,6 +979,18 @@ foreach ($name in @($roleAccounts.Values) + $subjectAccount) { $directory = Invoke-LabCommand -ComputerName $DomainController -ActivityName 'Prepare the accounts' -ScriptBlock $accountScript -ArgumentList $organizationalUnitName, $passwords, $groupMembers @labCommand $sids = $directory.Sids +$foreignAccounts = @( + for ($index = 0; $index -lt @($ForeignDomainController).Count; $index++) { + $account = Invoke-LabCommand -ComputerName $ForeignDomainController[$index] -ActivityName 'Prepare the account of another domain' -ScriptBlock $foreignAccountScript -ArgumentList $organizationalUnitName, $foreignAccount, (New-LabPassword) @labCommand + [pscustomobject]@{ + DomainName = $account.DomainName + Name = $account.Name + UserPrincipalName = $account.UserPrincipalName + Sid = $account.Sid + Rights = $foreignRights[$index % $foreignRights.Count] + } + } +) $localGroupSid = Invoke-LabCommand -ComputerName $FileServer -ActivityName 'Prepare the file server' -ScriptBlock $fileServerSetupScript -ArgumentList $shareName, $shareLocalPath, $payloadPath, $localGroupName, $sids[$subjectAccount], @($sids['NtfsLiveServerAdmin'], $sids['NtfsLiveAdmin']), $sids['NtfsLiveDelegates'] @labCommand $null = Invoke-LabCommand -ComputerName $Client -ActivityName 'Prepare the client' -ScriptBlock $clientSetupScript -ArgumentList $payloadPath, @($sids['NtfsLiveDelegate'], $sids['NtfsLiveAdmin']), @($sids['NtfsLiveServerAdmin']) @labCommand foreach ($computer in $Client, $FileServer) { @@ -880,8 +1034,9 @@ foreach ($module in $modules) { NtfsLiveOuter = $sids['NtfsLiveOuter'] LocalGroup = [string]$localGroupSid Orphan = $orphan.Sid + Subject = $sids[$subjectAccount] } - $fixture = Invoke-LabCommand -ComputerName $FileServer -ActivityName 'Create the folders of the run' -ScriptBlock $fixtureScript -ArgumentList $helperScript, $shareLocalPath, $runId, $fixtureSids, $subjectPrincipalName, $longPathSegments @labCommand + $fixture = Invoke-LabCommand -ComputerName $FileServer -ActivityName 'Create the folders of the run' -ScriptBlock $fixtureScript -ArgumentList $helperScript, $shareLocalPath, $runId, $fixtureSids, $subjectPrincipalName, $longPathSegments, $foreignAccounts @labCommand $null = Invoke-LabCommand -ComputerName $DomainController -ActivityName 'Delete the orphan account' -ScriptBlock $removeOrphanScript -ArgumentList $orphan.Guid @labCommand $oracle = Invoke-LabCommand -ComputerName $Client -ActivityName 'Calculate the rights on the client' -ScriptBlock $clientOracleScript -ArgumentList $helperScript, $fixture.EffectiveAccessSddl, $subjectPrincipalName, $orphan.Sid @labCommand if ($oracle.OrphanResolved) { @@ -911,6 +1066,17 @@ foreach ($module in $modules) { FileServerRights = [long]$fixture.FileServerRights ClientRights = [long]$oracle.ClientRights } + ForeignAccounts = @( + foreach ($account in $foreignAccounts) { + [ordered]@{ + Name = $account.Name + DomainName = $account.DomainName + Sid = $account.Sid + Rights = $account.Rights + EffectiveRights = [long]$fixture.ForeignEffectiveRights[$account.Sid] + } + } + ) Accounts = [ordered]@{ Delegate = [ordered]@{ Name = $credentials['Delegate'].UserName; Sid = $sids['NtfsLiveDelegate']; ClientAdministrator = $true; FileServerAdministrator = $false } ServerAdmin = [ordered]@{ Name = $credentials['ServerAdmin'].UserName; Sid = $sids['NtfsLiveServerAdmin']; ClientAdministrator = $false; FileServerAdministrator = $true } diff --git a/Tests/Lab/NTFSSecurity.Live.Tests.ps1 b/Tests/Lab/NTFSSecurity.Live.Tests.ps1 index 7037d7e..e8eae11 100644 --- a/Tests/Lab/NTFSSecurity.Live.Tests.ps1 +++ b/Tests/Lab/NTFSSecurity.Live.Tests.ps1 @@ -61,6 +61,49 @@ BeforeDiscovery { @{ Folder = "Case2\$auditRole\RemoveAudit"; Count = [int](-not $mayWrite) } } ) + + # Case 5: only the administrators of the file server hold the Restore privilege there, which assigning an owner + # other than the account itself needs. + $ownerCases = @( + @{ OwnerRole = 'Admin'; Description = 'an administrator of the file server and the client'; MayAssign = $true } + @{ OwnerRole = 'ServerAdmin'; Description = 'an administrator of the file server only'; MayAssign = $true } + @{ OwnerRole = 'Delegate'; Description = 'the delegated account, an administrator of the client only'; MayAssign = $false } + ) + $ownerExpectations = @( + foreach ($ownerCase in $ownerCases) { + @{ Folder = "Case5\$($ownerCase.OwnerRole)\GetOwner"; Owner = 'Administrators' } + @{ Folder = "Case5\$($ownerCase.OwnerRole)\TakeOwnership"; Owner = $ownerCase.OwnerRole } + @{ Folder = "Case5\$($ownerCase.OwnerRole)\AssignOwner"; Owner = if ($ownerCase.MayAssign) { 'Subject' } else { 'Administrators' } } + } + ) + + # Case 6: the state of the SACL that each folder has after the runs. The folders inherit one audit entry; the + # administrators of the file server change them, the delegated account changes nothing. + $auditInheritanceExpectations = @( + foreach ($auditRole in 'Admin', 'ServerAdmin', 'Delegate') { + $mayWrite = $auditRole -ne 'Delegate' + @{ Folder = "Case6\$auditRole\DisableAuditInheritance"; Protected = $mayWrite; Explicit = [int]$mayWrite; Inherited = [int](-not $mayWrite) } + @{ Folder = "Case6\$auditRole\EnableAuditInheritance"; Protected = -not $mayWrite; Explicit = 0; Inherited = [int]$mayWrite } + @{ Folder = "Case6\$auditRole\ClearAudit"; Protected = $false; Explicit = [int](-not $mayWrite); Inherited = 1 } + @{ Folder = "Case6\$auditRole\GetInheritance"; Protected = $false; Explicit = 0; Inherited = 1 } + } + ) + $ownedFolders += @( + foreach ($auditRole in 'Admin', 'ServerAdmin', 'Delegate') { + foreach ($operation in 'DisableAuditInheritance', 'EnableAuditInheritance', 'ClearAudit', 'GetInheritance') { + @{ Folder = "Case6\$auditRole\$operation" } + } + } + ) + + # Case 9: the accounts of other domains and forests that the script created, if any. + $foreignAccounts = @( + if ($configured) { + foreach ($account in (Get-Content -LiteralPath $ConfigurationPath -Raw | ConvertFrom-Json).ForeignAccounts) { + @{ Name = $account.Name; Sid = $account.Sid; Rights = $account.Rights; EffectiveRights = [long]$account.EffectiveRights } + } + } + ) } BeforeAll { @@ -377,6 +420,23 @@ Describe 'Get-NTFSEffectiveAccess for a domain account on a share folder' -Tag ' } } +Describe 'Get-NTFSEffectiveAccess as an account that is not an administrator of the file server' -Tag 'Delegate' -Skip:(-not $configured) { + # The cmdlet page: the authorization manager of a computer answers only its administrators and the members of its + # group Access Control Assistance Operators. The error must name the denial; no access instead of an error would be + # a wrong result. + It 'Should write a GetEffectiveAccessError that names the denial with -ServerName, and no result' { + $path = Get-LabPath -RelativePath 'Case5\Delegate\GetOwner' + + $result = @(Get-NTFSEffectiveAccess -Path $path -Account $configuration.Accounts.Delegate.Sid -ServerName $configuration.FileServerFqdn -WarningVariable operationWarnings -WarningAction SilentlyContinue -ErrorVariable operationErrors -ErrorAction SilentlyContinue) + + $result | Should -BeNullOrEmpty + $operationWarnings | Should -BeNullOrEmpty + @(Format-LabError -ErrorRecord $operationErrors) | Should -HaveCount 1 + $operationErrors[0].FullyQualifiedErrorId | Should -BeLike 'GetEffectiveAccessError,*' + $operationErrors[0].Exception.InnerException.NativeErrorCode | Should -Be 5 + } +} + Describe 'Get-NTFSOrphanedAccess with the entry of a deleted domain account on a share folder' -Tag 'Admin' -Skip:(-not $configured) { BeforeAll { $folder = Get-LabPath -RelativePath 'Case4\OrphanedAccess' @@ -455,6 +515,324 @@ Describe 'Copy-Item2 and Move-Item2 with -WhatIf onto an existing file on a shar } } +Describe 'Owner cmdlets on share folders' -Skip:(-not $configured) { + # The file server decides: taking ownership needs the Take Ownership right, which Full Control includes, and + # assigning another account needs the Restore privilege there. The folders start owned by Administrators. + foreach ($ownerCase in $ownerCases) { + Context 'As ' -Tag $ownerCase.OwnerRole -ForEach @($ownerCase) { + BeforeAll { + $folder = Get-LabPath -RelativePath "Case5\$OwnerRole" + $accountSid = $configuration.Accounts.$OwnerRole.Sid + } + + It 'Get-NTFSOwner should return Administrators' { + $owners = @(Get-NTFSOwner -Path (Join-Path -Path $folder -ChildPath 'GetOwner') -ErrorVariable operationErrors -ErrorAction SilentlyContinue) + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + $owners | Should -HaveCount 1 + $owners[0].Owner.Sid | Should -Be $administrators + } + + It 'Set-NTFSOwner should make the account itself the owner' { + $path = Join-Path -Path $folder -ChildPath 'TakeOwnership' + + Set-NTFSOwner -Path $path -Account $accountSid -ErrorVariable operationErrors -ErrorAction SilentlyContinue + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + Get-LabOwner -Path $path | Should -Be $accountSid + } + + It 'Set-NTFSOwner should assign another account only with the Restore privilege of the file server' { + $path = Join-Path -Path $folder -ChildPath 'AssignOwner' + + Set-NTFSOwner -Path $path -Account $configuration.Accounts.Subject.Sid -ErrorVariable operationErrors -ErrorAction SilentlyContinue + + $written = (Format-LabError -ErrorRecord $operationErrors) -join ' | ' + if ($MayAssign) { + $written | Should -BeNullOrEmpty + Get-LabOwner -Path $path | Should -Be $configuration.Accounts.Subject.Sid + } + else { + @(Format-LabError -ErrorRecord $operationErrors) | Should -HaveCount 1 -Because "the cmdlet wrote: $written" + $operationErrors[0].FullyQualifiedErrorId | Should -BeLike 'SetOwnerError,*' + Get-LabOwner -Path $path | Should -Be $administrators + } + } + } + } +} + +Describe 'Audit inheritance cmdlets and Clear-NTFSAudit on share folders' -Skip:(-not $configured) { + # The subfolders of Case6\ inherit one audit entry; the role Server checks the SACLs that the runs left. + foreach ($auditCase in $auditSuccessCases) { + Context 'As ' -Tag $auditCase.AuditRole -ForEach @($auditCase) { + BeforeAll { + $folder = Get-LabPath -RelativePath "Case6\$AuditRole" + } + + It 'Disable-NTFSAuditInheritance should protect the audit entries and keep the owner' { + $path = Join-Path -Path $folder -ChildPath 'DisableAuditInheritance' + + Disable-NTFSAuditInheritance -Path $path -ErrorVariable operationErrors -ErrorAction SilentlyContinue + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + Get-LabOwner -Path $path | Should -Be $administrators + (Get-NTFSInheritance -Path $path).AuditInheritanceEnabled | Should -BeFalse + } + + It 'Enable-NTFSAuditInheritance should let the folder inherit the audit entries and keep the owner' { + $path = Join-Path -Path $folder -ChildPath 'EnableAuditInheritance' + + Enable-NTFSAuditInheritance -Path $path -ErrorVariable operationErrors -ErrorAction SilentlyContinue + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + Get-LabOwner -Path $path | Should -Be $administrators + (Get-NTFSInheritance -Path $path).AuditInheritanceEnabled | Should -BeTrue + } + + It 'Clear-NTFSAudit should remove the explicit audit entry, keep the inherited one, and keep the owner' { + $path = Join-Path -Path $folder -ChildPath 'ClearAudit' + + Clear-NTFSAudit -Path $path -ErrorVariable operationErrors -ErrorAction SilentlyContinue + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + Get-LabOwner -Path $path | Should -Be $administrators + @(Get-NTFSAudit -Path $path -ExcludeInherited) | Should -BeNullOrEmpty + @(Get-NTFSAudit -Path $path -ExcludeExplicit) | Should -HaveCount 1 + } + + It 'Get-NTFSInheritance should report the protected DACL and the inherited audit entries' { + $states = @(Get-NTFSInheritance -Path (Join-Path -Path $folder -ChildPath 'GetInheritance') -ErrorVariable operationErrors -ErrorAction SilentlyContinue) + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + $states | Should -HaveCount 1 + $states[0].AccessInheritanceEnabled | Should -BeFalse + $states[0].AuditInheritanceEnabled | Should -BeTrue + } + } + } + + Context 'As the delegated account, an administrator of the client only' -Tag 'Delegate' { + BeforeAll { + $folder = Get-LabPath -RelativePath 'Case6\Delegate' + } + + It ' should write a that names the missing privilege and leave the folder unchanged' -ForEach @( + @{ Command = 'Disable-NTFSAuditInheritance'; SubFolder = 'DisableAuditInheritance'; ErrorId = 'ModifySdError' } + @{ Command = 'Enable-NTFSAuditInheritance'; SubFolder = 'EnableAuditInheritance'; ErrorId = 'ModifySdError' } + @{ Command = 'Clear-NTFSAudit'; SubFolder = 'ClearAudit'; ErrorId = 'ClearAclError' } + ) { + $path = Join-Path -Path $folder -ChildPath $SubFolder + $before = (Get-LabSecurityDescriptor -Path $path).GetSddlForm('All') + + & $Command -Path $path -ErrorVariable operationErrors -ErrorAction SilentlyContinue + + $written = (Format-LabError -ErrorRecord $operationErrors) -join ' | ' + (Get-LabSecurityDescriptor -Path $path).GetSddlForm('All') | Should -Be $before -Because "the cmdlet wrote: $written" + @(Format-LabError -ErrorRecord $operationErrors) | Should -HaveCount 1 -Because "the cmdlet wrote: $written" + $operationErrors[0].FullyQualifiedErrorId | Should -BeLike "$ErrorId,*" + $operationErrors[0].Exception.Message | Should -Match 'privilege' + } + + # The cmdlet page: without the Security privilege, AuditInheritanceEnabled is $null and no error is written. + It 'Get-NTFSInheritance should report the protected DACL, no audit state, and no error' { + $states = @(Get-NTFSInheritance -Path (Join-Path -Path $folder -ChildPath 'GetInheritance') -ErrorVariable operationErrors -ErrorAction SilentlyContinue) + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + $states | Should -HaveCount 1 + $states[0].AccessInheritanceEnabled | Should -BeFalse + $states[0].AuditInheritanceEnabled | Should -BeNullOrEmpty + } + } +} + +Describe 'Item cmdlets on a share folder' -Tag 'Delegate' -Skip:(-not $configured) { + # The delegated account fully controls the folder through its domain group. + BeforeAll { + $folder = Get-LabPath -RelativePath 'Case7\Items' + $source = Join-Path -Path $folder -ChildPath 'Source.txt' + } + + It 'Get-Item2 should return the file with its path on the share' { + $item = @(Get-Item2 -Path $source -ErrorVariable operationErrors -ErrorAction SilentlyContinue) + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + $item | Should -HaveCount 1 + $item[0].FullName | Should -Be $source + $item[0].Length | Should -Be 6 + } + + It 'Test-Path2 should find the file and the folder, and not a missing item' { + Test-Path2 -Path $source -PathType Leaf | Should -BeTrue + Test-Path2 -Path (Join-Path -Path $folder -ChildPath 'Folder') -PathType Container | Should -BeTrue + Test-Path2 -Path (Join-Path -Path $folder -ChildPath 'Missing.txt') | Should -BeFalse + } + + It 'Get-FileHash2 should return the hash that Get-FileHash returns' { + $hash = @(Get-FileHash2 -Path $source -Algorithm SHA256 -ErrorVariable operationErrors -ErrorAction SilentlyContinue) + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + $hash | Should -HaveCount 1 + $hash[0].Hash | Should -Be (Get-FileHash -LiteralPath $source -Algorithm SHA256).Hash + } + + It 'Copy-Item2 should copy a file, and a folder with its file' { + Copy-Item2 -Path $source -Destination (Join-Path -Path $folder -ChildPath 'Copy.txt') -ErrorVariable operationErrors -ErrorAction SilentlyContinue + Copy-Item2 -Path (Join-Path -Path $folder -ChildPath 'Folder') -Destination (Join-Path -Path $folder -ChildPath 'FolderCopy') -ErrorVariable +operationErrors -ErrorAction SilentlyContinue + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + Get-Content -LiteralPath (Join-Path -Path $folder -ChildPath 'Copy.txt') -Raw | Should -Be 'Source' + Get-Content -LiteralPath (Join-Path -Path $folder -ChildPath 'FolderCopy\File.txt') -Raw | Should -Be 'File' + Get-Content -LiteralPath $source -Raw | Should -Be 'Source' + } + + It 'Move-Item2 should move a file' { + $moving = Join-Path -Path $folder -ChildPath 'Move.txt' + + Move-Item2 -Path $moving -Destination (Join-Path -Path $folder -ChildPath 'Moved.txt') -ErrorVariable operationErrors -ErrorAction SilentlyContinue + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + Test-Path -LiteralPath $moving | Should -BeFalse + Get-Content -LiteralPath (Join-Path -Path $folder -ChildPath 'Moved.txt') -Raw | Should -Be 'Move' + } + + It 'Remove-Item2 should remove a file' { + $removing = Join-Path -Path $folder -ChildPath 'Remove.txt' + + Remove-Item2 -Path $removing -ErrorVariable operationErrors -ErrorAction SilentlyContinue + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + Test-Path -LiteralPath $removing | Should -BeFalse + } + + 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 + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + $names | Should -Contain 'Source.txt' + $names | Should -Contain 'Folder' + } +} + +Describe 'Link cmdlets on a share folder' -Tag 'Admin' -Skip:(-not $configured) { + BeforeAll { + $folder = Get-LabPath -RelativePath 'Case8\Links' + $target = Join-Path -Path $folder -ChildPath 'Target.txt' + $hardLink = Join-Path -Path $folder -ChildPath 'HardLink.txt' + } + + It 'New-NTFSHardLink should give the file a second name' { + New-NTFSHardLink -Path $hardLink -Target $target -ErrorVariable operationErrors -ErrorAction SilentlyContinue + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + Get-Content -LiteralPath $hardLink -Raw | Should -Be 'Target' + } + + # The cmdlet pages: Windows can't list the names of a file on a share, so the cmdlets write a GetHardLinkError. + It 'Get-NTFSHardLink should write a GetHardLinkError, because Windows cannot list the names on a share' { + $links = @(Get-NTFSHardLink -Path $target -ErrorVariable operationErrors -ErrorAction SilentlyContinue) + + $links | Should -BeNullOrEmpty + @(Format-LabError -ErrorRecord $operationErrors) | Should -HaveCount 1 + $operationErrors[0].FullyQualifiedErrorId | Should -BeLike 'GetHardLinkError,*' + } + + It 'New-NTFSHardLink -PassThru should create the link and write a GetHardLinkError instead of the names' { + $passThruLink = Join-Path -Path $folder -ChildPath 'PassThruLink.txt' + + $result = @(New-NTFSHardLink -Path $passThruLink -Target $target -PassThru -ErrorVariable operationErrors -ErrorAction SilentlyContinue) + + $result | Should -BeNullOrEmpty + @(Format-LabError -ErrorRecord $operationErrors) | Should -HaveCount 1 + $operationErrors[0].FullyQualifiedErrorId | Should -BeLike 'GetHardLinkError,*' + Get-Content -LiteralPath $passThruLink -Raw | Should -Be 'Target' + } + + It 'New-NTFSSymbolicLink should create a link to a file and a link to a folder' { + New-NTFSSymbolicLink -Path (Join-Path -Path $folder -ChildPath 'FileLink.txt') -Target $target -ErrorVariable operationErrors -ErrorAction SilentlyContinue + New-NTFSSymbolicLink -Path (Join-Path -Path $folder -ChildPath 'FolderLink') -Target (Join-Path -Path $folder -ChildPath 'TargetFolder') -ErrorVariable +operationErrors -ErrorAction SilentlyContinue + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + } +} + +Describe 'Get-NTFSSimpleAccess on share folders' -Tag 'Delegate' -Skip:(-not $configured) { + It 'Should report the parent folder first and for the subfolder only the entry of its own' { + $parent = Get-LabPath -RelativePath 'Case9\Simple' + $child = Join-Path -Path $parent -ChildPath 'Child' + + $entries = @(Get-NTFSSimpleAccess -Path $child -ErrorVariable operationErrors -ErrorAction SilentlyContinue) + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + $entries | Should -Not -BeNullOrEmpty + $entries[0].FullName | Should -Be $parent + @($entries | Where-Object -Property FullName -EQ -Value $child).Identity.Sid | Should -Be $configuration.Accounts.Subject.Sid + } +} + +Describe 'Get-NTFSOrphanedAudit with the audit entry of a deleted domain account on a share folder' -Tag 'Admin' -Skip:(-not $configured) { + It 'Should return the audit entry of the deleted account with its SID' { + $entries = @(Get-NTFSOrphanedAudit -Path (Get-LabPath -RelativePath 'Case4\OrphanedAudit') -WarningVariable operationWarnings -WarningAction SilentlyContinue -ErrorVariable operationErrors -ErrorAction SilentlyContinue) + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + $operationWarnings | Should -BeNullOrEmpty + $entries | Should -HaveCount 1 + $entries[0].Account.Sid | Should -Be $configuration.Accounts.Orphan.Sid + } +} + +Describe 'Accounts of another domain and of other forests on share folders' -Tag 'Admin' -Skip:(-not $configured -or $foreignAccounts.Count -eq 0) { + # Through the trusts, the client resolves the names of the accounts, and the file server creates their tokens. + BeforeAll { + $folder = Get-LabPath -RelativePath 'Case9\Foreign' + } + + It 'Get-NTFSAccess should return the entry of with its name' -ForEach $foreignAccounts { + $entries = @(Get-NTFSAccess -Path $folder -ExcludeInherited -ErrorVariable operationErrors -ErrorAction SilentlyContinue | + Where-Object -FilterScript { $_.Account.Sid -eq $Sid }) + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + $entries | Should -HaveCount 1 + $entries[0].Account.AccountName | Should -Be $Name + } + + It 'Get-NTFSOrphanedAccess should not report the entries of the accounts' { + $entries = @(Get-NTFSOrphanedAccess -Path $folder -ErrorVariable operationErrors -ErrorAction SilentlyContinue) + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + $entries | Should -BeNullOrEmpty + } + + It 'Add-NTFSAccess should add an entry for by its name' -ForEach $foreignAccounts { + $path = Get-LabPath -RelativePath 'Case9\ForeignAdd' + + Add-NTFSAccess -Path $path -Account $Name -AccessRights ReadAndExecute -ErrorVariable operationErrors -ErrorAction SilentlyContinue + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + @(Get-LabExplicitAccessRule -Path $path -Sid $Sid) | Should -HaveCount 1 + } + + It 'Remove-NTFSAccess should remove the entry of by its name' -ForEach $foreignAccounts { + $path = Get-LabPath -RelativePath 'Case9\ForeignRemove' + + Remove-NTFSAccess -Path $path -Account $Name -AccessRights ReadAndExecute -InheritanceFlags 'ContainerInherit, ObjectInherit' -PropagationFlags None -ErrorVariable operationErrors -ErrorAction SilentlyContinue + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + Get-LabExplicitAccessRule -Path $path -Sid $Sid | Should -BeNullOrEmpty + } + + It 'Get-NTFSEffectiveAccess should return the rights of on the file server with -ServerName, without a warning' -ForEach $foreignAccounts { + $EffectiveRights | Should -BeGreaterThan 0 -Because 'the file server must create a token for the account to calculate the expected rights' + + $result = @(Get-NTFSEffectiveAccess -Path $folder -Account $Name -ServerName $configuration.FileServerFqdn -WarningVariable operationWarnings -WarningAction SilentlyContinue -ErrorVariable operationErrors -ErrorAction SilentlyContinue) + + Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty + $operationWarnings | Should -BeNullOrEmpty + $result | Should -HaveCount 1 + Format-LabRight -Right $result[0].AccessRights | Should -Be (Format-LabRight -Right $EffectiveRights) + } +} + Describe 'Security descriptors on the file server after the runs on the client' -Tag 'Server' -Skip:(-not $configured) { It 'Should keep Administrators as the owner of ' -ForEach $ownedFolders { Get-LabOwner -Path (Get-LabPath -RelativePath $Folder) | Should -Be $administrators @@ -465,4 +843,43 @@ Describe 'Security descriptors on the file server after the runs on the client' @($acl.GetAuditRules($true, $false, $sidType)).Count | Should -Be $Count } + + It 'Should have the owner on ' -ForEach $ownerExpectations { + $expected = if ($Owner -eq 'Administrators') { $administrators } else { $configuration.Accounts.$Owner.Sid } + + Get-LabOwner -Path (Get-LabPath -RelativePath $Folder) | Should -Be $expected + } + + It 'Should have explicit and inherited audit entries on , protected: ' -ForEach $auditInheritanceExpectations { + $acl = Get-Acl -LiteralPath (Get-LabPath -RelativePath $Folder) -Audit + + $acl.AreAuditRulesProtected | Should -Be $Protected + @($acl.GetAuditRules($true, $false, $sidType)).Count | Should -Be $Explicit + @($acl.GetAuditRules($false, $true, $sidType)).Count | Should -Be $Inherited + } + + It 'Should have the items that the item cmdlets left' { + $folder = Get-LabPath -RelativePath 'Case7\Items' + + foreach ($name in 'Source.txt', 'Copy.txt', 'Moved.txt', 'FolderCopy\File.txt') { + Test-Path -LiteralPath (Join-Path -Path $folder -ChildPath $name) -PathType Leaf | Should -BeTrue -Because $name + } + + foreach ($name in 'Move.txt', 'Remove.txt') { + Test-Path -LiteralPath (Join-Path -Path $folder -ChildPath $name) | Should -BeFalse -Because $name + } + } + + It 'Should have the links that the link cmdlets created' { + $folder = Get-LabPath -RelativePath 'Case8\Links' + + @(& fsutil.exe hardlink list (Join-Path -Path $folder -ChildPath 'Target.txt') | Where-Object -FilterScript { $_ }) | Should -HaveCount 3 + (Get-Item -LiteralPath (Join-Path -Path $folder -ChildPath 'FileLink.txt') -Force).LinkType | Should -Be 'SymbolicLink' + (Get-Item -LiteralPath (Join-Path -Path $folder -ChildPath 'FolderLink') -Force).LinkType | Should -Be 'SymbolicLink' + } + + It 'Should have the entry of that Add-NTFSAccess added, and not the one that Remove-NTFSAccess removed' -ForEach $foreignAccounts { + @(Get-LabExplicitAccessRule -Path (Get-LabPath -RelativePath 'Case9\ForeignAdd') -Sid $Sid) | Should -HaveCount 1 + Get-LabExplicitAccessRule -Path (Get-LabPath -RelativePath 'Case9\ForeignRemove') -Sid $Sid | Should -BeNullOrEmpty + } } diff --git a/Tests/Lab/README.md b/Tests/Lab/README.md index ba0880a..1cca743 100644 --- a/Tests/Lab/README.md +++ b/Tests/Lab/README.md @@ -13,11 +13,16 @@ without a lab they skip every test. | --- | --- | --- | | 1, [#34][issue-34] | Delegate | `Add-NTFSAccess`, `Remove-NTFSAccess`, `Clear-NTFSAccess`, `Disable-NTFSAccessInheritance`, `Enable-NTFSAccessInheritance`, `Set-NTFSInheritance`, and `Set-NTFSSecurityDescriptor` on share folders that Administrators own and on which a domain group has Full Control, run by a member of that group who isn't an administrator of the file server. They succeed and keep the owner. | | 2 | Admin, ServerAdmin, Delegate | `Get-NTFSAudit`, `Add-NTFSAudit`, and `Remove-NTFSAudit` on share folders. Over SMB, the file server checks the Security privilege of the account. The administrators of the file server read and change the audit entries; the delegated account gets the errors that the cmdlet pages describe, and the folders stay unchanged. | -| 3 | Admin | `Get-NTFSEffectiveAccess` for a domain account with rights through two nested domain groups and through a local group of the file server. With `-ServerName`, the result includes the local group, without a warning; without it, the client doesn't know that group. With an unreachable server, the cmdlet falls back to the client and warns. | -| 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. | +| 3 | Admin, Delegate | `Get-NTFSEffectiveAccess` for a domain account with rights through two nested domain groups and through a local group of the file server. With `-ServerName`, the result includes the local group, without a warning; without it, the client doesn't know that group. With an unreachable server, the cmdlet falls back to the client and warns. The authorization manager of the file server refuses the delegated account, which isn't an administrator there, and the cmdlet reports that as an error, not as no access. | +| 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. | +| 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. | | [#108][issue-108] | Admin | `Copy-Item2` and `Move-Item2` with `-WhatIf` onto an existing file on the share write no error. | -| State | Server | After the runs on the client, the file server checks the owners and the audit entries of the folders itself, without the module. | +| State | Server | After the runs on the client, the file server checks the owners, the audit entries, the items, the links, and the entries of the foreign accounts itself, without the module. | Case 1 uses two kinds of folders. Before 5.0.0-rc3, the cmdlets wrote back the owner that Windows returns with a DACL without the auto-inherit flag, and the @@ -29,7 +34,8 @@ Windows 2000. `Set-NTFSSecurityDescriptor` wrote the owner on both kinds. The expected rights of case 3 come from the tokens that the file server and the client create for the account with a Kerberos S4U logon, the way the Effective -Access tab of the advanced security settings does. +Access tab of the advanced security settings does. Case 9 calculates the rights +of the foreign accounts the same way, on the file server. ## Roles @@ -43,7 +49,8 @@ Access tab of the advanced security settings does. The script also creates `NtfsLiveSubject`, the account of case 3, which is a member of `NtfsLiveInner`, a member of `NtfsLiveOuter`, and of the local group `NtfsLiveLocal` of the file server, and `NtfsLiveOrphan`, which it deletes in -every run. +every run. For case 9, it creates `NtfsLiveForeign` in the organizational unit +`NTFSSecurityLive` of each domain of `-ForeignDomainController`. ## Lab @@ -55,11 +62,15 @@ use the lab of `tests/Lab/Deploy-WindowsAccessControlLab.ps1` in that repository deploys: `F1ADC1` as domain controller, `F1AFile2` as file server, and `F1AFile1` as client, all in `a.forest1.net`. `-DomainController`, `-FileServer`, and -`-Client` select other machines. +`-Client` select other machines. Case 9 uses `F1BDC1` of `b.forest1.net`, a +domain of the same forest, and `F2DC1` and `F3DC1` of the forests +`forest2.net` and `forest3.net`, which have forest trusts with `forest1.net`; +`-ForeignDomainController @()` leaves it out. The script adds to the lab: -- the organizational unit `NTFSSecurityLive` with the accounts and groups +- the organizational unit `NTFSSecurityLive` with the accounts and groups, and + with `NtfsLiveForeign` in the domains of the foreign domain controllers - the local group `NtfsLiveLocal` and members of Administrators on the file server, and members of Administrators and Remote Management Users on the client From e2b6b24b197486aba3aa9a82bb12f0d26120760e Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Thu, 8 Oct 2026 10:46:32 +0000 Subject: [PATCH 24/33] docs: document who may use Get-NTFSEffectiveAccess -ServerName The authorization manager of a computer answers only its administrators and the members of its group Access Control Assistance Operators (S-1-5-32-579); any other account gets "Access is denied" and no result. A probe in the lab confirmed it on 2026-10-08: the delegated account got error 5 without the membership and its rights with it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- CHANGELOG.md | 4 ++++ Docs/Cmdlets/Get-NTFSEffectiveAccess.md | 4 ++-- NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml | 8 ++++---- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33a14d6..44d1689 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,10 @@ The format is based on also an unchanged owner, which failed with error 1307 where the account may not assign that owner ([#34](https://github.com/raandree/NTFSSecurity/issues/34)) +- Document that `Get-NTFSEffectiveAccess -ServerName` works only for the + administrators of the named computer and the members of its group Access + Control Assistance Operators; any other account gets the error "Access is + denied" and no result ### Deprecated diff --git a/Docs/Cmdlets/Get-NTFSEffectiveAccess.md b/Docs/Cmdlets/Get-NTFSEffectiveAccess.md index 63ce34f..c90652c 100644 --- a/Docs/Cmdlets/Get-NTFSEffectiveAccess.md +++ b/Docs/Cmdlets/Get-NTFSEffectiveAccess.md @@ -31,7 +31,7 @@ Calculates the rights an account really has on a file or a folder and writes the The calculation covers the NTFS permissions of the item only. Share permissions are stored in a separate security descriptor and are not part of the result, so access over a network share can be more restrictive than this cmdlet reports. -When `-Account` is omitted, the account that runs the session is used. `-ServerName` selects the computer whose authorization manager resolves the group memberships of the account and defaults to `localhost`; when the remote authorization manager of the named computer cannot be reached, the cmdlet falls back to the local one and warns that the result is based on the group memberships known on this computer and may be inaccurate. Reading effective access relies on the Security privilege, and the cmdlet warns when the account does not hold it or the privilege is disabled. +When `-Account` is omitted, the account that runs the session is used. `-ServerName` selects the computer whose authorization manager resolves the group memberships of the account and defaults to `localhost`; when the remote authorization manager of the named computer cannot be reached, the cmdlet falls back to the local one and warns that the result is based on the group memberships known on this computer and may be inaccurate. The authorization manager of the named computer answers only the administrators of that computer and the members of its local group Access Control Assistance Operators; for any other account, the cmdlet writes an error and doesn't fall back. Reading effective access relies on the Security privilege, and the cmdlet warns when the account does not hold it or the privilege is disabled. When `-Path` is omitted, the cmdlet calculates the effective access to the current location. In the `SecurityDescriptor` parameter set, it calculates the effective access from a `Security2.FileSystemSecurity2` object that `Get-NTFSSecurityDescriptor` returned, without reading the item again. @@ -139,7 +139,7 @@ Accept wildcard characters: False ### -ServerName -Specifies the computer whose authorization manager resolves the group memberships of the account. The default is `localhost`. Name the computer that stores the item when you query a network path, because the group memberships known there determine the result; if that computer cannot be reached, the cmdlet falls back to the local authorization manager and warns that the result may be inaccurate. +Specifies the computer whose authorization manager resolves the group memberships of the account. The default is `localhost`. Name the computer that stores the item when you query a network path, because the group memberships known there determine the result; if that computer cannot be reached, the cmdlet falls back to the local authorization manager and warns that the result may be inaccurate. The authorization manager of a computer answers only its administrators and the members of its local group Access Control Assistance Operators; for any other account, the cmdlet writes a non-terminating `GetEffectiveAccessError` with the message "Access is denied" and returns no result for the item. ```yaml Type: String diff --git a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml index 9caecdc..07e64ed 100644 --- a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml +++ b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml @@ -4943,7 +4943,7 @@ PS C:\> Get-NTFSAudit -SecurityDescriptor $sd Calculates the rights an account really has on a file or a folder and writes the result as a single `Security2.FileSystemAccessRule2` object per item. The cmdlet evaluates the complete discretionary access control list (DACL) of the item against the group memberships of the account with the Windows Authorization API, so allow entries, deny entries, and inherited entries are combined the same way the Windows access check combines them. This is the equivalent of the "Effective Access" tab of the advanced security dialog. The calculation covers the NTFS permissions of the item only. Share permissions are stored in a separate security descriptor and are not part of the result, so access over a network share can be more restrictive than this cmdlet reports. - When `-Account` is omitted, the account that runs the session is used. `-ServerName` selects the computer whose authorization manager resolves the group memberships of the account and defaults to `localhost`; when the remote authorization manager of the named computer cannot be reached, the cmdlet falls back to the local one and warns that the result is based on the group memberships known on this computer and may be inaccurate. Reading effective access relies on the Security privilege, and the cmdlet warns when the account does not hold it or the privilege is disabled. + When `-Account` is omitted, the account that runs the session is used. `-ServerName` selects the computer whose authorization manager resolves the group memberships of the account and defaults to `localhost`; when the remote authorization manager of the named computer cannot be reached, the cmdlet falls back to the local one and warns that the result is based on the group memberships known on this computer and may be inaccurate. The authorization manager of the named computer answers only the administrators of that computer and the members of its local group Access Control Assistance Operators; for any other account, the cmdlet writes an error and doesn't fall back. Reading effective access relies on the Security privilege, and the cmdlet warns when the account does not hold it or the privilege is disabled. When `-Path` is omitted, the cmdlet calculates the effective access to the current location. In the `SecurityDescriptor` parameter set, it calculates the effective access from a `Security2.FileSystemSecurity2` object that `Get-NTFSSecurityDescriptor` returned, without reading the item again. @@ -4987,7 +4987,7 @@ PS C:\> Get-NTFSAudit -SecurityDescriptor $sd ServerName - Specifies the computer whose authorization manager resolves the group memberships of the account. The default is `localhost`. Name the computer that stores the item when you query a network path, because the group memberships known there determine the result; if that computer cannot be reached, the cmdlet falls back to the local authorization manager and warns that the result may be inaccurate. + Specifies the computer whose authorization manager resolves the group memberships of the account. The default is `localhost`. Name the computer that stores the item when you query a network path, because the group memberships known there determine the result; if that computer cannot be reached, the cmdlet falls back to the local authorization manager and warns that the result may be inaccurate. The authorization manager of a computer answers only its administrators and the members of its local group Access Control Assistance Operators; for any other account, the cmdlet writes a non-terminating `GetEffectiveAccessError` with the message "Access is denied" and returns no result for the item. String @@ -5038,7 +5038,7 @@ PS C:\> Get-NTFSAudit -SecurityDescriptor $sd ServerName - Specifies the computer whose authorization manager resolves the group memberships of the account. The default is `localhost`. Name the computer that stores the item when you query a network path, because the group memberships known there determine the result; if that computer cannot be reached, the cmdlet falls back to the local authorization manager and warns that the result may be inaccurate. + Specifies the computer whose authorization manager resolves the group memberships of the account. The default is `localhost`. Name the computer that stores the item when you query a network path, because the group memberships known there determine the result; if that computer cannot be reached, the cmdlet falls back to the local authorization manager and warns that the result may be inaccurate. The authorization manager of a computer answers only its administrators and the members of its local group Access Control Assistance Operators; for any other account, the cmdlet writes a non-terminating `GetEffectiveAccessError` with the message "Access is denied" and returns no result for the item. String @@ -5102,7 +5102,7 @@ PS C:\> Get-NTFSAudit -SecurityDescriptor $sd ServerName - Specifies the computer whose authorization manager resolves the group memberships of the account. The default is `localhost`. Name the computer that stores the item when you query a network path, because the group memberships known there determine the result; if that computer cannot be reached, the cmdlet falls back to the local authorization manager and warns that the result may be inaccurate. + Specifies the computer whose authorization manager resolves the group memberships of the account. The default is `localhost`. Name the computer that stores the item when you query a network path, because the group memberships known there determine the result; if that computer cannot be reached, the cmdlet falls back to the local authorization manager and warns that the result may be inaccurate. The authorization manager of a computer answers only its administrators and the members of its local group Access Control Assistance Operators; for any other account, the cmdlet writes a non-terminating `GetEffectiveAccessError` with the message "Access is denied" and returns no result for the item. String From 6ed31ce611a75d0ac5c1de808fcb85ee2db999b9 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Thu, 8 Oct 2026 10:50:38 +0000 Subject: [PATCH 25/33] chore(release): prepare 5.0.0-rc6 Set the prerelease label rc6, and add 5.0.0-rc5 to the versions that the PowerShell Gallery already has. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- NTFSSecurity/NTFSSecurity.psd1 | 2 +- Tests/Repository.Tests.ps1 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/NTFSSecurity/NTFSSecurity.psd1 b/NTFSSecurity/NTFSSecurity.psd1 index 74894e3..1681f93 100644 --- a/NTFSSecurity/NTFSSecurity.psd1 +++ b/NTFSSecurity/NTFSSecurity.psd1 @@ -102,7 +102,7 @@ ProjectUri = 'https://github.com/raandree/NTFSSecurity' ReleaseNotes = 'https://github.com/raandree/NTFSSecurity/blob/master/CHANGELOG.md' # Remove the prerelease label for the final release, see Docs/Contributing/05-Releasing.md - Prerelease = 'rc5' + Prerelease = 'rc6' } } } \ No newline at end of file diff --git a/Tests/Repository.Tests.ps1 b/Tests/Repository.Tests.ps1 index 4380bdd..14513cd 100644 --- a/Tests/Repository.Tests.ps1 +++ b/Tests/Repository.Tests.ps1 @@ -97,7 +97,7 @@ Describe 'Release metadata' { # The PowerShell Gallery doesn't accept a version twice. Add every published version to this list # (Docs/Contributing/05-Releasing.md). It 'Should not reuse a version that the PowerShell Gallery already has' { - $publishedVersions = '4.0', '4.2.2', '4.2.3', '4.2.4', '4.2.5', '4.2.6', '5.0.0-rc1', '5.0.0-rc2', '5.0.0-rc3', '5.0.0-rc4' + $publishedVersions = '4.0', '4.2.2', '4.2.3', '4.2.4', '4.2.5', '4.2.6', '5.0.0-rc1', '5.0.0-rc2', '5.0.0-rc3', '5.0.0-rc4', '5.0.0-rc5' $publishedVersions | Should -Not -Contain $version } From acfe3af4cf184e6b12b0bf9b20a8a4d65149b01c Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Thu, 8 Oct 2026 11:05:40 +0000 Subject: [PATCH 26/33] fix: address the review of 5.0.0-rc6 - Copy-Item2 and Move-Item2 name a missing destination folder in a verbose message with -WhatIf, like an existing destination (#108); the operation itself fails with an error that names the folder (finding 1). - Invoke-TestsAsBasicUser.ps1 refuses a title with a double quote or a percent sign, which cmd.exe interprets inside the quoted argument, and fails when waiting for the test process fails (findings 4 and 5). - The page of Get-NTFSSimpleAccess says that ReadData is the right to list a folder (ListDirectory), which the cmdlet reports as Read (finding 10). Checked and kept: a UNC destination on a share that doesn't exist names the share, which a new test pins (finding 3); the lab script refuses machines outside the lab before it changes anything (finding 6). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- .github/scripts/Invoke-TestsAsBasicUser.ps1 | 14 +++++++-- CHANGELOG.md | 5 ++-- Docs/Cmdlets/Copy-Item2.md | 2 +- Docs/Cmdlets/Get-NTFSSimpleAccess.md | 2 +- Docs/Cmdlets/Move-Item2.md | 2 +- NTFSSecurity/BaseCmdlets.cs | 20 +++++++++++-- NTFSSecurity/ItemCmdlets/CopyItem2.cs | 8 +++++ NTFSSecurity/ItemCmdlets/MoveItem2.cs | 8 +++++ NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml | 6 ++-- Tests/ItemCmdlets.Tests.ps1 | 31 ++++++++++++++++++++ Tests/Repository.Tests.ps1 | 20 +++++++++++++ 11 files changed, 105 insertions(+), 13 deletions(-) diff --git a/.github/scripts/Invoke-TestsAsBasicUser.ps1 b/.github/scripts/Invoke-TestsAsBasicUser.ps1 index 34f48f2..93d1d82 100644 --- a/.github/scripts/Invoke-TestsAsBasicUser.ps1 +++ b/.github/scripts/Invoke-TestsAsBasicUser.ps1 @@ -14,7 +14,8 @@ Specifies the path of the result file in the NUnit format. .PARAMETER Title - Specifies the heading of the test results in the job summary. + Specifies the heading of the test results in the job summary. It can't contain a double quote or a percent sign, + which would change the command line of cmd.exe. .EXAMPLE .\.github\scripts\Invoke-TestsAsBasicUser.ps1 -ResultPath TestResults\WindowsPowerShell-BasicUser.xml -Title 'Windows PowerShell 5.1 as a basic user' @@ -30,6 +31,8 @@ param ( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] + # cmd.exe gets the title in a quoted argument and expands environment variables also inside quotes. + [ValidatePattern('^[^"%]+$')] [string] $Title ) @@ -48,6 +51,7 @@ public static class NTFSSecurityBasicUserProcess private const uint SaferLevelOpen = 1; private const uint CreateNoWindow = 0x08000000; private const uint Infinite = 0xFFFFFFFF; + private const uint WaitObject0 = 0; [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] private struct StartupInfo @@ -129,7 +133,11 @@ public static class NTFSSecurityBasicUserProcess try { - WaitForSingleObject(processInformation.hProcess, Infinite); + if (WaitForSingleObject(processInformation.hProcess, Infinite) != WaitObject0) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + uint exitCode; if (!GetExitCodeProcess(processInformation.hProcess, out exitCode)) { @@ -175,7 +183,7 @@ $executable = (Get-Process -Id $PID).Path $testScript = Join-Path -Path $PSScriptRoot -ChildPath 'Invoke-Tests.ps1' # cmd redirects the output of the tests, which have no console of their own. $commandLine = 'cmd.exe /d /s /c ""{0}" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "{1}" -ResultPath "{2}" -Title "{3}" > "{4}" 2>&1"' -f - $executable, $testScript, $runResultPath, $Title.Replace('"', "'"), $logPath + $executable, $testScript, $runResultPath, $Title, $logPath $stepSummary = $env:GITHUB_STEP_SUMMARY $env:GITHUB_STEP_SUMMARY = $summaryPath diff --git a/CHANGELOG.md b/CHANGELOG.md index 44d1689..fd7bdf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -297,8 +297,9 @@ The format is based on an error that named the source item ([#21](https://github.com/raandree/NTFSSecurity/issues/21)); they now write `DestinationFileAlreadyExists` and an error that names the missing - folder. `Copy-Item2` no longer creates the missing folders of the - destination when it copies a folder, which the prereleases of 5.0.0 did + folder, and with `-WhatIf` a verbose message that names it. `Copy-Item2` + no longer creates the missing folders of the destination when it copies a + folder, which the prereleases of 5.0.0 did - Fix `Get-NTFSHardLink`, which stopped for all remaining paths at a folder and at a file on a network share, where Windows can't list the names of a file, and `New-NTFSHardLink -PassThru`, which stopped with a diff --git a/Docs/Cmdlets/Copy-Item2.md b/Docs/Cmdlets/Copy-Item2.md index e272ff4..67879db 100644 --- a/Docs/Cmdlets/Copy-Item2.md +++ b/Docs/Cmdlets/Copy-Item2.md @@ -24,7 +24,7 @@ The `Copy-Item2` cmdlet copies the items in `-Path` to the location in `-Destina How `-Destination` is interpreted depends on what is already there. If the value names an existing folder, the cmdlet keeps the name of the source item and copies it into that folder. In every other case the value is the full path of the new item, which lets you copy and rename in one step. `-Destination` is resolved against the current location once, when the cmdlet starts. -Without `-Force`, the cmdlet checks whether a file or folder already exists at the destination and writes a `DestinationFileAlreadyExists` error instead of overwriting it or merging into it. With `-WhatIf`, it names an existing destination in a verbose message instead; before 5.0.0, it wrote the error also with `-WhatIf`. With `-Force`, an existing file is replaced, and a folder is copied into an existing folder of the same name, replacing the files that exist in both. The folder that is to contain the new item must exist; otherwise the cmdlet writes an error that names that folder. Relative paths and the `.` and `..` notations in `-Path` are resolved against the current location, and wildcard characters are not supported. +Without `-Force`, the cmdlet checks whether a file or folder already exists at the destination and writes a `DestinationFileAlreadyExists` error instead of overwriting it or merging into it. With `-WhatIf`, it names an existing destination in a verbose message instead; before 5.0.0, it wrote the error also with `-WhatIf`. With `-Force`, an existing file is replaced, and a folder is copied into an existing folder of the same name, replacing the files that exist in both. The folder that is to contain the new item must exist; otherwise the cmdlet writes an error that names that folder, or, with `-WhatIf`, a verbose message. Relative paths and the `.` and `..` notations in `-Path` are resolved against the current location, and wildcard characters are not supported. The cmdlet supports `-WhatIf` and `-Confirm`, and it writes nothing to the pipeline unless you specify `-PassThru $true`. diff --git a/Docs/Cmdlets/Get-NTFSSimpleAccess.md b/Docs/Cmdlets/Get-NTFSSimpleAccess.md index a038b81..e852c6f 100644 --- a/Docs/Cmdlets/Get-NTFSSimpleAccess.md +++ b/Docs/Cmdlets/Get-NTFSSimpleAccess.md @@ -27,7 +27,7 @@ Get-NTFSSimpleAccess [-IncludeRootFolder] [-SecurityDescriptor] + /// Returns the folder of a destination path when that folder doesn't exist. + /// + /// The full path of the item that the operation would create. + /// The missing folder, or null when the folder exists or the path has none, such as a share root. + protected string GetMissingDestinationFolder(string destinationPath) + { + var folder = Alphaleonis.Win32.Filesystem.Path.GetDirectoryName(destinationPath.TrimEnd('\\')); + if (string.IsNullOrEmpty(folder) || Alphaleonis.Win32.Filesystem.Directory.Exists(folder)) + { + return null; + } + + return folder; + } + /// /// Writes an error that names the folder of a destination path when that folder doesn't exist. Before /// 5.0.0-rc6, AlphaFS reported such a destination as the source path that could not be found (#21), and @@ -160,8 +176,8 @@ namespace NTFSSecurity /// Whether the folder is missing and the error was written. protected bool WriteMissingDestinationFolderError(string destinationPath, string errorId) { - var folder = Alphaleonis.Win32.Filesystem.Path.GetDirectoryName(destinationPath.TrimEnd('\\')); - if (string.IsNullOrEmpty(folder) || Alphaleonis.Win32.Filesystem.Directory.Exists(folder)) + var folder = GetMissingDestinationFolder(destinationPath); + if (folder == null) { return false; } diff --git a/NTFSSecurity/ItemCmdlets/CopyItem2.cs b/NTFSSecurity/ItemCmdlets/CopyItem2.cs index fdbe5bb..064f888 100644 --- a/NTFSSecurity/ItemCmdlets/CopyItem2.cs +++ b/NTFSSecurity/ItemCmdlets/CopyItem2.cs @@ -98,6 +98,14 @@ namespace NTFSSecurity { WriteVerbose(string.Format("The destination '{0}' already exists; without -Force, the copy would fail", actualDestination)); } + else + { + var missingFolder = GetMissingDestinationFolder(actualDestination); + if (missingFolder != null) + { + WriteVerbose(string.Format("The destination folder '{0}' does not exist; the copy would fail", missingFolder)); + } + } continue; } diff --git a/NTFSSecurity/ItemCmdlets/MoveItem2.cs b/NTFSSecurity/ItemCmdlets/MoveItem2.cs index 52ea1d6..76376e8 100644 --- a/NTFSSecurity/ItemCmdlets/MoveItem2.cs +++ b/NTFSSecurity/ItemCmdlets/MoveItem2.cs @@ -98,6 +98,14 @@ namespace NTFSSecurity { WriteVerbose(string.Format("The destination '{0}' already exists; without -Force, the move would fail", actualDestination)); } + else + { + var missingFolder = GetMissingDestinationFolder(actualDestination); + if (missingFolder != null) + { + WriteVerbose(string.Format("The destination folder '{0}' does not exist; the move would fail", missingFolder)); + } + } continue; } diff --git a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml index 07e64ed..9a59a89 100644 --- a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml +++ b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml @@ -1997,7 +1997,7 @@ PS C:\> Set-NTFSSecurityDescriptor -SecurityDescriptor $sd The `Copy-Item2` cmdlet copies the items in `-Path` to the location in `-Destination`. It is the long-path counterpart of the built-in `Copy-Item` cmdlet: it works through the AlphaFS library (`Alphaleonis.Win32.Filesystem`), so source and destination may be longer than the 260-character `MAX_PATH` limit. How `-Destination` is interpreted depends on what is already there. If the value names an existing folder, the cmdlet keeps the name of the source item and copies it into that folder. In every other case the value is the full path of the new item, which lets you copy and rename in one step. `-Destination` is resolved against the current location once, when the cmdlet starts. - Without `-Force`, the cmdlet checks whether a file or folder already exists at the destination and writes a `DestinationFileAlreadyExists` error instead of overwriting it or merging into it. With `-WhatIf`, it names an existing destination in a verbose message instead; before 5.0.0, it wrote the error also with `-WhatIf`. With `-Force`, an existing file is replaced, and a folder is copied into an existing folder of the same name, replacing the files that exist in both. The folder that is to contain the new item must exist; otherwise the cmdlet writes an error that names that folder. Relative paths and the `.` and `..` notations in `-Path` are resolved against the current location, and wildcard characters are not supported. + Without `-Force`, the cmdlet checks whether a file or folder already exists at the destination and writes a `DestinationFileAlreadyExists` error instead of overwriting it or merging into it. With `-WhatIf`, it names an existing destination in a verbose message instead; before 5.0.0, it wrote the error also with `-WhatIf`. With `-Force`, an existing file is replaced, and a folder is copied into an existing folder of the same name, replacing the files that exist in both. The folder that is to contain the new item must exist; otherwise the cmdlet writes an error that names that folder, or, with `-WhatIf`, a verbose message. Relative paths and the `.` and `..` notations in `-Path` are resolved against the current location, and wildcard characters are not supported. The cmdlet supports `-WhatIf` and `-Confirm`, and it writes nothing to the pipeline unless you specify `-PassThru $true`. @@ -6382,7 +6382,7 @@ PS C:\Data> Get-NTFSSecurityDescriptor - Reads the access control entries of folders and writes them as `Security2.SimpleFileSystemAccessRule` objects whose rights are reduced to the three values `Read`, `Write`, and `Delete`. Reading rights such as `ReadAttributes` or `Traverse` become `Read`, changing rights such as `CreateFiles`, `WriteAttributes`, `ChangePermissions`, or `TakeOwnership` become `Write`, and `Delete` and `DeleteSubdirectoriesAndFiles` become `Delete`; `FullControl` becomes all three. The result answers who may read, change, or delete in a folder without the detail of the full ACL. + Reads the access control entries of folders and writes them as `Security2.SimpleFileSystemAccessRule` objects whose rights are reduced to the three values `Read`, `Write`, and `Delete`. Reading rights such as `ReadData`, which on a folder is the right to list it (`ListDirectory`), `ReadAttributes`, or `Traverse` become `Read`, changing rights such as `CreateFiles`, `WriteAttributes`, `ChangePermissions`, or `TakeOwnership` become `Write`, and `Delete` and `DeleteSubdirectoriesAndFiles` become `Delete`; `FullControl` becomes all three. The result answers who may read, change, or delete in a folder without the detail of the full ACL. The second simplification is that repetitions are left out. The first folder the cmdlet processes is reported with all of its entries, and for every folder that follows only the entries are reported that its parent folder does not already cover. An entry is covered when the parent has an entry for the same account and access type that includes at least the same simple rights. This makes a recursive listing show where permissions actually change instead of repeating the inherited ones on every level, and it requires the parent folder to be processed before its children, which `Get-ChildItem`, `Get-ChildItem2`, and `Get-Item2` do by default. `-IncludeRootFolder` is on by default and adds the parent folder of the first path as the baseline for the comparison, which is why the first result usually belongs to the folder above the one that was asked for. Use `-IncludeRootFolder:$false` to start the comparison at the first path itself. The cmdlet only processes folders; a path that points to a file is skipped silently, while the security descriptor of a file is reported. Relative paths are resolved against the current location, and the current location is used when `-Path` is omitted. `-ExcludeInherited`, `-ExcludeExplicit`, and `-Account` work as in `Get-NTFSAccess`. With `-SecurityDescriptor`, the cmdlet reports the entries of a `Security2.FileSystemSecurity2` object that `Get-NTFSSecurityDescriptor` returned, without comparing them with a parent folder. @@ -6785,7 +6785,7 @@ PS C:\Data> Get-NTFSSecurityDescriptor The `Move-Item2` cmdlet moves the items in `-Path` to the location in `-Destination`. It is the long-path counterpart of the built-in `Move-Item` cmdlet: it works through the AlphaFS library (`Alphaleonis.Win32.Filesystem`), so source and destination may be longer than the 260-character `MAX_PATH` limit. Files and folders can both be moved, and a folder is moved with everything it contains. How `-Destination` is interpreted depends on what is already there. If the value names an existing folder, the cmdlet keeps the name of the source item and moves it into that folder. In every other case the value is the full path of the new item, which lets you move and rename in one step, or rename an item in place. `-Destination` is resolved against the current location once, when the cmdlet starts. - Without `-Force`, the cmdlet checks whether a file or folder already exists at the destination and writes a `DestinationFileAlreadyExists` error instead of overwriting it; the move itself then runs with the `CopyAllowed` option, which allows a file to move to a different volume. With `-WhatIf`, the cmdlet names an existing destination in a verbose message instead; before 5.0.0, it wrote the error also with `-WhatIf`. With `-Force`, the move runs with the `ReplaceExisting` option and overwrites an existing destination item. The folder that is to contain the moved item must exist; otherwise the cmdlet writes an error that names that folder. + Without `-Force`, the cmdlet checks whether a file or folder already exists at the destination and writes a `DestinationFileAlreadyExists` error instead of overwriting it; the move itself then runs with the `CopyAllowed` option, which allows a file to move to a different volume. With `-WhatIf`, the cmdlet names an existing destination in a verbose message instead; before 5.0.0, it wrote the error also with `-WhatIf`. With `-Force`, the move runs with the `ReplaceExisting` option and overwrites an existing destination item. The folder that is to contain the moved item must exist; otherwise the cmdlet writes an error that names that folder, or, with `-WhatIf`, a verbose message. The cmdlet supports `-WhatIf` and `-Confirm`, and it writes nothing to the pipeline unless you specify `-PassThru $true`. diff --git a/Tests/ItemCmdlets.Tests.ps1 b/Tests/ItemCmdlets.Tests.ps1 index ba6c75d..03f4826 100644 --- a/Tests/ItemCmdlets.Tests.ps1 +++ b/Tests/ItemCmdlets.Tests.ps1 @@ -316,6 +316,37 @@ Describe 'Copy-Item2, Move-Item2, and Remove-Item2 with several paths' { @($messages | Where-Object -FilterScript { "$_" -like "*'$existing' already exists*" }) | Should -HaveCount 1 } + + # Before 5.0.0-rc6, -WhatIf didn't tell that the operation would fail because the folder of the destination is + # missing. + It '<_> should name the missing folder of the destination in a verbose message with -WhatIf, and write no error' -ForEach @('Copy-Item2', 'Move-Item2') { + $missingFolder = Join-Path -Path $folder -ChildPath 'MissingFolder' + $target = Join-Path -Path $missingFolder -ChildPath 'Item' + Assert-TestSandboxPath -Sandbox $sandbox -Path $missingFolder, $target + + $messages = & $_ -Path $first -Destination $target -WhatIf -Verbose -ErrorVariable itemErrors -ErrorAction SilentlyContinue 4>&1 + + $itemErrors | Should -BeNullOrEmpty + @($messages | Where-Object -FilterScript { "$_" -like "*'$missingFolder' does not exist*" }) | Should -HaveCount 1 + $first | Should -Exist + $missingFolder | Should -Not -Exist + } + + # The folder of a destination on a share that doesn't exist is the share itself, which the error names. + It ' should name the missing share of a UNC destination' -ForEach @( + @{ Command = 'Copy-Item2'; ErrorId = 'CopyError' } + @{ Command = 'Move-Item2'; ErrorId = 'MoveError' } + ) { + $missingShare = '\\localhost\NTFSSecurityMissing-{0}' -f [guid]::NewGuid().ToString('N').Substring(0, 8) + $target = Join-Path -Path $missingShare -ChildPath 'Item.txt' + + & $Command -Path $first -Destination $target -ErrorVariable itemErrors -ErrorAction SilentlyContinue + + $itemErrors | Should -HaveCount 1 + $itemErrors[0].FullyQualifiedErrorId | Should -BeLike "$ErrorId,*" + $itemErrors[0].Exception.Message | Should -BeLike "*'$missingShare'*" + $first | Should -Exist + } } Describe 'Copy-Item2' { diff --git a/Tests/Repository.Tests.ps1 b/Tests/Repository.Tests.ps1 index 14513cd..66ce3f1 100644 --- a/Tests/Repository.Tests.ps1 +++ b/Tests/Repository.Tests.ps1 @@ -107,3 +107,23 @@ Describe 'Release metadata' { Should -Not -Match '\d+\.\d+\.\d+-[A-Za-z]' } } + +Describe 'Invoke-TestsAsBasicUser.ps1' { + BeforeAll { + # Only the parameters of the script, so that a test binds them without running the tests as a basic user + $path = Join-Path -Path $PSScriptRoot -ChildPath '..\.github\scripts\Invoke-TestsAsBasicUser.ps1' + $tokens = $parseErrors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($path, [ref] $tokens, [ref] $parseErrors) + $bindParameters = [scriptblock]::Create($ast.ParamBlock.Extent.Text) + } + + # The title goes into a quoted argument of cmd.exe, which expands environment variables also inside quotes. + It 'Should refuse a title with the character <_>, which would change the command line of cmd.exe' -ForEach @('%', '"') { + { & $bindParameters -ResultPath 'TestResults\Refused.xml' -Title "CI run $_ 1" } | + Should -Throw -ExpectedMessage "*'Title'*" + } + + It 'Should accept the title <_> of the CI workflow' -ForEach @('Windows PowerShell 5.1 as a basic user', 'PowerShell 7 as a basic user') { + { & $bindParameters -ResultPath 'TestResults\Accepted.xml' -Title $_ } | Should -Not -Throw + } +} From cff40c392d45b37d266cc592da8ad04cdadb0564 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Thu, 8 Oct 2026 11:41:35 +0000 Subject: [PATCH 27/33] docs: record the lab acceptance of 5.0.0-rc6 The candidate acfe3af passed the live tests in both editions with all roles: 326 tests, none failed. The record lists the hashes of the packages, the readiness of the lab, the checkpoint, the results, the baseline, and the checked removal of the fixture. The published 5.0.0-rc5 fails only the two hard-link tests of case 8 on the share, the defect that rc6 fixes. The README of the live tests describes the acceptance of a release candidate, and the release guide runs it before a release. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- Docs/Contributing/05-Releasing.md | 5 + Tests/Lab/Acceptance-2026-10-08-5.0.0-rc6.md | 131 +++++++++++++++++++ Tests/Lab/README.md | 25 +++- 3 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 Tests/Lab/Acceptance-2026-10-08-5.0.0-rc6.md diff --git a/Docs/Contributing/05-Releasing.md b/Docs/Contributing/05-Releasing.md index fc91b16..0ed71ba 100644 --- a/Docs/Contributing/05-Releasing.md +++ b/Docs/Contributing/05-Releasing.md @@ -77,6 +77,11 @@ Gallery compares labels as text, so `rc10` sorts before `rc2`. ## Publish a release +Before you publish a release, run the live tests in a lab against the last +prerelease from the PowerShell Gallery, as the +[acceptance of a release candidate](../../Tests/Lab/README.md#acceptance-of-a-release-candidate) +describes, with `-Version` instead of `-ModulePath`. + 1. Remove the `Prerelease` value from the module manifest. 2. In `CHANGELOG.md`, rename `## [Unreleased]` to the version with the release date, such as `## [5.0.0] - 2026-10-31`, add an empty diff --git a/Tests/Lab/Acceptance-2026-10-08-5.0.0-rc6.md b/Tests/Lab/Acceptance-2026-10-08-5.0.0-rc6.md new file mode 100644 index 0000000..e91e245 --- /dev/null +++ b/Tests/Lab/Acceptance-2026-10-08-5.0.0-rc6.md @@ -0,0 +1,131 @@ +# Lab acceptance of 5.0.0-rc6 + +Acceptance of the release candidate 5.0.0-rc6 in the lab, on 2026-10-08, +before the pull request. It follows the procedure in the +[README](README.md#acceptance-of-a-release-candidate). + +## Candidate + +- Branch `ai/release-5.0.0-rc6`, commit + `acfe3af4cf184e6b12b0bf9b20a8a4d65149b01c`, 26 commits on `fcb370e` + (5.0.0-rc5). +- Release build of that commit, packaged with + `.github\scripts\New-ModulePackage.ps1`. The live tests imported the + module from the extracted `NTFSSecurity.zip`. CI builds the packages that + the tag publishes again, so their hashes differ; the live tests run once + more against the published package. + +| SHA-256 | File | +| --- | --- | +| `30F9694556CB3ADB57D0455AC8E917FA7DC405770DAD3F8C37F42337AECE6374` | `NTFSSecurity.5.0.0-rc6.nupkg` | +| `2A1E5482A67167658A33C7977D7251681F25D4EA437C06B441B5EAAC1016EF55` | `NTFSSecurity.zip` | +| `D308BD24061DEBB633F7A11C924D6347457C530924ACDD4893BEA48BEC58B63E` | `NTFSSecurity\NTFSSecurity.dll` | +| `32C8EA2A55F8721F7953A4E1DE382E1DA6D1CD4BBA844347DD1CA4DA38661D04` | `NTFSSecurity\Security2.dll` | +| `902157ABBD2E0B76DA744A918BDD174D5226C3494908ABA75F9E5DE28AE6A008` | `NTFSSecurity\ProcessPrivileges.dll` | +| `E2077AFEB38703345AE7857C1266F8B26E167ED887BFFAC8C8169A8F267BE6E9` | `NTFSSecurity\PrivilegeControl.dll` | +| `A8DA47194AB0F71232C69D01955AD93BA73C7ECEB58D0DE800CA085D4A2E18D8` | `NTFSSecurity\AlphaFS.dll` | +| `75DA9F7A54DF7011968BACB3FDF5E30B33F4C078861D1DF87BC06F5E64A962D8` | `NTFSSecurity\NTFSSecurity.psd1` | +| `3F777E9D141EE0046119DA9D5ECF88A3BC7E023726098FE2FB150528E2FB59B8` | `NTFSSecurity\NTFSSecurity.psm1` | +| `59583423241951EBE0FC2D8237D0C28C3ECC8C7CD2C115D2660F8579888632FC` | `NTFSSecurity\NTFSSecurity.Init.ps1` | +| `FB0920CC37ED858F55AFD54998DC854E27FBBE6A0177CB059CB03CFE91361197` | `NTFSSecurity\NTFSSecurity.format.ps1xml` | +| `CB6882FF91E6716605D5599E7B464C3346E461216ACED07E847621738F04FB9B` | `NTFSSecurity\NTFSSecurity.types.ps1xml` | +| `DF9657E224E1DDA6D933099A3A09F7E794391B307FD4DDD1C8358049961BF146` | `NTFSSecurity\en-US\NTFSSecurity.dll-Help.xml` | + +## Tests without a lab + +The Pester suite of the commit, 657 tests, against the same build. No test +failed, and every test ran in at least one configuration. + +| Configuration | Passed | Failed | Skipped | +| --- | ---: | ---: | ---: | +| Windows PowerShell 5.1, elevated | 635 | 0 | 22 | +| PowerShell 7, elevated | 605 | 0 | 52 | +| Windows PowerShell 5.1, basic user | 566 | 0 | 91 | +| PowerShell 7, basic user | 536 | 0 | 121 | + +## Lab + +`WindowsAccessControlLab` (AutomatedLab on Hyper-V). Every machine runs +Windows Server 2025 Datacenter (10.0.26100). + +| Machine | Domain | Role in the tests | +| --- | --- | --- | +| `F1ADC1` | `a.forest1.net` | Domain controller of the accounts | +| `F1AFile1` | `a.forest1.net` | Client that runs the tests | +| `F1AFile2` | `a.forest1.net` | File server with the share | +| `F1BDC1` | `b.forest1.net` | Account of another domain of the forest | +| `F2DC1` | `forest2.net` | Account of another forest | +| `F3DC1` | `forest3.net` | Account of another forest | + +Readiness, 11:06 to 11:07 UTC: WinRM answered on all six machines. The +four domain controllers answered LDAP (RootDSE, synchronized) and issued a +Kerberos ticket for `krbtgt`. The client and the file server found a +domain controller, had a working secure channel, and got a service ticket +for each other. The clocks were 4.3 to 5.0 seconds ahead of the host. + +Checkpoint `ntfs-rc6-acfe3af-before-acceptance` (Production) of the six +machines, taken 11:07 to 11:08 UTC before the run. + +## Results + +`Invoke-NTFSSecurityLabTest.ps1 -ModulePath ` in both +editions, 11:08 to 11:25 UTC. The module reported version 5.0.0-rc6 in +every role that loads it. + +| Edition | Role | Passed | Failed | Skipped | +| --- | --- | ---: | ---: | ---: | +| Windows PowerShell 5.1 | Delegate | 38 | 0 | 0 | +| Windows PowerShell 5.1 | ServerAdmin | 13 | 0 | 0 | +| Windows PowerShell 5.1 | Admin | 40 | 0 | 0 | +| Windows PowerShell 5.1 | Server | 72 | 0 | 1 | +| PowerShell 7 | Delegate | 38 | 0 | 0 | +| PowerShell 7 | ServerAdmin | 13 | 0 | 0 | +| PowerShell 7 | Admin | 40 | 0 | 0 | +| PowerShell 7 | Server | 72 | 0 | 1 | + +The role Server skips the check of the module version, because it doesn't +load the module. The accounts of the other domain and forests were +`B\NtfsLiveForeign`, `forest2\NtfsLiveForeign`, and +`forest3\NtfsLiveForeign`; their 13 tests passed in both editions. + +## Baseline + +The published 5.0.0-rc5 from the PowerShell Gallery, whose hash the script +checks against the one the Gallery publishes, in Windows PowerShell 5.1, +11:26 to 11:34 UTC: Delegate 38 passed, ServerAdmin 13, Admin 38 passed and +2 failed, Server 72 passed and 1 skipped. The two failures are the defect +that 5.0.0-rc6 fixes: on the share, `Get-NTFSHardLink` and +`New-NTFSHardLink -PassThru` stopped with the terminating error "(50) The +request is not supported" instead of writing a `GetHardLinkError`. The new +cases find no other difference between the two versions; the local tests +cover the other fixes of 5.0.0-rc6. + +## Cleanup + +`Invoke-NTFSSecurityLabTest.ps1 -RemoveFixture`, 11:38 UTC, after the +baseline. The check compared the lab with the 10 SIDs of the fixture's +accounts and groups, read before the removal; the same check had found the +fixture before the removal: + +- No domain has the organizational unit `NTFSSecurityLive` or an account + whose name starts with `NtfsLive`. +- The file server has no share `NTFSSecurityLive`, no folder + `C:\NTFSSecurityLive` or `C:\NTFSSecurityLab`, and no local group + `NtfsLiveLocal`. +- The client has no folder `C:\NTFSSecurityLab`. +- On both machines, Administrators, Access Control Assistance Operators, + and Remote Management Users have no member of the fixture, and no + profile of the fixture's accounts is left. + +The checkpoint `ntfs-rc6-acfe3af-before-acceptance` stays on the six +machines until the maintainer deletes it. + +## Not covered + +- Other operating systems than Windows Server 2025, such as a Windows 11 + client and Server 2019 or 2022 file servers: Phase 3 of the quality + gate. +- File servers that aren't Windows, such as the IBM ESS system of #34: + only the feedback of the reporter covers them. +- The package that CI publishes for the tag: the live tests run against it + after the release, with `-Version 5.0.0-rc6`. diff --git a/Tests/Lab/README.md b/Tests/Lab/README.md index 1cca743..2967ca5 100644 --- a/Tests/Lab/README.md +++ b/Tests/Lab/README.md @@ -118,9 +118,30 @@ Each call writes to a new folder in `$env:TEMP\NTFSSecurityLab\Results`: folder after the run A version before 5.0.0-rc3 fails case 1 with error 1307, a version before -5.0.0-rc4 fails the tests of #108, and a version before 5.0.0-rc5 fails the +5.0.0-rc4 fails the tests of #108, a version before 5.0.0-rc5 fails the test of case 3 with a computer that can't be reached: it returned no access -instead of the result of the client. +instead of the result of the client. A version before 5.0.0-rc6 fails two +tests of case 8: `Get-NTFSHardLink` and `New-NTFSHardLink -PassThru` stopped +on the share with the terminating error (50). + +## Acceptance of a release candidate + +Before a release, run the live tests once more under controlled conditions +and record the evidence in this folder: + +1. Build the candidate once, package it with + `.github\scripts\New-ModulePackage.ps1`, and record the SHA-256 of the + packages and of the module files. +2. Check that WinRM, LDAP, Kerberos, the secure channel, and the clocks of + the lab machines work. +3. Take a checkpoint of the machines, named after the candidate and its + commit. +4. Run the tests with `-ModulePath` of the extracted `NTFSSecurity.zip` in + both editions. +5. Remove the fixture with `-RemoveFixture` and check that its accounts, + share, folders, group memberships, and profiles are gone. + +Records: [5.0.0-rc6](Acceptance-2026-10-08-5.0.0-rc6.md). ## Files From 8247c85fc1f5d29cac7655b484f4e3dc94329e66 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Thu, 8 Oct 2026 11:54:06 +0000 Subject: [PATCH 28/33] test: cover -PassThru and the flag parameters of Add-NTFSAccess The coverage report of rc6 showed parameters that no test used: -PassThru after a successful change in both parameter sets, -InheritanceFlags and -PropagationFlags on a folder and on a file, where the page says they are ignored, and Clear-NTFSAccess -DisableInheritance on a security descriptor. The tests pin the documented behavior; all pass in the four configurations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- Tests/Access.Tests.ps1 | 59 ++++++++++++++++++++++++++ Tests/SecurityDescriptorSets.Tests.ps1 | 18 ++++++++ 2 files changed, 77 insertions(+) diff --git a/Tests/Access.Tests.ps1 b/Tests/Access.Tests.ps1 index 6c34196..097ffcc 100644 --- a/Tests/Access.Tests.ps1 +++ b/Tests/Access.Tests.ps1 @@ -635,6 +635,65 @@ Describe 'Add-NTFSAccess' { @($acl.GetAccessRules($false, $true, $sidType)) | Should -HaveCount $inheritedCount } } + + # The page: -PassThru writes all entries, explicit and inherited, of every item that the cmdlet changed. + Context 'With -PassThru' { + It 'Should write all entries of the item after the change' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'AddPassThru' + Assert-TestSandboxPath -Sandbox $sandbox -Path $file + + $result = @(Add-NTFSAccess -Path $file -Account 'S-1-1-0' -AccessRights ReadData -PassThru) + + $result | ForEach-Object -Process { $_ | Should -BeOfType [Security2.FileSystemAccessRule2] } + $result | Should -HaveCount @(Get-NTFSAccess -Path $file).Count + @($result | Where-Object -FilterScript { $_.IsInherited }) | Should -Not -BeNullOrEmpty + $added = @($result | Where-Object -FilterScript { $_.Account.Sid -eq 'S-1-1-0' -and -not $_.IsInherited }) + $added | Should -HaveCount 1 + $added[0].AccessRights.HasFlag([Security2.FileSystemRights2]::ReadData) | Should -BeTrue + } + + It 'Should write all entries of a security descriptor after the change and leave the item unchanged' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'AddPassThruDescriptor' + Assert-TestSandboxPath -Sandbox $sandbox -Path $file + $sd = Get-NTFSSecurityDescriptor -Path $file + + $result = @(Add-NTFSAccess -SecurityDescriptor $sd -Account 'S-1-1-0' -AccessRights ReadData -PassThru) + + $result | Should -HaveCount @($sd.SecurityDescriptor.GetAccessRules($true, $true, $sidType)).Count + @($result | Where-Object -FilterScript { $_.Account.Sid -eq 'S-1-1-0' -and -not $_.IsInherited }) | Should -HaveCount 1 + @((Get-Acl -LiteralPath $file).GetAccessRules($true, $false, $sidType) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-1-0' }) | Should -BeNullOrEmpty + } + } + + Context 'With -InheritanceFlags and -PropagationFlags' { + It 'Should add an entry with the flags to a folder' { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'AddFlags' -Directory + Assert-TestSandboxPath -Sandbox $sandbox -Path $folder + + Add-NTFSAccess -Path $folder -Account 'S-1-5-32-546' -AccessRights ReadData -InheritanceFlags ContainerInherit -PropagationFlags InheritOnly + + $rules = @((Get-Acl -LiteralPath $folder).GetAccessRules($true, $false, $sidType) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-5-32-546' }) + $rules | Should -HaveCount 1 + $rules[0].InheritanceFlags | Should -Be ([System.Security.AccessControl.InheritanceFlags]::ContainerInherit) + $rules[0].PropagationFlags | Should -Be ([System.Security.AccessControl.PropagationFlags]::InheritOnly) + } + + # The page: inheritance and propagation flags are ignored on files. + It 'Should add an entry without flags to a file' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'AddFlagsFile' + Assert-TestSandboxPath -Sandbox $sandbox -Path $file + + Add-NTFSAccess -Path $file -Account 'S-1-5-32-546' -AccessRights ReadData -InheritanceFlags 'ContainerInherit, ObjectInherit' -PropagationFlags InheritOnly + + $rules = @((Get-Acl -LiteralPath $file).GetAccessRules($true, $false, $sidType) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-5-32-546' }) + $rules | Should -HaveCount 1 + $rules[0].InheritanceFlags | Should -Be ([System.Security.AccessControl.InheritanceFlags]::None) + $rules[0].PropagationFlags | Should -Be ([System.Security.AccessControl.PropagationFlags]::None) + } + } } Describe 'Security descriptor parameter sets' { diff --git a/Tests/SecurityDescriptorSets.Tests.ps1 b/Tests/SecurityDescriptorSets.Tests.ps1 index a8d4a8a..fcd15e1 100644 --- a/Tests/SecurityDescriptorSets.Tests.ps1 +++ b/Tests/SecurityDescriptorSets.Tests.ps1 @@ -50,6 +50,24 @@ Describe 'Cmdlets that change a security descriptor in memory' { Get-ExplicitAccessCount -Acl (Get-Acl -LiteralPath $file) | Should -Be 0 } + # Like the Path parameter set, the cmdlet doesn't copy the inherited entries, so the DACL ends up empty. + It 'Clear-NTFSAccess -DisableInheritance should leave the descriptor with an empty, protected DACL' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'ClearAccessProtected' + Add-NTFSAccess -Path $file -Account 'S-1-1-0' -AccessRights ReadData + $daclBefore = (Get-Acl -LiteralPath $file).GetSecurityDescriptorSddlForm('Access') + $sd = Get-NTFSSecurityDescriptor -Path $file + + Clear-NTFSAccess -SecurityDescriptor $sd -DisableInheritance -ErrorAction Stop + + $sd.SecurityDescriptor.AreAccessRulesProtected | Should -BeTrue + @($sd.SecurityDescriptor.GetAccessRules($true, $true, $sidType)) | Should -BeNullOrEmpty + (Get-Acl -LiteralPath $file).GetSecurityDescriptorSddlForm('Access') | Should -Be $daclBefore + Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -ErrorAction Stop + $acl = Get-Acl -LiteralPath $file + $acl.AreAccessRulesProtected | Should -BeTrue + @($acl.GetAccessRules($true, $true, $sidType)) | Should -BeNullOrEmpty + } + It 'Disable-NTFSAccessInheritance should protect the DACL of the descriptor and keep the inherited entries' { $file = New-TestSandboxItem -Sandbox $sandbox -Name 'DisableAccess' $inheritedCount = @((Get-Acl -LiteralPath $file).GetAccessRules($false, $true, $sidType)).Count From 0df248252a8f55f71a0c3a8dde6d604ff906cb25 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Thu, 8 Oct 2026 12:19:54 +0000 Subject: [PATCH 29/33] fix: resolve relative paths that start with a dot GetRelativePath treated every path that started with a dot as .\path and dropped its first two characters, so a command on .gitignore read, changed, or removed itignore in the same folder when that item existed; Remove-Item2 -Path .RemoveMe removed emoveMe, and Copy-Item2 -Force with the destination .CopyTarget overwrote opyTarget. Only .\ and ./ are now stripped, and only .. and ..\ go up a folder; any other name is combined with the current location. All path parameters resolve through this method: -Path, -Destination, and -Target. The coverage report of rc6 found the untested branch. The tests use a decoy item with the shortened name and fail without the fix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- CHANGELOG.md | 4 +++ Docs/FAQ.md | 4 ++- NTFSSecurity/BaseCmdlets.cs | 4 +-- Tests/Owner.Tests.ps1 | 58 +++++++++++++++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd7bdf9..2fbe06e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -312,5 +312,9 @@ The format is based on - Fix `Get-NTFSOrphanedAccess`, which reported an item that it couldn't read as an `AddAceError`; it now writes a `ReadSecurityError`, like `Get-NTFSAccess` +- Fix every cmdlet for a relative path that starts with a dot but not with + `.\`, such as `.gitignore`: the cmdlets dropped its first two characters + and read, changed, or removed the item with the shorter name, such as + `itignore`, when one existed [Unreleased]: https://github.com/raandree/NTFSSecurity/compare/4.2.6...HEAD diff --git a/Docs/FAQ.md b/Docs/FAQ.md index 498f7d4..8fd93fb 100644 --- a/Docs/FAQ.md +++ b/Docs/FAQ.md @@ -71,5 +71,7 @@ No. The cmdlets read and write the file system directly through the AlphaFS library, not through the PowerShell providers, so they don't know drives that `New-PSDrive` created, or drives of other providers such as `HKLM:`. Use the file system path instead, such as `C:\Data` or `\\server\share`. A -relative path is resolved against the current file system location. See +relative path is resolved against the current file system location, also a +name that starts with a dot, such as `.gitignore`; before 5.0.0-rc6, the +cmdlets dropped the first two characters of such a name. See [Long paths](Concepts.md#long-paths). diff --git a/NTFSSecurity/BaseCmdlets.cs b/NTFSSecurity/BaseCmdlets.cs index 2fb8503..9dd30f1 100644 --- a/NTFSSecurity/BaseCmdlets.cs +++ b/NTFSSecurity/BaseCmdlets.cs @@ -112,14 +112,14 @@ namespace NTFSSecurity { path = GetCurrentLocation(); } - else if (path.StartsWith("..")) + else if (path == ".." || path.StartsWith("..\\")) { var currentLocation = GetCurrentLocation(); path = System.IO.Path.Combine( string.Join("\\", currentLocation.Split('\\').Take(currentLocation.Split('\\').Count() - path.Split('\\').Count(s => s == "..")).ToArray()), string.Join("\\", path.Split('\\').Where(e => e != "..").ToArray())); } - else if (path.StartsWith(".")) + else if (path.StartsWith(".\\") || path.StartsWith("./")) { //combine . and .\path\subpath path = System.IO.Path.Combine(GetCurrentLocation(), path.Substring(2)); diff --git a/Tests/Owner.Tests.ps1 b/Tests/Owner.Tests.ps1 index 8767460..00e4697 100644 --- a/Tests/Owner.Tests.ps1 +++ b/Tests/Owner.Tests.ps1 @@ -106,6 +106,64 @@ Describe 'Current location' { } } +# Before 5.0.0-rc6, a relative path that started with a dot, but not with .\, lost its first two characters: a command +# on .Dotfile read or changed the item otfile in the same folder when one existed. +Describe 'Relative paths that start with a dot' { + BeforeAll { + $dotFolder = New-TestSandboxItem -Sandbox $sandbox -Name 'Dot' -Directory + Push-Location -LiteralPath $dotFolder + # The names that the defect made of the paths + foreach ($name in '.Dotfile', '..Dotfile', 'otfile', 'Dotfile') { + Assert-TestSandboxPath -Sandbox $sandbox -Path $name + Set-Content -LiteralPath $name -Value $name + } + } + + AfterAll { + Pop-Location + } + + It 'Should resolve to the item of the current location' -ForEach @( + @{ Path = '.Dotfile'; Expected = '.Dotfile' } + @{ Path = '..Dotfile'; Expected = '..Dotfile' } + @{ Path = '.\.Dotfile'; Expected = '.Dotfile' } + @{ Path = './.Dotfile'; Expected = '.Dotfile' } + @{ Path = '..\{0}\.Dotfile'; Expected = '.Dotfile' } + ) { + # {0} is the name of the current folder. + $relative = $Path -f (Split-Path -Path $dotFolder -Leaf) + + $result = Get-NTFSOwner -Path $relative -ErrorAction Stop + + $result.FullName | Should -Be (Join-Path -Path $dotFolder -ChildPath $Expected) + } + + It 'Remove-Item2 should remove the item that the path names, not another item' { + foreach ($name in '.RemoveMe', 'emoveMe') { + Assert-TestSandboxPath -Sandbox $sandbox -Path $name + Set-Content -LiteralPath $name -Value $name + } + + Remove-Item2 -Path '.RemoveMe' -ErrorAction Stop + + Join-Path -Path $dotFolder -ChildPath '.RemoveMe' | Should -Not -Exist + Join-Path -Path $dotFolder -ChildPath 'emoveMe' | Should -Exist + } + + It 'Copy-Item2 should copy to the destination that the path names, not over another item' { + foreach ($name in 'CopySource', 'opyTarget') { + Assert-TestSandboxPath -Sandbox $sandbox -Path $name + Set-Content -LiteralPath $name -Value $name + } + Assert-TestSandboxPath -Sandbox $sandbox -Path '.CopyTarget' + + Copy-Item2 -Path 'CopySource' -Destination '.CopyTarget' -Force -ErrorAction Stop + + Get-Content -LiteralPath (Join-Path -Path $dotFolder -ChildPath '.CopyTarget') | Should -Be 'CopySource' + Get-Content -LiteralPath (Join-Path -Path $dotFolder -ChildPath 'opyTarget') | Should -Be 'opyTarget' + } +} + Describe 'File and folder objects as arguments' { # Before 5.0.0, Windows PowerShell bound a folder object that was passed by position as its name, which the # cmdlets resolved against the current location (#88). From 1b9edbbd6a91f9857f9d3c69c3bf7ccde187416d Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Thu, 8 Oct 2026 12:20:06 +0000 Subject: [PATCH 30/33] fix: compare output objects and report InheritedFrom correctly Equals of FileSystemAccessRule2, FileSystemAuditRule2, and FileSystemSecurity2 cast its argument to the .NET type, which throws for the wrapper types themselves: -eq and -contains, and in PowerShell 7 also Select-Object -Unique and Compare-Object, stopped with an InvalidCastException. GetHashCode of FileSystemSecurity2 read a field that is never set and threw a NullReferenceException. Two objects are now equal when they hold the same entry or descriptor, like the .NET types. InheritedFrom: Win32.GetInheritedFrom returned the sources of the SACL whenever the descriptor had one, also for the access entries, and the callers gave the filtered entries of -ExcludeExplicit the sources of the first entries of the ACL. Get-NTFSAccess -SecurityDescriptor stopped with an ArgumentOutOfRangeException for a descriptor with audit entries, as Get-NTFSSecurityDescriptor reads them in an elevated session. The method now takes the ACL of the entries, and the callers map the sources before they filter. The coverage report of rc6 pointed at both; each test fails without its fix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- CHANGELOG.md | 11 +++ Docs/Cmdlets/Get-NTFSAccess.md | 2 + Docs/Cmdlets/Get-NTFSAudit.md | 2 + NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml | 2 + ...temAccessRule2.GetFileSystemAccessRules.cs | 18 +++-- .../FileSystemAccessRule2.cs | 7 +- ...ystemAuditRule2.GetFileSystemAuditRules.cs | 20 ++++-- .../FileSystemAuditRule2.cs | 7 +- Security2/FileSystem/FileSystemSecurity2.cs | 10 ++- Security2/Win32/Lib.cs | 41 +++-------- Tests/Access.Tests.ps1 | 71 +++++++++++++++++++ Tests/Audit.Tests.ps1 | 34 +++++++++ Tests/SecurityDescriptor.Tests.ps1 | 19 +++++ 13 files changed, 194 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fbe06e..137c644 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -316,5 +316,16 @@ The format is based on `.\`, such as `.gitignore`: the cmdlets dropped its first two characters and read, changed, or removed the item with the shorter name, such as `itignore`, when one existed +- Fix comparing the objects of the access, audit, and security descriptor + cmdlets: `-eq` and `-contains`, and in PowerShell 7 also + `Select-Object -Unique` and `Compare-Object`, stopped with an + `InvalidCastException`, and a security descriptor as the key of a + hashtable with a `NullReferenceException`. Two objects are now equal when + they hold the same entry or descriptor, as in .NET +- Fix `InheritedFrom` of `Get-NTFSAccess` and `Get-NTFSAudit`: with + `-ExcludeExplicit`, each inherited entry showed the source of another + entry, and `Get-NTFSAccess -SecurityDescriptor` stopped with an + `ArgumentOutOfRangeException` for a descriptor with audit entries, such + as one that `Get-NTFSSecurityDescriptor` reads in an elevated session [Unreleased]: https://github.com/raandree/NTFSSecurity/compare/4.2.6...HEAD diff --git a/Docs/Cmdlets/Get-NTFSAccess.md b/Docs/Cmdlets/Get-NTFSAccess.md index 26ff37b..1b092c6 100644 --- a/Docs/Cmdlets/Get-NTFSAccess.md +++ b/Docs/Cmdlets/Get-NTFSAccess.md @@ -186,6 +186,8 @@ Entries whose account cannot be translated into a name are returned with their S Before 5.0.0, after a path whose ACL could not be read, the cmdlet returned the entries of the previous item again. +Before 5.0.0-rc6, with `-ExcludeExplicit`, each inherited entry showed the `InheritedFrom` path of another entry, and for a security descriptor with audit entries, such as one that `Get-NTFSSecurityDescriptor` reads in an elevated session, the cmdlet stopped with an `ArgumentOutOfRangeException`. + For the root of a drive, such as `C:\`, or of a volume, such as `\\?\Volume{GUID}\`, the cmdlets that read and change security use the root folder of the volume, like Explorer, `icacls`, and `Get-Acl`. Before 5.0.0, they read and changed the security descriptor of the drive itself, a device object with other entries. ## RELATED LINKS diff --git a/Docs/Cmdlets/Get-NTFSAudit.md b/Docs/Cmdlets/Get-NTFSAudit.md index 9294d25..bfbb742 100644 --- a/Docs/Cmdlets/Get-NTFSAudit.md +++ b/Docs/Cmdlets/Get-NTFSAudit.md @@ -185,6 +185,8 @@ If reading the audit entries is denied, the cmdlet writes a `ReadSecurityError` Before 5.0.0, the cmdlet returned no entries and no error without the Security privilege, and after a path whose security descriptor could not be read, it returned the entries of the previous item again. The `InheritanceEnabled` property of the entries also reported whether the access entries were inherited instead of the audit entries. +Before 5.0.0-rc6, with `-ExcludeExplicit`, each inherited entry showed the `InheritedFrom` path of another entry. + ## RELATED LINKS [Add-NTFSAudit](Add-NTFSAudit.md) diff --git a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml index 9a59a89..f8e25a2 100644 --- a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml +++ b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml @@ -4588,6 +4588,7 @@ PS C:\> Disable-Privileges If the ACL of an item cannot be read because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. Reading the owner, which that fallback needs first, requires the same Read Permissions right as reading the ACL, so the cmdlet then writes a non-terminating `ReadSecurityError` and continues with the next item. Entries whose account cannot be translated into a name are returned with their SID. Use `Get-NTFSOrphanedAccess` to list only those entries. Before 5.0.0, after a path whose ACL could not be read, the cmdlet returned the entries of the previous item again. + Before 5.0.0-rc6, with `-ExcludeExplicit`, each inherited entry showed the `InheritedFrom` path of another entry, and for a security descriptor with audit entries, such as one that `Get-NTFSSecurityDescriptor` reads in an elevated session, the cmdlet stopped with an `ArgumentOutOfRangeException`. For the root of a drive, such as `C:`, or of a volume, such as `\?\Volume{GUID}`, the cmdlets that read and change security use the root folder of the volume, like Explorer, `icacls`, and `Get-Acl`. Before 5.0.0, they read and changed the security descriptor of the drive itself, a device object with other entries. @@ -4871,6 +4872,7 @@ PS C:\> Disable-Privileges Reading the SACL requires the Security privilege (`SeSecurityPrivilege`, "Manage auditing and security log"), so run this cmdlet in an elevated session of an account that holds that privilege. Without it, the cmdlet writes the non-terminating error `ReadSecurityError` for each item, which reports "A required privilege is not held by the client". `Get-NTFSSecurityDescriptor` reads a security descriptor without its SACL when the privilege is missing; for such a descriptor, the cmdlet writes a `ReadSecurityError` as well. If reading the audit entries is denied, the cmdlet writes a `ReadSecurityError` with the category `PermissionDenied`. It doesn't take ownership of the item, because ownership grants no access to the SACL. Before 5.0.0, the cmdlet returned no entries and no error without the Security privilege, and after a path whose security descriptor could not be read, it returned the entries of the previous item again. The `InheritanceEnabled` property of the entries also reported whether the access entries were inherited instead of the audit entries. + Before 5.0.0-rc6, with `-ExcludeExplicit`, each inherited entry showed the `InheritedFrom` path of another entry. diff --git a/Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.GetFileSystemAccessRules.cs b/Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.GetFileSystemAccessRules.cs index 35f3bec..eb0c35c 100644 --- a/Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.GetFileSystemAccessRules.cs +++ b/Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.GetFileSystemAccessRules.cs @@ -21,21 +21,29 @@ namespace Security2 if (getInheritedFrom) { - inheritedFrom = Win32.GetInheritedFrom(sd.Item, sd.SecurityDescriptor); + inheritedFrom = Win32.GetInheritedFrom(sd.Item, sd.SecurityDescriptor, false); } + // All entries, so that each entry gets the source at its index in the DACL; before 5.0.0-rc6, the entries of + // -ExcludeExplicit got the sources of other entries. The filter follows. var aceCounter = 0; var acl = !sd.IsFile ? - ((DirectorySecurity)sd.SecurityDescriptor).GetAccessRules(includeExplicit, includeInherited, typeof(SecurityIdentifier)) : - ((FileSecurity)sd.SecurityDescriptor).GetAccessRules(includeExplicit, includeInherited, typeof(SecurityIdentifier)); + ((DirectorySecurity)sd.SecurityDescriptor).GetAccessRules(true, true, typeof(SecurityIdentifier)) : + ((FileSecurity)sd.SecurityDescriptor).GetAccessRules(true, true, typeof(SecurityIdentifier)); foreach (FileSystemAccessRule ace in acl) { + var source = getInheritedFrom && aceCounter < inheritedFrom.Count ? inheritedFrom[aceCounter] : null; + aceCounter++; + if (ace.IsInherited ? !includeInherited : !includeExplicit) + { + continue; + } + var ace2 = new FileSystemAccessRule2(ace) { FullName = sd.Item.FullName, InheritanceEnabled = !sd.SecurityDescriptor.AreAccessRulesProtected }; if (getInheritedFrom && inheritedFrom.Count > 0) { - ace2.inheritedFrom = string.IsNullOrEmpty(inheritedFrom[aceCounter]) ? "" : inheritedFrom[aceCounter].Substring(0, inheritedFrom[aceCounter].Length - 1); - aceCounter++; + ace2.inheritedFrom = string.IsNullOrEmpty(source) ? "" : source.Substring(0, source.Length - 1); } aceList.Add(ace2); diff --git a/Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.cs b/Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.cs index b48e41d..7cf84a4 100644 --- a/Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.cs +++ b/Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.cs @@ -48,10 +48,13 @@ namespace Security2 return new FileSystemAccessRule2(ace); } - //REQUIRED BECAUSE OF CONVERSION OPERATORS + // Like the entries of .NET, two objects are equal when they wrap the same entry. A cast instead of "as" + // threw an InvalidCastException for any other type, also for -eq in PowerShell. public override bool Equals(object obj) { - return fileSystemAccessRule == (FileSystemAccessRule)obj; + var other = obj as FileSystemAccessRule2; + var rule = other != null ? other.fileSystemAccessRule : obj as FileSystemAccessRule; + return rule != null && ReferenceEquals(fileSystemAccessRule, rule); } public override int GetHashCode() { diff --git a/Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.GetFileSystemAuditRules.cs b/Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.GetFileSystemAuditRules.cs index 9fc525c..772c509 100644 --- a/Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.GetFileSystemAuditRules.cs +++ b/Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.GetFileSystemAuditRules.cs @@ -21,21 +21,29 @@ namespace Security2 if (getInheritedFrom) { - inheritedFrom = Win32.GetInheritedFrom(sd.Item, sd.SecurityDescriptor); + inheritedFrom = Win32.GetInheritedFrom(sd.Item, sd.SecurityDescriptor, true); } + // All entries, so that each entry gets the source at its index in the SACL; before 5.0.0-rc6, the entries of + // -ExcludeExplicit got the sources of other entries. The filter follows. var aceCounter = 0; var acl = !sd.IsFile ? - ((DirectorySecurity)sd.SecurityDescriptor).GetAuditRules(includeExplicit, includeInherited, typeof(SecurityIdentifier)) : - ((FileSecurity)sd.SecurityDescriptor).GetAuditRules(includeExplicit, includeInherited, typeof(SecurityIdentifier)); + ((DirectorySecurity)sd.SecurityDescriptor).GetAuditRules(true, true, typeof(SecurityIdentifier)) : + ((FileSecurity)sd.SecurityDescriptor).GetAuditRules(true, true, typeof(SecurityIdentifier)); foreach (FileSystemAuditRule ace in acl) { + var source = getInheritedFrom && aceCounter < inheritedFrom.Count ? inheritedFrom[aceCounter] : null; + aceCounter++; + if (ace.IsInherited ? !includeInherited : !includeExplicit) + { + continue; + } + var ace2 = new FileSystemAuditRule2(ace) { FullName = sd.Item.FullName, InheritanceEnabled = !sd.SecurityDescriptor.AreAuditRulesProtected }; - if (getInheritedFrom) + if (getInheritedFrom && inheritedFrom.Count > 0) { - ace2.inheritedFrom = string.IsNullOrEmpty(inheritedFrom[aceCounter]) ? "" : inheritedFrom[aceCounter].Substring(0, inheritedFrom[aceCounter].Length - 1); - aceCounter++; + ace2.inheritedFrom = string.IsNullOrEmpty(source) ? "" : source.Substring(0, source.Length - 1); } aceList.Add(ace2); diff --git a/Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.cs b/Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.cs index 7f951e4..1253d90 100644 --- a/Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.cs +++ b/Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.cs @@ -48,10 +48,13 @@ namespace Security2 { return new FileSystemAuditRule2(ace); } - //REQUIRED BECAUSE OF CONVERSION OPERATORS + // Like the entries of .NET, two objects are equal when they wrap the same entry. A cast instead of "as" + // threw an InvalidCastException for any other type, also for -eq in PowerShell. public override bool Equals(object obj) { - return this.fileSystemAuditRule == (FileSystemAuditRule)obj; + var other = obj as FileSystemAuditRule2; + var rule = other != null ? other.fileSystemAuditRule : obj as FileSystemAuditRule; + return rule != null && ReferenceEquals(fileSystemAuditRule, rule); } public override int GetHashCode() { diff --git a/Security2/FileSystem/FileSystemSecurity2.cs b/Security2/FileSystem/FileSystemSecurity2.cs index 76c590d..39e5c8c 100644 --- a/Security2/FileSystem/FileSystemSecurity2.cs +++ b/Security2/FileSystem/FileSystemSecurity2.cs @@ -254,14 +254,18 @@ namespace Security2 return new FileSystemSecurity2(new DirectoryInfo("")); } - //REQUIRED BECAUSE OF CONVERSION OPERATORS + // Like the descriptors of .NET, two objects are equal when they wrap the same descriptor. The descriptor is in + // sd; fileSecurityDescriptor is never set, so its hash code threw a NullReferenceException, and the cast an + // InvalidCastException for any other type. public override bool Equals(object obj) { - return this.fileSecurityDescriptor == (FileSecurity)obj; + var other = obj as FileSystemSecurity2; + var descriptor = other != null ? other.sd : obj as FileSystemSecurity; + return descriptor != null && ReferenceEquals(sd, descriptor); } public override int GetHashCode() { - return fileSecurityDescriptor.GetHashCode(); + return sd != null ? sd.GetHashCode() : 0; } #endregion diff --git a/Security2/Win32/Lib.cs b/Security2/Win32/Lib.cs index 9df1595..46e42aa 100644 --- a/Security2/Win32/Lib.cs +++ b/Security2/Win32/Lib.cs @@ -19,44 +19,21 @@ namespace Security2 IntPtr pErrorSecObj = IntPtr.Zero; #region GetInheritedFrom - public static List GetInheritedFrom(FileSystemInfo item, ObjectSecurity sd) + // Returns the source of each entry of the DACL, or of the SACL for audit entries, in the order of the ACL. Before + // 5.0.0-rc6, a descriptor with a SACL returned the sources of the audit entries also for the access entries. + public static List GetInheritedFrom(FileSystemInfo item, ObjectSecurity sd, bool auditEntries) { var inheritedFrom = new List(); var sdBytes = sd.GetSecurityDescriptorBinaryForm(); - byte[] aclBytes = null; var rawSd = new RawSecurityDescriptor(sdBytes, 0); + var acl = auditEntries ? rawSd.SystemAcl : rawSd.DiscretionaryAcl; - var aceCount = 0; - - if (rawSd.SystemAcl != null) - { - aceCount = rawSd.SystemAcl.Count; - aclBytes = new byte[rawSd.SystemAcl.BinaryLength]; - rawSd.SystemAcl.GetBinaryForm(aclBytes, 0); - - try - { - inheritedFrom = GetInheritedFrom(item.FullName, - aclBytes, - aceCount, - item is DirectoryInfo ? true : false, - SECURITY_INFORMATION.SACL_SECURITY_INFORMATION); - } - catch - { - inheritedFrom = new List(); - for (int i = 0; i < aceCount; i++) - { - inheritedFrom.Add("unknown parent"); - } - } - } - else if (rawSd.DiscretionaryAcl != null) + if (acl != null) { - aceCount = rawSd.DiscretionaryAcl.Count; - aclBytes = new byte[rawSd.DiscretionaryAcl.BinaryLength]; - rawSd.DiscretionaryAcl.GetBinaryForm(aclBytes, 0); + var aceCount = acl.Count; + var aclBytes = new byte[acl.BinaryLength]; + acl.GetBinaryForm(aclBytes, 0); try { @@ -64,7 +41,7 @@ namespace Security2 aclBytes, aceCount, item is DirectoryInfo ? true : false, - SECURITY_INFORMATION.DACL_SECURITY_INFORMATION); + auditEntries ? SECURITY_INFORMATION.SACL_SECURITY_INFORMATION : SECURITY_INFORMATION.DACL_SECURITY_INFORMATION); } catch { diff --git a/Tests/Access.Tests.ps1 b/Tests/Access.Tests.ps1 index 097ffcc..deafdc6 100644 --- a/Tests/Access.Tests.ps1 +++ b/Tests/Access.Tests.ps1 @@ -784,3 +784,74 @@ Describe 'Clear-NTFSAccess' { } } } + +# Before 5.0.0-rc6, comparing an entry with anything threw an InvalidCastException, so -eq, -contains, and in PowerShell 7 +# also Select-Object -Unique and Compare-Object failed for the output of Get-NTFSAccess. Like the entries of .NET, two +# objects are equal when they wrap the same entry. +Describe 'Comparing access entries' { + BeforeAll { + $compareFile = New-TestSandboxItem -Sandbox $sandbox -Name 'Compare' + Add-NTFSAccess -Path $compareFile -Account 'S-1-1-0' -AccessRights ReadData + $entries = @(Get-NTFSAccess -Path $compareFile) + } + + It 'Should find an entry equal to itself and not to another entry' { + $entries.Count | Should -BeGreaterThan 1 + + $entries[0] -eq $entries[0] | Should -BeTrue + $entries[0] -eq $entries[1] | Should -BeFalse + $entries -contains $entries[1] | Should -BeTrue + $entries[0].Equals('S-1-1-0') | Should -BeFalse + $entries[0].Equals($null) | Should -BeFalse + } + + It 'Should work with Select-Object -Unique and Compare-Object' { + @(@($entries) + $entries[0] | Select-Object -Unique) | Should -HaveCount $entries.Count + Compare-Object -ReferenceObject $entries -DifferenceObject $entries | Should -BeNullOrEmpty + } +} + +# Before 5.0.0-rc6, -ExcludeExplicit gave each inherited entry the source of another entry, and Get-NTFSAccess stopped +# with an ArgumentOutOfRangeException for a security descriptor with audit entries, because it took the sources of the +# audit entries for the access entries. +Describe 'InheritedFrom of access entries' { + BeforeAll { + $inheritedFromFile = New-TestSandboxItem -Sandbox $sandbox -Name 'InheritedFrom' + Add-NTFSAccess -Path $inheritedFromFile -Account 'S-1-1-0' -AccessRights ReadData + $expectedSource = @{} + foreach ($entry in Get-NTFSAccess -Path $inheritedFromFile) { + if ($entry.IsInherited) { + $expectedSource["$($entry.Account.Sid)"] = $entry.InheritedFrom + } + } + } + + It 'Should name the folder that each inherited entry comes from' { + $expectedSource.Count | Should -BeGreaterThan 0 + $expectedSource.Values | ForEach-Object -Process { $_ | Should -Not -BeNullOrEmpty } + } + + It 'Should name the same folders with -ExcludeExplicit' { + $result = @(Get-NTFSAccess -Path $inheritedFromFile -ExcludeExplicit) + + $result | Should -HaveCount $expectedSource.Count + foreach ($entry in $result) { + $entry.InheritedFrom | Should -Be $expectedSource["$($entry.Account.Sid)"] + } + } + + It 'Should read a security descriptor with audit entries and name the same folders' -Skip:(-not $holdsSecurityPrivilege) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'InheritedFromAudit' + Add-NTFSAccess -Path $file -Account 'S-1-1-0' -AccessRights ReadData + Add-NTFSAudit -Path $file -Account 'S-1-1-0' -AccessRights ReadData -InheritanceFlags None -PropagationFlags None + $sd = Get-NTFSSecurityDescriptor -Path $file + @($sd.SecurityDescriptor.GetAuditRules($true, $true, $sidType)) | Should -Not -BeNullOrEmpty + + $result = @(Get-NTFSAccess -SecurityDescriptor $sd -ErrorAction Stop) + + $result | Should -HaveCount @(Get-NTFSAccess -Path $file).Count + foreach ($entry in @($result | Where-Object -FilterScript { $_.IsInherited })) { + $entry.InheritedFrom | Should -Be $expectedSource["$($entry.Account.Sid)"] + } + } +} diff --git a/Tests/Audit.Tests.ps1 b/Tests/Audit.Tests.ps1 index d7dae3a..fc99457 100644 --- a/Tests/Audit.Tests.ps1 +++ b/Tests/Audit.Tests.ps1 @@ -462,3 +462,37 @@ Describe 'Clear-NTFSAudit' { } } } + +# Before 5.0.0-rc6, comparing an audit entry with anything threw an InvalidCastException. +Describe 'Comparing audit entries' { + It 'Should find an entry equal to itself and not to a string' -Skip:(-not $canReadAudit) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'Compare' + Add-NTFSAudit -Path $file -Account 'S-1-1-0' -AccessRights ReadData -InheritanceFlags None -PropagationFlags None + $entries = @(Get-NTFSAudit -Path $file -ExcludeInherited) + $entries | Should -HaveCount 1 + + $entries[0] -eq $entries[0] | Should -BeTrue + $entries -contains $entries[0] | Should -BeTrue + $entries[0].Equals('S-1-1-0') | Should -BeFalse + } +} + +# Before 5.0.0-rc6, -ExcludeExplicit gave each inherited audit entry the source of another entry. +Describe 'InheritedFrom of audit entries' { + It 'Should name the folder that an inherited entry comes from, also with -ExcludeExplicit' -Skip:(-not $canReadAudit) { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'InheritedFrom' -Directory + Add-NTFSAudit -Path $folder -Account 'S-1-1-0' -AccessRights ReadData -InheritanceFlags 'ContainerInherit, ObjectInherit' -PropagationFlags None + $file = Join-Path -Path $folder -ChildPath 'File.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $file + Set-Content -LiteralPath $file -Value 'File' + Add-NTFSAudit -Path $file -Account 'S-1-5-32-546' -AccessRights Delete -InheritanceFlags None -PropagationFlags None + + $all = @(Get-NTFSAudit -Path $file) + $inherited = @(Get-NTFSAudit -Path $file -ExcludeExplicit) + + @($all | Where-Object -FilterScript { $_.IsInherited }) | Should -HaveCount 1 + @($all | Where-Object -FilterScript { $_.IsInherited })[0].InheritedFrom | Should -Be $folder + $inherited | Should -HaveCount 1 + $inherited[0].InheritedFrom | Should -Be $folder + } +} diff --git a/Tests/SecurityDescriptor.Tests.ps1 b/Tests/SecurityDescriptor.Tests.ps1 index 3fa8050..5fad078 100644 --- a/Tests/SecurityDescriptor.Tests.ps1 +++ b/Tests/SecurityDescriptor.Tests.ps1 @@ -293,3 +293,22 @@ Describe 'FileSystemSecurity2.Write with another item' { (Get-Acl -LiteralPath $target).GetOwner($sidType).Value | Should -Be $targetOwner } } + +# Before 5.0.0-rc6, comparing a descriptor threw an InvalidCastException, and its hash code a NullReferenceException, +# so -eq and a hashtable with the descriptor as key failed. +Describe 'Comparing security descriptors' { + It 'Should find a descriptor equal to itself and not to another one, and use it as a key' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'Compare' + $sd = Get-NTFSSecurityDescriptor -Path $file + $other = Get-NTFSSecurityDescriptor -Path $file + + $sd -eq $sd | Should -BeTrue + $sd -eq $other | Should -BeFalse + $sd.Equals('x') | Should -BeFalse + $table = @{} + $table[$sd] = 'first' + $table[$other] = 'second' + $table[$sd] | Should -Be 'first' + $table.Count | Should -Be 2 + } +} From 7b0781ff8bb2c1ee4087a16411acd4c7070ba1fa Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Thu, 8 Oct 2026 12:46:13 +0000 Subject: [PATCH 31/33] fix: address the second review of 5.0.0-rc6 - The conversions of a FileSystemSecurity2 to FileSecurity and DirectorySecurity returned fields that were never set, so they gave null; they return the descriptor, and the dead fields are gone (finding 1). - Equals of the entries and descriptors accepted the .NET type as well, which doesn't know the wrapper, so equality depended on the direction; only an object of the module can now be equal (finding 2). - Invoke-TestsAsBasicUser.ps1 refuses a title with a line break, also a final one, which $ let through (finding 6). - The InheritedFrom test of the access entries checks a known parent folder with two explicit entries in front; it fails on acfe3af (finding 7). Checked and kept: a callback ACE before the inherited entries doesn't shift InheritedFrom, because .NET returns it as a rule (finding 3). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- .github/scripts/Invoke-TestsAsBasicUser.ps1 | 9 +++--- CHANGELOG.md | 3 +- .../FileSystemAccessRule2.cs | 7 ++--- .../FileSystemAuditRule2.cs | 7 ++--- Security2/FileSystem/FileSystemSecurity2.cs | 17 ++++++----- Tests/Access.Tests.ps1 | 28 +++++++++++++++++++ Tests/Repository.Tests.ps1 | 17 +++++++++-- Tests/SecurityDescriptor.Tests.ps1 | 20 +++++++++++++ 8 files changed, 83 insertions(+), 25 deletions(-) diff --git a/.github/scripts/Invoke-TestsAsBasicUser.ps1 b/.github/scripts/Invoke-TestsAsBasicUser.ps1 index 93d1d82..60d33c6 100644 --- a/.github/scripts/Invoke-TestsAsBasicUser.ps1 +++ b/.github/scripts/Invoke-TestsAsBasicUser.ps1 @@ -14,8 +14,8 @@ Specifies the path of 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 or a percent sign, - which would change the command line of cmd.exe. + Specifies the heading of the test results in the job summary. It can't contain a double quote, a percent sign, or a + line break, which would change the command line of cmd.exe. .EXAMPLE .\.github\scripts\Invoke-TestsAsBasicUser.ps1 -ResultPath TestResults\WindowsPowerShell-BasicUser.xml -Title 'Windows PowerShell 5.1 as a basic user' @@ -31,8 +31,9 @@ param ( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] - # cmd.exe gets the title in a quoted argument and expands environment variables also inside quotes. - [ValidatePattern('^[^"%]+$')] + # cmd.exe gets the title in a quoted argument, expands environment variables also inside quotes, and ends the + # command at a line break. \z, unlike $, doesn't match before a final line feed. + [ValidatePattern('\A[^"%\r\n]+\z')] [string] $Title ) diff --git a/CHANGELOG.md b/CHANGELOG.md index 137c644..9659ecb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -321,7 +321,8 @@ The format is based on `Select-Object -Unique` and `Compare-Object`, stopped with an `InvalidCastException`, and a security descriptor as the key of a hashtable with a `NullReferenceException`. Two objects are now equal when - they hold the same entry or descriptor, as in .NET + they hold the same entry or descriptor, as in .NET. Converting a security + descriptor to `FileSecurity` or `DirectorySecurity` returned `$null` - Fix `InheritedFrom` of `Get-NTFSAccess` and `Get-NTFSAudit`: with `-ExcludeExplicit`, each inherited entry showed the source of another entry, and `Get-NTFSAccess -SecurityDescriptor` stopped with an diff --git a/Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.cs b/Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.cs index 7cf84a4..bcba319 100644 --- a/Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.cs +++ b/Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.cs @@ -48,13 +48,12 @@ namespace Security2 return new FileSystemAccessRule2(ace); } - // Like the entries of .NET, two objects are equal when they wrap the same entry. A cast instead of "as" - // threw an InvalidCastException for any other type, also for -eq in PowerShell. + // Like the entries of .NET, two objects are equal when they wrap the same entry; an entry of .NET is never equal, + // so that equality is the same in both directions. A cast instead of "as" threw an InvalidCastException. public override bool Equals(object obj) { var other = obj as FileSystemAccessRule2; - var rule = other != null ? other.fileSystemAccessRule : obj as FileSystemAccessRule; - return rule != null && ReferenceEquals(fileSystemAccessRule, rule); + return other != null && ReferenceEquals(fileSystemAccessRule, other.fileSystemAccessRule); } public override int GetHashCode() { diff --git a/Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.cs b/Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.cs index 1253d90..f8c729f 100644 --- a/Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.cs +++ b/Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.cs @@ -48,13 +48,12 @@ namespace Security2 { return new FileSystemAuditRule2(ace); } - // Like the entries of .NET, two objects are equal when they wrap the same entry. A cast instead of "as" - // threw an InvalidCastException for any other type, also for -eq in PowerShell. + // Like the entries of .NET, two objects are equal when they wrap the same entry; an entry of .NET is never equal, + // so that equality is the same in both directions. A cast instead of "as" threw an InvalidCastException. public override bool Equals(object obj) { var other = obj as FileSystemAuditRule2; - var rule = other != null ? other.fileSystemAuditRule : obj as FileSystemAuditRule; - return rule != null && ReferenceEquals(fileSystemAuditRule, rule); + return other != null && ReferenceEquals(fileSystemAuditRule, other.fileSystemAuditRule); } public override int GetHashCode() { diff --git a/Security2/FileSystem/FileSystemSecurity2.cs b/Security2/FileSystem/FileSystemSecurity2.cs index 39e5c8c..b984d7d 100644 --- a/Security2/FileSystem/FileSystemSecurity2.cs +++ b/Security2/FileSystem/FileSystemSecurity2.cs @@ -7,8 +7,6 @@ namespace Security2 { public class FileSystemSecurity2 { - protected FileSecurity fileSecurityDescriptor; - protected DirectorySecurity directorySecurityDescriptor; protected FileSystemInfo item; protected FileSystemSecurity sd; protected AccessControlSections sections; @@ -236,9 +234,11 @@ namespace Security2 } #region Conversion + // The descriptor is in sd. Before 5.0.0-rc6, these conversions returned fields that were never set, so they + // gave null, and the hash code threw a NullReferenceException. public static implicit operator FileSecurity(FileSystemSecurity2 fs2) { - return fs2.fileSecurityDescriptor; + return fs2.sd as FileSecurity; } public static implicit operator FileSystemSecurity2(FileSecurity fs) { @@ -247,21 +247,20 @@ namespace Security2 public static implicit operator DirectorySecurity(FileSystemSecurity2 fs2) { - return fs2.directorySecurityDescriptor; + return fs2.sd as DirectorySecurity; } public static implicit operator FileSystemSecurity2(DirectorySecurity fs) { return new FileSystemSecurity2(new DirectoryInfo("")); } - // Like the descriptors of .NET, two objects are equal when they wrap the same descriptor. The descriptor is in - // sd; fileSecurityDescriptor is never set, so its hash code threw a NullReferenceException, and the cast an - // InvalidCastException for any other type. + // Like the descriptors of .NET, two objects are equal when they wrap the same descriptor; a descriptor of .NET + // is never equal, so that equality is the same in both directions. A cast instead of "as" threw an + // InvalidCastException. public override bool Equals(object obj) { var other = obj as FileSystemSecurity2; - var descriptor = other != null ? other.sd : obj as FileSystemSecurity; - return descriptor != null && ReferenceEquals(sd, descriptor); + return other != null && ReferenceEquals(sd, other.sd); } public override int GetHashCode() { diff --git a/Tests/Access.Tests.ps1 b/Tests/Access.Tests.ps1 index deafdc6..7431465 100644 --- a/Tests/Access.Tests.ps1 +++ b/Tests/Access.Tests.ps1 @@ -809,6 +809,15 @@ Describe 'Comparing access entries' { @(@($entries) + $entries[0] | Select-Object -Unique) | Should -HaveCount $entries.Count Compare-Object -ReferenceObject $entries -DifferenceObject $entries | Should -BeNullOrEmpty } + + # The entry of .NET doesn't know the object of the module, so equality in one direction only would make a hashtable + # lookup depend on which of the two is the key. + It 'Should be equal only to an entry of the module, in both directions' { + $raw = [System.Security.AccessControl.FileSystemAccessRule] $entries[0] + + $entries[0].Equals($raw) | Should -BeFalse + $raw.Equals($entries[0]) | Should -BeFalse + } } # Before 5.0.0-rc6, -ExcludeExplicit gave each inherited entry the source of another entry, and Get-NTFSAccess stopped @@ -840,6 +849,25 @@ Describe 'InheritedFrom of access entries' { } } + # Two explicit entries before the inherited ones; before 5.0.0-rc6, -ExcludeExplicit shifted the sources by two. + It 'Should name the folder of an inheritable entry, also with -ExcludeExplicit' { + $parent = New-TestSandboxItem -Sandbox $sandbox -Name 'InheritedFromParent' -Directory + Add-NTFSAccess -Path $parent -Account 'S-1-5-32-546' -AccessRights ReadData -AppliesTo ThisFolderSubfoldersAndFiles + $child = Join-Path -Path $parent -ChildPath 'Child.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $child + Set-Content -LiteralPath $child -Value 'Child' + Add-NTFSAccess -Path $child -Account 'S-1-1-0' -AccessRights ReadData + Add-NTFSAccess -Path $child -Account 'S-1-5-32-545' -AccessRights ReadData + + $all = @(Get-NTFSAccess -Path $child | Where-Object -FilterScript { $_.IsInherited -and "$($_.Account.Sid)" -eq 'S-1-5-32-546' }) + $inherited = @(Get-NTFSAccess -Path $child -ExcludeExplicit | Where-Object -FilterScript { "$($_.Account.Sid)" -eq 'S-1-5-32-546' }) + + $all | Should -HaveCount 1 + $all[0].InheritedFrom | Should -Be $parent + $inherited | Should -HaveCount 1 + $inherited[0].InheritedFrom | Should -Be $parent + } + It 'Should read a security descriptor with audit entries and name the same folders' -Skip:(-not $holdsSecurityPrivilege) { $file = New-TestSandboxItem -Sandbox $sandbox -Name 'InheritedFromAudit' Add-NTFSAccess -Path $file -Account 'S-1-1-0' -AccessRights ReadData diff --git a/Tests/Repository.Tests.ps1 b/Tests/Repository.Tests.ps1 index 66ce3f1..d13c8cd 100644 --- a/Tests/Repository.Tests.ps1 +++ b/Tests/Repository.Tests.ps1 @@ -117,9 +117,20 @@ Describe 'Invoke-TestsAsBasicUser.ps1' { $bindParameters = [scriptblock]::Create($ast.ParamBlock.Extent.Text) } - # The title goes into a quoted argument of cmd.exe, which expands environment variables also inside quotes. - It 'Should refuse a title with the character <_>, which would change the command line of cmd.exe' -ForEach @('%', '"') { - { & $bindParameters -ResultPath 'TestResults\Refused.xml' -Title "CI run $_ 1" } | + # The title goes into a quoted argument of cmd.exe, which expands environment variables also inside quotes and ends + # the command at a line break. + It 'Should refuse a title with the character , which would change the command line of cmd.exe' -ForEach @( + @{ Name = '%'; Character = '%' } + @{ Name = 'double quote'; Character = '"' } + @{ Name = 'line feed'; Character = "`n" } + @{ Name = 'carriage return'; Character = "`r" } + ) { + { & $bindParameters -ResultPath 'TestResults\Refused.xml' -Title "CI run $Character 1" } | + Should -Throw -ExpectedMessage "*'Title'*" + } + + It 'Should refuse a title that ends with a line break' { + { & $bindParameters -ResultPath 'TestResults\Refused.xml' -Title "CI run`n" } | Should -Throw -ExpectedMessage "*'Title'*" } diff --git a/Tests/SecurityDescriptor.Tests.ps1 b/Tests/SecurityDescriptor.Tests.ps1 index 5fad078..6be3eeb 100644 --- a/Tests/SecurityDescriptor.Tests.ps1 +++ b/Tests/SecurityDescriptor.Tests.ps1 @@ -311,4 +311,24 @@ Describe 'Comparing security descriptors' { $table[$sd] | Should -Be 'first' $table.Count | Should -Be 2 } + + # Before 5.0.0-rc6, the conversion returned a field that was never set, so it gave $null. + It 'Should convert to the security object of .NET that it holds' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'ConvertFile' + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'ConvertFolder' -Directory + $fileSd = Get-NTFSSecurityDescriptor -Path $file + $folderSd = Get-NTFSSecurityDescriptor -Path $folder + + [object]::ReferenceEquals([System.Security.AccessControl.FileSecurity] $fileSd, $fileSd.SecurityDescriptor) | Should -BeTrue + [object]::ReferenceEquals([System.Security.AccessControl.DirectorySecurity] $folderSd, $folderSd.SecurityDescriptor) | Should -BeTrue + } + + It 'Should be equal only to a descriptor of the module, in both directions' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'Symmetric' + $sd = Get-NTFSSecurityDescriptor -Path $file + $raw = $sd.SecurityDescriptor + + $sd.Equals($raw) | Should -BeFalse + $raw.Equals($sd) | Should -BeFalse + } } From 608621cd78658213e459c5a9c06f9d658d987f65 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Thu, 8 Oct 2026 13:11:11 +0000 Subject: [PATCH 32/33] docs: record the lab acceptance of the final 5.0.0-rc6 candidate The acceptance ran three times, for acfe3af, 1b9edbb, and 7b0781f, the last commit that changes the module; each run passed 326 tests in both editions with no failure. The record now describes the run of 7b0781f, with its hashes, readiness, checkpoint, and the checked removal of the fixture after each run, and names the earlier runs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- Tests/Lab/Acceptance-2026-10-08-5.0.0-rc6.md | 68 +++++++++++++------- 1 file changed, 43 insertions(+), 25 deletions(-) diff --git a/Tests/Lab/Acceptance-2026-10-08-5.0.0-rc6.md b/Tests/Lab/Acceptance-2026-10-08-5.0.0-rc6.md index e91e245..1e5f73d 100644 --- a/Tests/Lab/Acceptance-2026-10-08-5.0.0-rc6.md +++ b/Tests/Lab/Acceptance-2026-10-08-5.0.0-rc6.md @@ -4,10 +4,17 @@ Acceptance of the release candidate 5.0.0-rc6 in the lab, on 2026-10-08, before the pull request. It follows the procedure in the [README](README.md#acceptance-of-a-release-candidate). +The candidate changed twice during the acceptance. The run of `acfe3af` +passed; the coverage report of the candidate then found three defects, +which `0df2482` and `1b9edbb` fix, and the run of `1b9edbb` passed as +well; a second review led to `7b0781f`. This record describes the run of +`7b0781f`, the last commit of the pull request that changes the module, +and names the results of the earlier runs. + ## Candidate - Branch `ai/release-5.0.0-rc6`, commit - `acfe3af4cf184e6b12b0bf9b20a8a4d65149b01c`, 26 commits on `fcb370e` + `7b0781ff8bb2c1ee4087a16411acd4c7070ba1fa`, 31 commits on `fcb370e` (5.0.0-rc5). - Release build of that commit, packaged with `.github\scripts\New-ModulePackage.ps1`. The live tests imported the @@ -17,10 +24,10 @@ before the pull request. It follows the procedure in the | SHA-256 | File | | --- | --- | -| `30F9694556CB3ADB57D0455AC8E917FA7DC405770DAD3F8C37F42337AECE6374` | `NTFSSecurity.5.0.0-rc6.nupkg` | -| `2A1E5482A67167658A33C7977D7251681F25D4EA437C06B441B5EAAC1016EF55` | `NTFSSecurity.zip` | -| `D308BD24061DEBB633F7A11C924D6347457C530924ACDD4893BEA48BEC58B63E` | `NTFSSecurity\NTFSSecurity.dll` | -| `32C8EA2A55F8721F7953A4E1DE382E1DA6D1CD4BBA844347DD1CA4DA38661D04` | `NTFSSecurity\Security2.dll` | +| `E99B5123F45E4F56AC005C629C2241DC616B2AAC152C17EA57A67C0823FFCA10` | `NTFSSecurity.5.0.0-rc6.nupkg` | +| `53C020EAD59467A407ED755F3D9296E9C70AFFAB184CF9CDF27AF92117FBBEE0` | `NTFSSecurity.zip` | +| `F438D7FDB3F5A1D75F5CA48D7B610EED31215FF1BF9C185C6856AA365D195C8D` | `NTFSSecurity\NTFSSecurity.dll` | +| `EE1B0DF9619C998A4482F3F79DCB2191BBEAD859660D6D924D1F333DBE7CBBCE` | `NTFSSecurity\Security2.dll` | | `902157ABBD2E0B76DA744A918BDD174D5226C3494908ABA75F9E5DE28AE6A008` | `NTFSSecurity\ProcessPrivileges.dll` | | `E2077AFEB38703345AE7857C1266F8B26E167ED887BFFAC8C8169A8F267BE6E9` | `NTFSSecurity\PrivilegeControl.dll` | | `A8DA47194AB0F71232C69D01955AD93BA73C7ECEB58D0DE800CA085D4A2E18D8` | `NTFSSecurity\AlphaFS.dll` | @@ -29,19 +36,22 @@ before the pull request. It follows the procedure in the | `59583423241951EBE0FC2D8237D0C28C3ECC8C7CD2C115D2660F8579888632FC` | `NTFSSecurity\NTFSSecurity.Init.ps1` | | `FB0920CC37ED858F55AFD54998DC854E27FBBE6A0177CB059CB03CFE91361197` | `NTFSSecurity\NTFSSecurity.format.ps1xml` | | `CB6882FF91E6716605D5599E7B464C3346E461216ACED07E847621738F04FB9B` | `NTFSSecurity\NTFSSecurity.types.ps1xml` | -| `DF9657E224E1DDA6D933099A3A09F7E794391B307FD4DDD1C8358049961BF146` | `NTFSSecurity\en-US\NTFSSecurity.dll-Help.xml` | +| `5115D0D76CA2A06795CD754539AC7EC19A70591E8C5616E7BEB5AE66E6971E6D` | `NTFSSecurity\en-US\NTFSSecurity.dll-Help.xml` | ## Tests without a lab -The Pester suite of the commit, 657 tests, against the same build. No test +The Pester suite of the commit, 684 tests, against the same build. No test failed, and every test ran in at least one configuration. | Configuration | Passed | Failed | Skipped | | --- | ---: | ---: | ---: | -| Windows PowerShell 5.1, elevated | 635 | 0 | 22 | -| PowerShell 7, elevated | 605 | 0 | 52 | -| Windows PowerShell 5.1, basic user | 566 | 0 | 91 | -| PowerShell 7, basic user | 536 | 0 | 121 | +| Windows PowerShell 5.1, elevated | 662 | 0 | 22 | +| PowerShell 7, elevated | 632 | 0 | 52 | +| Windows PowerShell 5.1, basic user | 590 | 0 | 94 | +| PowerShell 7, basic user | 560 | 0 | 124 | + +The C# coverage of the four configurations, measured with AltCover on +`1b9edbb`: 68.1% of the lines and 44.3% of the branches. ## Lab @@ -57,19 +67,22 @@ Windows Server 2025 Datacenter (10.0.26100). | `F2DC1` | `forest2.net` | Account of another forest | | `F3DC1` | `forest3.net` | Account of another forest | -Readiness, 11:06 to 11:07 UTC: WinRM answered on all six machines. The -four domain controllers answered LDAP (RootDSE, synchronized) and issued a -Kerberos ticket for `krbtgt`. The client and the file server found a -domain controller, had a working secure channel, and got a service ticket -for each other. The clocks were 4.3 to 5.0 seconds ahead of the host. +Readiness, checked before each run: WinRM answered on all six machines. +The four domain controllers answered LDAP (RootDSE, synchronized) and +issued a Kerberos ticket for `krbtgt`. The client and the file server +found a domain controller, had a working secure channel, and got a service +ticket for each other. The clocks were 4.3 to 5.0 seconds ahead of the +host. -Checkpoint `ntfs-rc6-acfe3af-before-acceptance` (Production) of the six -machines, taken 11:07 to 11:08 UTC before the run. +Checkpoints (Production) of the six machines, taken before each run: +`ntfs-rc6-acfe3af-before-acceptance` (11:07 UTC), +`ntfs-rc6-1b9edbb-before-acceptance` (12:21 UTC), and +`ntfs-rc6-7b0781f-before-acceptance` (12:50 UTC). ## Results `Invoke-NTFSSecurityLabTest.ps1 -ModulePath ` in both -editions, 11:08 to 11:25 UTC. The module reported version 5.0.0-rc6 in +editions, 12:51 to 13:07 UTC. The module reported version 5.0.0-rc6 in every role that loads it. | Edition | Role | Passed | Failed | Skipped | @@ -88,6 +101,9 @@ load the module. The accounts of the other domain and forests were `B\NtfsLiveForeign`, `forest2\NtfsLiveForeign`, and `forest3\NtfsLiveForeign`; their 13 tests passed in both editions. +The earlier runs had the same counts and no failure: `acfe3af` from 11:08 +to 11:25 UTC, and `1b9edbb` from 12:23 to 12:39 UTC. + ## Baseline The published 5.0.0-rc5 from the PowerShell Gallery, whose hash the script @@ -102,10 +118,12 @@ cover the other fixes of 5.0.0-rc6. ## Cleanup -`Invoke-NTFSSecurityLabTest.ps1 -RemoveFixture`, 11:38 UTC, after the -baseline. The check compared the lab with the 10 SIDs of the fixture's -accounts and groups, read before the removal; the same check had found the -fixture before the removal: +`Invoke-NTFSSecurityLabTest.ps1 -RemoveFixture` after each run: at 11:38 +UTC after the first run and the baseline, at 12:48 UTC after the second, +and at 13:09 UTC after the third. Each check compared the lab with the 10 +SIDs of the fixture's accounts and groups, read before the removal; the +same check had found the fixture before the first removal. After each +removal: - No domain has the organizational unit `NTFSSecurityLive` or an account whose name starts with `NtfsLive`. @@ -117,8 +135,8 @@ fixture before the removal: and Remote Management Users have no member of the fixture, and no profile of the fixture's accounts is left. -The checkpoint `ntfs-rc6-acfe3af-before-acceptance` stays on the six -machines until the maintainer deletes it. +The three checkpoints stay on the six machines until the maintainer +deletes them. ## Not covered From be04cb7132a748e32f2390f853443d5301c3d538 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Thu, 8 Oct 2026 13:11:11 +0000 Subject: [PATCH 33/33] chore(memory-bank): record Phase 2 of the quality gate and 5.0.0-rc6 Phase 2 is complete on the branch: the fixes, the basic-user CI, the live tests of all cmdlet groups, three passing lab acceptances, two reviews, and the coverage across the four configurations, measured again without --save, which kept only one run. The behavior changes for the maintainer and the reachable code that no test runs are open work. The Set-Acl pitfall of the test fixtures is a pattern. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- .memory-bank/activeContext.md | 147 ++++++++---------- .../0021-quality-gate-before-5.0.0.md | 4 +- .memory-bank/progress.md | 92 +++++++++-- .memory-bank/systemPatterns.md | 15 +- .memory-bank/techContext.md | 62 ++++++-- 5 files changed, 200 insertions(+), 120 deletions(-) diff --git a/.memory-bank/activeContext.md b/.memory-bank/activeContext.md index f386e2e..c6136ce 100644 --- a/.memory-bank/activeContext.md +++ b/.memory-bank/activeContext.md @@ -9,95 +9,70 @@ source: current task evidence ## Current focus -Phase 2 of the quality gate before 5.0.0 (Decision 21), on the branch -`ai/release-5.0.0-rc6`, released as 5.0.0-rc6: tests until every code -path is tested or explained, live tests for the remaining cmdlets, and -test-first fixes of the known defects. The maintainer approved it on -2026-10-08. Then Phase 3 and 5.0.0; after 5.0.0 the repository is archived -in favor of WindowsAccessControl (Decision 18). +Phase 2 of the quality gate before 5.0.0 (Decision 21) is complete on the +local branch `ai/release-5.0.0-rc6`, candidate `7b0781f`. The maintainer +pushes the branch, merges the pull request after CI, and tags 5.0.0-rc6. +Then Phase 3 (live tests on more operating systems) and 5.0.0; after +5.0.0 the repository is archived in favor of WindowsAccessControl +(Decision 18). ## Evidence -- 2026-10-08, Phase 1, measured on 5.0.0-rc5 (`fcb370e`): - - The published package passes the live tests in both editions: 8 role - runs, no failure. - - As a basic user, the 11 tests that CI skips pass in both editions. The - one failure, `Enable-Privileges should write one object per - privilege`, assumes more than one privilege. Every test runs in at - least one of four configurations (elevated or basic user, two - editions), but CI runs only elevated. - - The suite runs 55.9% of the C# lines and 37.4% of the branches - (`techContext.md`, Validation). No line of `Test-Path2` and - `Get-DiskSpace` runs; `Set-NTFSOwner` (46%) runs only as a setup step - of another test; `Clear-NTFSAccess` and the access inheritance cmdlets - run about 43%, `FileSystemSecurity2` 42%. No cmdlet calls the registry - classes, `SimpleFileSystemAuditRule`, `PrivilegeEnabler`, or - `FileSystemEffectivePermissionEntry` (244 lines). - - 19 of the 36 cmdlets never ran over SMB, among them `Get-NTFSOwner`, - `Set-NTFSOwner`, the audit inheritance cmdlets, and `Clear-NTFSAudit`; - no account of another domain or forest ran; both ends of the lab run - Windows Server 2025. -- Known defects from the reviews of #113 and #114 (`progress.md`, open - work 3): `Move-Item2 -PassThru` returns the source item; the conflict - checks of `Copy-Item2` and `Move-Item2` use `File.Exists` also for - folders, maybe the cause of #21; an error while restoring the owner can - hide the original one (R4); `Set-NTFSSecurityDescriptor -PassThru` reads - the item again inside the retry (R5); the access check doesn't read - `AUTHZ_ACCESS_REPLY.Error`, and its buffers aren't initialized. #110 - lists seven test follow-ups. -- #34: no reply from the tester by 06:44 UTC on 2026-10-08. -- Phase 2, step 1, first part (2026-10-08, commits `82969ba` to - `ff74100`): 34 new tests for `Set-NTFSOwner`, `Test-Path2`, and - `Get-DiskSpace`, and two defects found and fixed test-first. Every - cmdlet that enables privileges left the Backup, Restore, Take Ownership, - and Security privileges enabled in the session when a later command or a - terminating error stopped the pipeline; `Test-Path2` stopped with - "Illegal characters in path" in Windows PowerShell for a path such as - `C:\a|b`. -- Phase 2, step 1, link cmdlets (2026-10-08, `4d5c8c4`, `09eb337`): 18 new - tests for `New-NTFSHardLink`, `Get-NTFSHardLink`, and - `New-NTFSSymbolicLink` pass elevated and as a basic user in both - editions; no code defect. The page of `New-NTFSSymbolicLink` was wrong - twice: `-PassThru` returns a folder object for a link to a folder since - 5.0.0, and Windows Developer Mode doesn't help, because AlphaFS 2.2.1 - passes only the File or Directory flag to `CreateSymbolicLinkW`. Lab - check on `F1AFile1` as `NtfsLiveServerAdmin` (no administrator): - with Developer Mode on, `mklink` created a link and the cmdlet of - 5.0.0-rc5 failed with error 1314; the setting was restored. -- Security review of `fcb370e..00c3646` (`security-reviewer`, 2026-10-08): - the `Dispose` approach is sound; two Major findings, both one root cause - that 4.2.6 already had: the privilege cleanup decided on the states read - in `BeginProcessing` and stopped at the first failure. Reproduced: a - privilege that another command in the pipeline disabled left the others - enabled (silently after an early stop), and - `Get-NTFSOwner ... | ForEach-Object { Disable-Privileges; $_ }` stopped - with "Priviledge already disabled". Two Minor findings: the early-stop - tests could pass vacuously, and `Test-Path2` gave no reason for `$false`. - The maintainer chose to fix all four; fixed test-first in `578042f` and - `b241441`. The suite (555 tests) passes elevated in both editions - (534/0/21 and 504/0/51), and the changed test files pass as a basic user. +- 2026-10-08, Phase 2 on `ai/release-5.0.0-rc6`, 31 commits on `fcb370e` + (5.0.0-rc5); the candidate is `7b0781f`: + - The suite has 684 tests. Elevated: 662 passed and 22 skipped in + Windows PowerShell 5.1, 632 and 52 in PowerShell 7. As a basic user + through `Invoke-TestsAsBasicUser.ps1`: 590 and 94, 560 and 124. No + failure, and no test is skipped in all four configurations; CI runs + all four since this branch. + - C# coverage of all four configurations (AltCover without `--save`, + `techContext.md`): 68.1% of the lines and 44.3% of the branches on + `1b9edbb`; rc5 58.1% and 38.0%. The earlier figures counted one of + the four runs. Of the 972 points that no test ran at `e2b6b24` + (without the classes that no cmdlet calls), 400 are code that nothing + calls, 107 defensive guards, 74 need a failure of Windows, 4 need the + lab, and 387 are reachable; 51 of those ran in runs that the first + measurement missed, and the top items of the rest are in + `progress.md`, open work 7. + - Fixed test-first: `Get-NTFSSimpleAccess` (`ReadData`, the parent of a + relative path); `Copy-Item2` and `Move-Item2` (a folder at the + destination, the missing destination folder of #21, also with + `-WhatIf`); the hard-link cmdlets on shares and at folders; + `Set-NTFSSecurityDescriptor -PassThru` (R5); the error ID of + `Get-NTFSOrphanedAccess`; relative paths that start with a dot, which + every cmdlet shortened by two characters (`Remove-Item2 .x` removed + another item); comparing entries and descriptors + (`InvalidCastException`, also `Compare-Object` in PowerShell 7) and + their conversions; `InheritedFrom` with `-ExcludeExplicit` and for a + descriptor with audit entries (`ArgumentOutOfRangeException`). + `Copy-Item2` no longer creates the missing folders of a destination + for a folder (assumption, flagged for the maintainer). + - Live tests: cases 4b to 9 and accounts of `b.forest1.net`, + `forest2.net`, and `forest3.net`. The lab acceptance passed for + `acfe3af`, `1b9edbb`, and `7b0781f` (326 tests each, none failed; + `Tests/Lab/Acceptance-2026-10-08-5.0.0-rc6.md`). Baseline: the + published 5.0.0-rc5 fails only the two hard-link tests of case 8. The + fixture was removed and the removal checked after each run; three + checkpoints `ntfs-rc6-*-before-acceptance` stay on six machines. + - `Get-NTFSEffectiveAccess -ServerName` needs administrators or + Access Control Assistance Operators on the named computer (lab probe, + documented). + - Two `security-reviewer` passes approved the branch with no Blocker or + Major finding: `fcb370e..e2b6b24` (findings 1, 4, 5, 10 fixed in + `acfe3af`) and `e2b6b24..1b9edbb` (findings 1, 2, 6, 7 fixed in + `7b0781f`). Not fixed: a failed privilege disable isn't tried again, + and a non-qualified ACE could shift `InheritedFrom` (neither + reproducible); `New-NTFSHardLink` stays terminating for a folder (a + behavior change for the maintainer). +- #34: no reply from the tester since 2026-10-06. ## Next step -Phase 2, one step at a time, each with evidence before the next: - -1. Tests for the cmdlets without tests of their own: done for - `Set-NTFSOwner`, `Test-Path2`, `Get-DiskSpace`, and the link cmdlets; - open for `Get-NTFSOrphanedAudit` and `Get-NTFSSimpleAccess`. -2. The other parameter sets and error paths, such as the - `-SecurityDescriptor` sets of the inheritance cmdlets and of - `Clear-NTFSAccess`. -3. The paths of `Security2` that no test runs, and #110. -4. Test-first fixes of the known defects; behavior changes go to the - maintainer (Decision 16). -5. A CI job as a basic user, live tests for the other cmdlets over SMB and - for accounts of other forests, and a lab run. -6. Measure the coverage again, explain what remains, review, and prepare - 5.0.0-rc6. The final review covers the whole branch, including the fix - round of `578042f` and `b241441`. - -Open question for the maintainer, not planned: `New-NTFSSymbolicLink` -could request unprivileged creation with -`SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE`, so that Developer Mode -works. That is a behavior change (Decision 16) and needs a fallback for -Windows versions before 10 1703, which don't know the flag. +1. The maintainer pushes the branch, opens the pull request, merges it + after CI, and tags `5.0.0-rc6`; then the live tests run against the + published package (`-Version 5.0.0-rc6`). The first CI run is the first + run of `Invoke-TestsAsBasicUser.ps1` on a GitHub runner. +2. The maintainer decides the behavior changes in `progress.md`, open + work 4 (Decision 16), and the scope of Phase 3: the operating systems, + the code that nothing calls, and file servers that aren't Windows + (#34). diff --git a/.memory-bank/decisions/0021-quality-gate-before-5.0.0.md b/.memory-bank/decisions/0021-quality-gate-before-5.0.0.md index d32a8d7..5664903 100644 --- a/.memory-bank/decisions/0021-quality-gate-before-5.0.0.md +++ b/.memory-bank/decisions/0021-quality-gate-before-5.0.0.md @@ -28,7 +28,9 @@ source: maintainer decision of 2026-10-08 of the matrix. - Rationale: rc5 passed every test that ran, but the tests ran 55.9% of the code lines and 37.4% of the branches; five cmdlets had no tests of - their own, and 19 cmdlets never ran over SMB. + their own, and 19 cmdlets never ran over SMB. (That measurement counted + only one of the four test runs; with all four, rc5 runs 58.1% of the + lines and 38.0% of the branches, see `techContext.md`.) - Open: behavior changes found on the way stay the maintainer's decision (Decision 16); so do the 244 lines of classes that no cmdlet calls, the operating systems of Phase 3, and how to cover file servers that aren't diff --git a/.memory-bank/progress.md b/.memory-bank/progress.md index c9435e0..435d20f 100644 --- a/.memory-bank/progress.md +++ b/.memory-bank/progress.md @@ -11,13 +11,13 @@ source: repository evidence 5.0.0-rc5 is on the PowerShell Gallery and in the GitHub releases, published by CI on 2026-10-08 from the tag `5.0.0-rc5` on `master` -(`fcb370e`, the merge of #114; Decision 12). It adds to 5.0.0-rc4 the live -tests in a lab (Decision 20) and the fix of -`Get-NTFSEffectiveAccess -ServerName` that they found. Before 5.0.0, the -maintainer wants the highest quality with everything tested: the quality -gate of Decision 21, now in Phase 2, which ends with 5.0.0-rc6. The stable -Gallery version is still 4.2.6. NTFSSecurity will be archived soon; its -users move to WindowsAccessControl (Decision 18). +(`fcb370e`, the merge of #114; Decision 12). Phase 2 of the quality gate +(Decision 21) is complete on the local branch `ai/release-5.0.0-rc6`: the +candidate 5.0.0-rc6 (`acfe3af`) passed the suite in four configurations, +one `security-reviewer` pass, and the lab acceptance. It waits for the +maintainer to push, merge, and tag it; Phase 3 follows. The stable Gallery +version is still 4.2.6. NTFSSecurity will be archived soon; its users move +to WindowsAccessControl (Decision 18). ## Recent milestones @@ -83,6 +83,23 @@ users move to WindowsAccessControl (Decision 18). 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`). ## Stable capabilities @@ -97,15 +114,20 @@ users move to WindowsAccessControl (Decision 18). ## Open work -1. Quality gate before 5.0.0 (Decision 21): Phase 2 on the branch - `ai/release-5.0.0-rc6` (`activeContext.md`), released as 5.0.0-rc6; - Phase 3 runs the live tests on more operating systems. Then release +1. Quality gate before 5.0.0 (Decision 21): Phase 2 is done on the branch + `ai/release-5.0.0-rc6` (`activeContext.md`); the maintainer pushes it, + merges the pull request, and tags 5.0.0-rc6, and the live tests run + against the published package. 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: #110 (tests) is the open follow-up of the review findings; #68 +2. Issues: the rc6 branch addresses the seven items of #110 (tests); the + pull request names it without a closing keyword, so the maintainer + closes it after the merge. #21 (a misleading error of `Move-Item2`) got + a fix in rc6 that names the missing destination folder. #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, @@ -119,11 +141,51 @@ users move to WindowsAccessControl (Decision 18). `AUTHZ_ACCESS_REPLY.Error`, a fallback warning without the server name, 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). -4. `pwsh` 7.6.1 crashed three times during test runs on the ARM64 + 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). +4. Behavior changes found in Phase 2, for the maintainer (Decision 16): + `Get-NTFSOrphanedAudit` returns nothing without the Security privilege, + while `Get-NTFSAudit` writes `ReadSecurityError`; + `Get-NTFSSimpleAccess` skips a folder whose parent it didn't process; + `New-NTFSSymbolicLink` could create links without the privilege in + Developer Mode (flag `SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE`, with + a fallback before Windows 10 1703); `-WhatIf` names a conflict in a + verbose message instead of a warning (R7); the fallback warning of + `Get-NTFSEffectiveAccess` doesn't name the server; `Move-Item2` can't + move a folder to another volume; `New-NTFSHardLink` stops with a + terminating error for a folder, unlike `Get-NTFSHardLink` (rc6 review, + finding 11). Taken as an assumption in rc6, for review: `Copy-Item2` + no longer creates the missing folders of a destination for a folder. + Found by the coverage report of rc6: `-Target` of the link cmdlets is + optional and means the current location when it's omitted; equality of + entries and descriptors is the identity of the wrapped .NET object, so + two reads of the same entry differ for `Compare-Object` and + `Select-Object -Unique` (value equality would be a behavior change); 400 + points of code that nothing calls besides the 244 lines of unused + classes (Phase 3). +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. -5. Optional for the maintainer: delete the AppVeyor project and revoke its +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`. + `test/transfer`. In the lab, delete the checkpoints + `ntfs-rc6-*-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. diff --git a/.memory-bank/systemPatterns.md b/.memory-bank/systemPatterns.md index 44118f5..5b9208e 100644 --- a/.memory-bank/systemPatterns.md +++ b/.memory-bank/systemPatterns.md @@ -99,9 +99,20 @@ Each Decision record is a file in `decisions/`; read only the relevant ones. - 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` and run in CI (elevated). `Block-Test*` - make a read or a write fail without elevation; `Set-TestOwner` with + 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 diff --git a/.memory-bank/techContext.md b/.memory-bank/techContext.md index 84e6aaf..9b949e1 100644 --- a/.memory-bank/techContext.md +++ b/.memory-bank/techContext.md @@ -88,7 +88,8 @@ source: repository evidence ## Constraints -- `ModuleVersion` is `5.0.0` with the prerelease label `rc5`. +- `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 @@ -143,7 +144,9 @@ source: repository evidence 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. Job `wiki` on `ubuntu-latest` + 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, @@ -163,7 +166,8 @@ source: repository evidence ci.yml`, `gh pr checks `, and `gh run view --log-failed` (read-only). - Workflow lint: actionlint (download the release zip into `$env:TEMP` and - check its SHA-256 against the checksum file); PowerShell steps check + 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+ @@ -175,25 +179,37 @@ source: repository evidence 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 CI and in an elevated - session. Run them as a basic user with `runas /trustlevel:0x20000`, and - give Windows PowerShell its own `PSModulePath`; that token holds one - privilege, so the `Enable-Privileges -PassThru` count test fails there. - `runas` returns at once, so the script it starts writes its own log. +- 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: `--save`, + which has the PDB files that the published package lacks: `--reportFormat=OpenCover`, AlphaFS and `System.Management.Automation` - excluded with `--assemblyFilter`. Put the instrumented module in - `NTFSSecurity\bin\Release` of a `git worktree`, run - `.github\scripts\Invoke-Tests.ps1` elevated and as a basic user in both - editions, then `AltCover.exe runner --collect --recorderDirectory=`. Baseline of the rc5 tree on 2026-10-08: 55.9% of - the lines (1,943 of 3,476), 37.4% of the branches (700 of 1,873); 60.1% - of the lines without 244 lines in classes that no cmdlet calls. + excluded with `--assemblyFilter`, and no `--save`: then every process + writes its hits into the report when it exits. With `--save`, each + process writes a recorder file, and `runner --collect` keeps only the + first one (verified 2026-10-08), so the numbers measured that way held + only the main process of the elevated Windows PowerShell run. Put the + instrumented module in `NTFSSecurity\bin\Release` of a `git worktree`, + run `.github\scripts\Invoke-Tests.ps1` elevated and + `Invoke-TestsAsBasicUser.ps1` in both editions, then + `AltCover.exe runner --collect --recorderDirectory=`, which recalculates the summary of the report from the hits. + All four configurations, 2026-10-08: the rc5 tree 58.1% of the lines + (2,020 of 3,476) and 38.0% of the branches (711 of 1,873), 62.5% without + 244 lines in classes that no cmdlet calls; the rc6 candidate (`1b9edbb`) + 68.1% of the lines (2,412 of 3,540) and 44.3% of the branches (850 of + 1,918), 73.2% without those classes, the `NTFSSecurity` assembly 78.1%. + The earlier figures, 55.9% for rc5 and 65.6% for rc6, used `--save`. - Live tests (Decision 20): in an elevated Windows PowerShell 5.1 session on the lab host, `Tests\Lab\Invoke-NTFSSecurityLabTest.ps1` with `-Version` for Gallery packages or `-ModulePath` for a build; it writes @@ -204,6 +220,20 @@ source: repository evidence (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-