diff --git a/.memory-bank/activeContext.md b/.memory-bank/activeContext.md index c6136ce..2ad5297 100644 --- a/.memory-bank/activeContext.md +++ b/.memory-bank/activeContext.md @@ -9,70 +9,62 @@ source: current task evidence ## Current focus -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). +5.0.0-rc6 is on the PowerShell Gallery (tag `5.0.0-rc6` on `b51d970`, the +merge of #115); its GitHub release waits for a rerun of the failed Release +job. #116 (`ai/release-5.0.0-rc7`, base `master`) holds the behavior +changes that Phase 2 found, decided as assumptions for the maintainer's +review (Decision 22), and waits for that review. Then 5.0.0-rc7, Phase 3, +and 5.0.0; after 5.0.0 the repository is archived in favor of +WindowsAccessControl (Decision 18). ## Evidence -- 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). +- 2026-10-08, 5.0.0-rc6: the Release job of the tag (run `37839669028`) + published the package at 20:40 UTC and failed after it, because + `Publish-PSResource` gave up waiting after 100 seconds and its retry got + 409 (`progress.md`, open work 8). The live tests of rc7 ran with + `-Version 5.0.0-rc6`, which checks the hash of the Gallery, in both + editions, 20:43 to 21:00 UTC: all passed except the warning text that + rc7 changed, which matches the live tests of rc6. The fixture was + removed at 21:03 UTC and its removal checked. +- 2026-10-08, #116, 11 commits on `be04cb7` (`3899228` to `1063b29`) and + commits of records: + - Decision 22: items 1, 2, 5, 6, 7, and 8 changed, 7 and 8 breaking (the + link cmdlets require `-Path` and `-Target` and write non-terminating + errors); items 3, 4, 9, and 10 kept, 9 with an FAQ entry. New defects, + fixed with a regression test that failed first: `Move-Item2` deleted + an empty folder that it moved to another volume (AlphaFS emulated the + move); the link cmdlets failed with `GetDefaultValueFailed` for every + piped object; `Get-NTFSSimpleAccess` failed for a folder that came + after its parent folder a second time. + - One `security-reviewer` pass over `be04cb7..4ee01e5`: no Blocker or + Major. Minor 1 to 5 and Nits 7 to 9 fixed test-first in `7936d9f` to + `1063b29`; Nit 7, the warning of `Get-NTFSEffectiveAccess` for names + of this computer, was reproduced first. Nit 6 declined (Decision 22). + - Suite of `1063b29`: 712 tests. Elevated: 688 passed and 24 skipped in + Windows PowerShell 5.1, 658 and 54 in PowerShell 7. As a basic user: + 612 and 100, 582 and 130. No failure, none skipped in all four. + - Lab acceptance of `dc6e9f5` after the checkpoint + `ntfs-rc7-dc6e9f5-before-acceptance`, 16:24 to 16:40 UTC: 326 tests in + both editions, none failed, 2 skipped as in rc6 + (`Tests/Lab/Acceptance-2026-10-08-5.0.0-rc7.md`). The code of + `4ee01e5` and a first run of `dc6e9f5` without the checkpoint had the + same counts. The fixture was removed at 16:21 and 16:44 UTC, and its + removal checked each time. +- #115 passed CI in all four configurations on `be04cb7`, with the first + runs of `Invoke-TestsAsBasicUser.ps1` on GitHub runners; #116 passed CI + on `ebe91fe` against the rc6 branch. - #34: no reply from the tester since 2026-10-06. ## Next step -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). +1. The maintainer reruns the failed Release job of 5.0.0-rc6, which skips + the published package and creates the GitHub release, and closes #110. +2. He pushes the records to #116 and reviews the choices of Decision 22, + each its own commit, above all the two breaking changes of the link + cmdlets. After the CI of the push, he merges #116 with a merge commit + (Decision 15) and tags `5.0.0-rc7`; the live tests then run against the + published package (`-Version 5.0.0-rc7`). +3. He decides the fix of the publish step (`progress.md`, open work 8) and + the scope of Phase 3: the operating systems, the code that nothing + calls, and file servers that aren't Windows (#34). diff --git a/.memory-bank/decisions/0022-phase-2-behavior-changes.md b/.memory-bank/decisions/0022-phase-2-behavior-changes.md new file mode 100644 index 0000000..f59d750 --- /dev/null +++ b/.memory-bank/decisions/0022-phase-2-behavior-changes.md @@ -0,0 +1,53 @@ +--- +status: proposed +date: 2026-10-08 +last-verified: 2026-10-08 +owner: shared +source: agent choices in autopilot on 2026-10-08, for the maintainer's review +--- + +# Decision 22: The behavior changes of Phase 2 + +- Context: Decision 16 left the behavior changes that Phase 2 found to the + maintainer (`progress.md`, open work 4). On 2026-10-08 he asked to work + on them while the pull request of 5.0.0-rc6 (#115) built, in autopilot; + the agent took the recommended option for each. Every choice is an + assumption for his review. Each change is its own commit on + `ai/release-5.0.0-rc7` (`3899228` to `4ee01e5`, the label in `d17e0f7`, + the review fixes in `7936d9f` to `1063b29`). A revert can conflict where + a later commit touched the same page or test, and then needs the help + file generated again. +- Choices: + +| # | Item | Choice | +| --- | --- | --- | +| 1 | `Get-NTFSOrphanedAudit` returned nothing without the Security privilege | Changed: `ReadSecurityError`, like `Get-NTFSAudit`; a missing path keeps `ReadError` | +| 2 | `Get-NTFSSimpleAccess` left out a folder whose parent it hadn't reported | Fixed: such a folder, also a drive root, gets all entries; a repeated folder no longer fails with `ReadError` | +| 3 | Developer Mode for `New-NTFSSymbolicLink` | Kept as documented: new P/Invoke and fallback code before the archive (Decision 18) | +| 4 | `-WhatIf` names a conflict in a verbose message, not a warning (R7) | Kept, as for #108 and the missing destination folder | +| 5 | The fallback warning of `Get-NTFSEffectiveAccess` didn't name the server | Changed: it names the computer | +| 6 | `Move-Item2` can't move a folder to another volume | Kept the refusal; fixed what was found: with `CopyAllowed`, AlphaFS copied and deleted folders and lost empty ones. Now a `MoveError` that names folder and destination | +| 7 | The link cmdlets stopped with terminating errors | **Breaking:** a non-terminating error per link, and the next link | +| 8 | `-Path` and `-Target` of the link cmdlets were optional | **Breaking:** required. An omitted `-Path` failed with an index error, an omitted `-Target` meant the current location | +| 9 | Entries and descriptors are equal only as the same .NET object | Kept the equality of .NET; the FAQ shows `Compare-Object -Property` | +| 10 | `Copy-Item2` doesn't create the missing destination folders (rc6) | Kept, like `Copy-Item` and `Move-Item2` | + +- Found on the way and fixed: every object piped to the link cmdlets + failed with `GetDefaultValueFailed` (item 8). Item 6 is not the cause of + #21, whose report used `-Force` within one volume. +- Review: one `security-reviewer` pass over `be04cb7..4ee01e5` found no + Blocker or Major issue. Fixed test-first: the link cmdlets stopped for a + path with an invalid character in Windows PowerShell, named no path in + some errors, and checked `-Path` and `-Target` in different orders; + `Get-NTFSEffectiveAccess` warned for every name of this computer except + `localhost` in lowercase (reproduced, Decision 16); the tests of the + case-insensitive parent lookup and of a drive root, and the shared + administrative-share helpers. Declined: one error ID for a missing path + in the audit cmdlets (`ReadError` and `ReadFileError`), a change of + behavior for scripts that check the ID. +- Rationale: an error instead of a result that looks valid (1, 2, 6); + per-item errors, as in the other cmdlets (7); no silent default for a + path that creates something (8); no new features before the archive + (3); the conventions of .NET and PowerShell (4, 9, 10). +- Open: the maintainer accepts or reverts each choice; then this record + becomes `accepted`. diff --git a/.memory-bank/progress.md b/.memory-bank/progress.md index 435d20f..72b5f84 100644 --- a/.memory-bank/progress.md +++ b/.memory-bank/progress.md @@ -9,15 +9,16 @@ source: repository evidence ## Current status -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). 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). +5.0.0-rc6 is on the PowerShell Gallery, published by CI on 2026-10-08 at +20:40 UTC from the tag `5.0.0-rc6` on `master` (`b51d970`, the merge of +pull request #115; Decision 12). The Release job failed after the upload, +so the GitHub release waits for a rerun of the failed job. The published +package passed the live tests. Phase 2 of the quality gate (Decision 21) +is complete. The pull request #116 (`ai/release-5.0.0-rc7`) holds the +behavior changes that Phase 2 found, decided as assumptions for the +maintainer's review (Decision 22), and waits for that review. Phase 3 +follows. The stable Gallery version is still 4.2.6. NTFSSecurity will be +archived soon; its users move to WindowsAccessControl (Decision 18). ## Recent milestones @@ -100,6 +101,23 @@ to WindowsAccessControl (Decision 18). measured again; the first measurements counted one of four runs). Two `security-reviewer` passes; the lab acceptance of `7b0781f` passed (`Tests/Lab/Acceptance-2026-10-08-5.0.0-rc6.md`). +- 2026-10-08: #115 (rc6, head `be04cb7`) passed CI in all four + configurations. On `ai/release-5.0.0-rc7` (local), the behavior changes + of Phase 2 were decided as assumptions for review (Decision 22) and + implemented test-first, two of them breaking (the link cmdlets); new + defects found on the way: `Move-Item2` deleted an empty folder that it + moved to another volume, the link cmdlets failed for every piped object, + and `Get-NTFSEffectiveAccess` warned for names of this computer. One + `security-reviewer` pass (no Blocker or Major; its findings fixed but + one, declined). Suite and lab acceptance in `activeContext.md`. +- 2026-10-08: #115 merged (`b51d970`) and tagged `5.0.0-rc6`. The Release + job published the package at 20:40 UTC, then failed: `Publish-PSResource` + gave up waiting after 100 seconds while the Gallery accepted the upload, + and its retry got 409, so the job didn't create the GitHub release. The + published package passed the live tests of rc7 in both editions except + the one test whose expected warning text rc7 changed + (`Tests/Lab/Acceptance-2026-10-08-5.0.0-rc6.md`, After the release). + #116 (5.0.0-rc7) was opened on the rc6 branch and moved to `master`. ## Stable capabilities @@ -114,20 +132,22 @@ to WindowsAccessControl (Decision 18). ## Open work -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: 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 +1. Quality gate before 5.0.0 (Decision 21): the maintainer reruns the + failed Release job of 5.0.0-rc6, which creates the GitHub release; + reviews the choices of Decision 22 in #116; merges #116 and tags + 5.0.0-rc7, whose published package then runs the live tests. Phase 3 + runs the live tests on more operating systems. Then release 5.0.0 + through CI (Decision 12): remove the label, date + `[Unreleased]` as `[5.0.0]`, add the last prerelease to + `$publishedVersions`, and tag `5.0.0` (steps in + `Docs/Contributing/05-Releasing.md`). #34 stays open with Bug and Help + Wanted until a tester with a file server that refuses the owner + confirms the fix, or until 5.0.0 ships. +2. Issues: 5.0.0-rc6 addresses the seven items of #110 (tests); #115 + named it without a closing keyword, so the maintainer closes it now. + #21 (a misleading error of `Move-Item2`) got + a fix in rc6 that names the missing destination folder; the folder + moves to another volume that rc7 fixes are a different defect. #68 tracks `-WhatIf` and `-Confirm` for every cmdlet that changes security. The labels follow Decision 17; #16, #21, #45, and #89 wait for their reporters (Needs Info). Not planned for 5.0.0: the enhancements #22, @@ -138,32 +158,17 @@ to WindowsAccessControl (Decision 18). them the accounts filter that `RemoveFileSystemAccessRuleAll` and `RemoveFileSystemAuditRuleAll` ignore, which no cmdlet passes; of rc5, the bare `catch` in `Win32.GetEffectiveAccess`, the unchecked - `AUTHZ_ACCESS_REPLY.Error`, 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); 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). + `AUTHZ_ACCESS_REPLY.Error`, and hardening of the lab controller (guards + in the setup blocks, interpolated `-EncodedCommand` paths, CredSSP by + IP address, the password string in memory, disabling the role accounts + after a run); of rc6, a privilege that fails to be disabled isn't tried + again by `Dispose` (finding 2, not reproducible); of rc7, one error ID + for a missing path in the audit cmdlets (declined, Decision 22). +4. Behavior changes found in Phase 2 (Decision 16): decided in Decision 22 + as assumptions for the maintainer's review, on `ai/release-5.0.0-rc7`; + two of them are breaking changes of the link cmdlets. Left for Phase 3: + 400 points of code that nothing calls besides the 244 lines of unused + classes. 5. `pwsh` 7.6.1 crashed three times during test runs on the ARM64 workstation (x64 emulation), without module frames; none of the CI runs on native x64 on 2026-10-05 crashed. @@ -171,8 +176,8 @@ to WindowsAccessControl (Decision 18). GitHub authorization, restrict wiki editing to collaborators, ask `Sup3rlativ3` to delete the Read the Docs project, and delete the branch `test/transfer`. In the lab, delete the checkpoints - `ntfs-rc6-*-before-acceptance` of the six machines when they are no - longer needed. + `ntfs-rc6-*-before-acceptance` and `ntfs-rc7-*-before-acceptance` of the + six machines when they are no longer needed. 7. Reachable code that no test runs (coverage report of rc6, ranked by impact; about 300 points): `Remove-Item2` on folders (`-Recurse`, `-Force`, `DeleteError`); the owner restore after taking ownership @@ -189,3 +194,10 @@ to WindowsAccessControl (Decision 18). `Set-NTFSInheritance`. A display limit, not a defect: a conditional ACE shows as an unconditional entry, because the .NET rules have no condition. +8. The publish step of the Release job fails when `Publish-PSResource` + gives up waiting after 100 seconds while the Gallery accepts the + package, because its retry gets 409 (5.0.0-rc6). Proposed for the + maintainer (Decision 16, not reproducible on demand; he was asked on + 2026-10-08 and didn't answer, so it stays open): treat the error as + success when `Find-PSResource` then lists the version, in a script with + Pester tests. Until then, rerun the failed job. diff --git a/.memory-bank/systemPatterns.md b/.memory-bank/systemPatterns.md index 5b9208e..4f71acb 100644 --- a/.memory-bank/systemPatterns.md +++ b/.memory-bank/systemPatterns.md @@ -76,9 +76,27 @@ Each Decision record is a file in `decisions/`; read only the relevant ones. | 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) | +| 22 | [The behavior changes of Phase 2 (proposed)](decisions/0022-phase-2-behavior-changes.md) | ## Patterns +### Writing cmdlets + +- A parameter that takes pipeline input needs a getter that doesn't + throw: PowerShell reads it before it binds each input object, and an + exception turns every object into `GetDefaultValueFailed` (the link + cmdlets before 5.0.0-rc7). +- An error for one item is non-terminating, so that the cmdlet goes on + with the next path or pipeline object; since 5.0.0-rc7, the link cmdlets + too. Its message names the item, and its target object is the item that + the cmdlet was asked to process. Resolving a path can throw in Windows + PowerShell for an invalid character, so that belongs inside the + per-item error handling. +- Folders move without `MoveOptions.CopyAllowed`: for another volume, + AlphaFS then copies and deletes, which lost empty folders. Windows + refuses such a move with `NotSameDeviceException` (17). Tests reach + another volume through `\\localhost\C$`, elevated only. + ### Verifying documentation - Run platyPS in Windows PowerShell 5.1 against a Release build; a copy of diff --git a/CHANGELOG.md b/CHANGELOG.md index 9659ecb..5db9b63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,18 @@ The format is based on 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 +- **Breaking:** require `-Path` and `-Target` in `New-NTFSHardLink` and + `New-NTFSSymbolicLink`. Without `-Path`, they failed with an index error; + without `-Target`, they used the current location, so + `New-NTFSSymbolicLink -Path Link` created a link to the current folder +- **Breaking:** write a non-terminating error in `New-NTFSHardLink` and + `New-NTFSSymbolicLink` for a link that they can't create, such as for an + existing `-Path`, a missing `-Target`, or a path with a character that + Windows doesn't allow, and continue with the next link; they stopped + with a terminating error. A script that relies on the stop needs + `-ErrorAction Stop` +- Name the computer in the warning of `Get-NTFSEffectiveAccess` when the + computer of `-ServerName` can't be reached ### Deprecated @@ -328,5 +340,32 @@ The format is based on 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 +- Fix `Get-NTFSOrphanedAudit`, which returned nothing without the Security + privilege, as for an item without orphaned entries, and wrote a warning + for an item that it couldn't read; it now writes a `ReadSecurityError`, + like `Get-NTFSAudit` +- Fix `Get-NTFSSimpleAccess`, which left out a folder whose parent folder it + hadn't reported, and with it all of its subfolders, and which compared a + drive root with the parent folder of the folder before it; such folders + are now reported with all of their entries, and a parent folder is found + also when its path differs in case. A folder that came after its parent + folder a second time failed with a `ReadError` +- Fix `Move-Item2` for a folder on another volume, which Windows can't + move: the cmdlet copied and deleted it instead, so that an empty folder + was deleted without being created at the destination, and a folder with + files failed with an error that named one of its files. It now writes a + `MoveError` that names the folder and the destination, and leaves the + folder in place +- Fix `New-NTFSHardLink` and `New-NTFSSymbolicLink`, which failed with + `GetDefaultValueFailed` for every object piped to them, such as the rows + of a CSV file with the columns `Path` and `Target` +- Fix the errors of `New-NTFSSymbolicLink` for an existing `-Path` and a + missing `-Target`, and of `New-NTFSHardLink` for a folder as `-Target`, + which named no path. `New-NTFSSymbolicLink` now checks `-Path` first, + like `New-NTFSHardLink` +- Fix `Get-NTFSEffectiveAccess`, which warned that the result might be + inaccurate for every name of this computer in `-ServerName` except + `localhost` in lowercase, such as `.`, `LOCALHOST`, or the computer name, + where the computer doesn't offer the remote access check [Unreleased]: https://github.com/raandree/NTFSSecurity/compare/4.2.6...HEAD diff --git a/Docs/Cmdlets/Get-NTFSEffectiveAccess.md b/Docs/Cmdlets/Get-NTFSEffectiveAccess.md index c90652c..7f1ca96 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. 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 `-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 warning names the computer that couldn't be reached. For a name of this computer, such as `localhost`, `.`, or its computer name, the local authorization manager gives the result of the named computer, so the cmdlet doesn't warn. 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. @@ -184,6 +184,8 @@ Reading effective access needs the Security privilege. In a session that does no Before 5.0.0, `-ExcludeNoneAccessEntries` had no effect, and the cmdlet returned nothing without `-Path` or for `-SecurityDescriptor`. When the computer of `-ServerName` couldn't be reached, the cmdlet warned that it had calculated the result on this computer, but returned no access instead of that result. +Before 5.0.0-rc7, the warning about a computer that couldn't be reached didn't name the computer, and the cmdlet warned for every name of this computer except `localhost` in lowercase, such as `.`, `LOCALHOST`, or the computer name. + ## RELATED LINKS [Get-NTFSAccess](Get-NTFSAccess.md) diff --git a/Docs/Cmdlets/Get-NTFSOrphanedAudit.md b/Docs/Cmdlets/Get-NTFSOrphanedAudit.md index 59a3229..d96682a 100644 --- a/Docs/Cmdlets/Get-NTFSOrphanedAudit.md +++ b/Docs/Cmdlets/Get-NTFSOrphanedAudit.md @@ -163,7 +163,7 @@ You can pipe paths to this cmdlet, or objects that have a `Path` or `FullName` p ### Security2.FileSystemSecurity2[] -Security descriptors bind to the inherited `-SecurityDescriptor` parameter, but this cmdlet does not read their audit entries. +Security descriptors that `Get-NTFSSecurityDescriptor` returned bind to `-SecurityDescriptor`, and the cmdlet examines their audit entries. ### Security2.IdentityReference2 @@ -179,12 +179,14 @@ The cmdlet returns the audit entries whose account SID cannot be translated into 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. -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 reads the security descriptor without its SACL and reports no orphaned entries at all, which looks the same as a tree that has none. +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", like `Get-NTFSAudit`. -If an item cannot be read, the cmdlet writes a warning and continues with the next item. Unlike `Get-NTFSAudit`, it does not try to take ownership of the item when access is denied. +If the audit entries of an item can't be read, the cmdlet writes a `ReadSecurityError`, with the category `PermissionDenied` when access is denied, and continues with the next item; for a path that doesn't exist, it writes a `ReadError`. Like `Get-NTFSAudit`, it doesn't take ownership of the item, because ownership grants no access to the SACL. Before 5.0.0, the cmdlet ignored `-Account` and `-SecurityDescriptor` and wrote the entries of each item as one collection. +Before 5.0.0-rc7, the cmdlet read an item without its SACL when the Security privilege was missing and reported no orphaned entries, which looked the same as an item that has none. For an item that it couldn't read, it wrote a warning instead of an error. + ## RELATED LINKS [Get-NTFSAudit](Get-NTFSAudit.md) diff --git a/Docs/Cmdlets/Get-NTFSSimpleAccess.md b/Docs/Cmdlets/Get-NTFSSimpleAccess.md index e852c6f..57835b5 100644 --- a/Docs/Cmdlets/Get-NTFSSimpleAccess.md +++ b/Docs/Cmdlets/Get-NTFSSimpleAccess.md @@ -29,7 +29,7 @@ Get-NTFSSimpleAccess [-IncludeRootFolder] [-SecurityDescriptor] ] [[-Target] ] [-PassThru] [] +New-NTFSHardLink [-Path] [-Target] [-PassThru] [] ``` ## DESCRIPTION -The `New-NTFSHardLink` cmdlet gives an existing file an additional name. `-Path` is the new hard link that the cmdlet creates, and `-Target` is the existing file that the new link refers to. Read the command as "create *Path*, which points to *Target*". +The `New-NTFSHardLink` cmdlet gives an existing file an additional name. `-Path` is the new hard link that the cmdlet creates, and `-Target` is the existing file that the new link refers to. Read the command as "create *Path*, which points to *Target*". Both parameters are required. The cmdlet validates both ends before it creates the link. `-Path` must not exist yet, so the cmdlet never overwrites an existing file, and `-Target` must exist and must be a file. A folder as `-Target` is rejected, because NTFS supports hard links for files only. Relative paths are resolved against the current location. +To create several links, pipe objects with the properties `Path` and `Target` to the cmdlet, one link per object. For a link that it can't create, the cmdlet writes a non-terminating error and continues with the next object. + After the link is created, both names refer to the same data on the volume. Writing through one name changes what the other name returns, and the file is only released when its last name is deleted. By default the cmdlet produces no output. With `-PassThru` it returns one object for every hard link that the file has after the operation, which includes the original name and the new link, not just the link that was created. @@ -61,6 +63,14 @@ PS C:\> Get-NTFSHardLink -Path C:\Data\Report-2026.txt | Select-Object -ExpandPr This command lists all names of the file after the link was created, which is the same information that `-PassThru` returns. +### Example 5: Create several links from a list + +```PowerShell +PS C:\> Import-Csv -Path C:\Data\Links.csv | New-NTFSHardLink +``` + +This command creates one hard link for each row of `Links.csv`, which has the columns `Path` and `Target`. For a row whose link it can't create, the cmdlet writes an error and continues with the next row. + ## PARAMETERS ### -PassThru @@ -88,7 +98,7 @@ Type: String Parameter Sets: (All) Aliases: FullName -Required: False +Required: True Position: 1 Default value: None Accept pipeline input: True (ByPropertyName, ByValue) @@ -104,7 +114,7 @@ Type: String Parameter Sets: (All) Aliases: -Required: False +Required: True Position: 2 Default value: None Accept pipeline input: True (ByPropertyName, ByValue) @@ -120,6 +130,10 @@ This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable You can pass the path of the new link and the path of the target as strings. +### System.Management.Automation.PSObject + +You can pipe objects whose `Path` or `FullName` property names the new link and whose `Target` property names its target, such as the rows of a CSV file that `Import-Csv` reads. + ## OUTPUTS ### Alphaleonis.Win32.Filesystem.FileInfo @@ -136,12 +150,14 @@ Windows supports hard links only for files on the same NTFS volume. A link that 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. +The cmdlet does not overwrite anything. If `-Path` already exists, if `-Target` is missing or is a folder, or if a path contains a character that Windows doesn't allow, such as `|`, the cmdlet writes a non-terminating `CreateHardLinkError` with the category `ResourceExists`, `ObjectNotFound`, or `InvalidArgument`, leaves the file system unchanged, and continues with the next object from the pipeline. It checks `-Path` before `-Target`, and the error for an existing `-Path`, a missing `-Target`, or a folder as `-Target` names that path. When Windows refuses the link, such as for a target on another volume, the cmdlet writes a `CreateHardLinkError` as well. 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. +Before 5.0.0-rc7, `-Path` and `-Target` were optional: without `-Path`, the cmdlet failed with an index error, and without `-Target`, it used the current location, which is a folder. It stopped with a terminating error for an existing `-Path`, a missing `-Target`, a folder as `-Target`, or a link that Windows refused, in Windows PowerShell also for a path with a character that Windows doesn't allow, and every object piped to it failed with `GetDefaultValueFailed`. + ## RELATED LINKS [Get-NTFSHardLink](Get-NTFSHardLink.md) diff --git a/Docs/Cmdlets/New-NTFSSymbolicLink.md b/Docs/Cmdlets/New-NTFSSymbolicLink.md index 9770fea..477ce70 100644 --- a/Docs/Cmdlets/New-NTFSSymbolicLink.md +++ b/Docs/Cmdlets/New-NTFSSymbolicLink.md @@ -14,17 +14,19 @@ Creates a symbolic link to an existing file or folder. ## SYNTAX ``` -New-NTFSSymbolicLink [[-Path] ] [[-Target] ] [-PassThru] [] +New-NTFSSymbolicLink [-Path] [-Target] [-PassThru] [] ``` ## DESCRIPTION -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 `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*". Both parameters are required. -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. +Before it creates the link, the cmdlet inspects the target 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. +To create several links, pipe objects with the properties `Path` and `Target` to the cmdlet, one link per object. For a link that it can't create, the cmdlet writes a non-terminating error and continues with the next object. + 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 @@ -61,6 +63,14 @@ PS C:\> Test-Path2 -Path C:\Data\Current\Report.txt -PathType Leaf This command tests a path that leads through the symbolic link. It returns `$true` when the link resolves and the file exists in the target folder. +### Example 5: Create several links from a list + +```PowerShell +PS C:\> Import-Csv -Path C:\Data\Links.csv | New-NTFSSymbolicLink +``` + +This command creates one symbolic link for each row of `Links.csv`, which has the columns `Path` and `Target`. For a row whose link it can't create, the cmdlet writes an error and continues with the next row. + ## PARAMETERS ### -PassThru @@ -88,7 +98,7 @@ Type: String Parameter Sets: (All) Aliases: FullName -Required: False +Required: True Position: 1 Default value: None Accept pipeline input: True (ByPropertyName, ByValue) @@ -104,7 +114,7 @@ Type: String Parameter Sets: (All) Aliases: -Required: False +Required: True Position: 2 Default value: None Accept pipeline input: True (ByPropertyName, ByValue) @@ -120,6 +130,10 @@ This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable You can pass the path of the new link and the path of the target as strings. +### System.Management.Automation.PSObject + +You can pipe objects whose `Path` or `FullName` property names the new link and whose `Target` property names its target, such as the rows of a CSV file that `Import-Csv` reads. + ## OUTPUTS ### Alphaleonis.Win32.Filesystem.FileInfo @@ -136,8 +150,12 @@ Creating a symbolic link on Windows requires the "Create symbolic links" user ri 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. +If `-Path` already exists, `-Target` is missing, or a path contains a character that Windows doesn't allow, such as `|`, the cmdlet writes a non-terminating `CreateSymbolicLinkError` with the category `ResourceExists`, `ObjectNotFound`, or `InvalidArgument`, leaves the file system unchanged, and continues with the next object from the pipeline. It checks `-Path` before `-Target`, and the error for an existing `-Path` or a missing `-Target` names that path. When Windows refuses the link, such as with error 1314 without the right to create symbolic links, the cmdlet writes a `CreateSymbolicLinkError` as well. + 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. +Before 5.0.0-rc7, `-Path` and `-Target` were optional: without `-Path`, the cmdlet failed with an index error, and without `-Target`, it created a link to the current folder. It stopped with a terminating error for an existing `-Path` or a link that Windows refused, in Windows PowerShell also for a path with a character that Windows doesn't allow, and every object piped to it failed with `GetDefaultValueFailed`. It checked `-Target` before `-Path`, and its errors for an existing `-Path` and a missing `-Target` named no path. + ## RELATED LINKS [New-NTFSHardLink](New-NTFSHardLink.md) diff --git a/Docs/FAQ.md b/Docs/FAQ.md index 8fd93fb..1232611 100644 --- a/Docs/FAQ.md +++ b/Docs/FAQ.md @@ -75,3 +75,21 @@ 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). + +## How do I compare the permissions of two items? + +Compare the properties of the entries, not the entries themselves. Each +entry that `Get-NTFSAccess` returns is an object of its own, and like the +access rules of .NET, two entries are equal only when they are the same +object, even when they grant the same rights to the same account. +`Compare-Object` with `-Property` lists the entries that only one of the +items has: + +```powershell +$properties = 'Account', 'AccessRights', 'AccessControlType', 'InheritanceFlags', 'PropagationFlags' +Compare-Object -ReferenceObject (Get-NTFSAccess -Path C:\Data\A) -DifferenceObject (Get-NTFSAccess -Path C:\Data\B) -Property $properties +``` + +The same works for the entries of `Get-NTFSAudit`, with `AuditFlags` in +place of `AccessControlType`. See +[Get-NTFSAccess](Cmdlets/Get-NTFSAccess.md). diff --git a/NTFSSecurity/AccessCmdlets/GetEffectiveAccess.cs b/NTFSSecurity/AccessCmdlets/GetEffectiveAccess.cs index 5284330..c8468af 100644 --- a/NTFSSecurity/AccessCmdlets/GetEffectiveAccess.cs +++ b/NTFSSecurity/AccessCmdlets/GetEffectiveAccess.cs @@ -159,9 +159,10 @@ namespace NTFSSecurity { if (!result.FromRemote) { - WriteWarning("The effective rights can only be computed based on group membership on this" + - " computer. For more accurate results, calculate effective access rights on " + - "the target computer"); + // Since 5.0.0-rc7, the warning names the computer, which a command with many items can't tell otherwise. + WriteWarning(string.Format("The effective rights can only be computed based on group membership on this computer, " + + "because the computer '{0}' can't be reached for a remote access check. " + + "For more accurate results, calculate effective access rights on that computer.", serverName)); } if (result.OperationFailed) diff --git a/NTFSSecurity/AuditCmdlets/Get-OrphanedAudit.cs b/NTFSSecurity/AuditCmdlets/Get-OrphanedAudit.cs index fe1c43d..02fbaf8 100644 --- a/NTFSSecurity/AuditCmdlets/Get-OrphanedAudit.cs +++ b/NTFSSecurity/AuditCmdlets/Get-OrphanedAudit.cs @@ -46,13 +46,21 @@ namespace NTFSSecurity.AuditCmdlets IEnumerable acl = null; + // Only the SACL, like Get-NTFSAudit, which fails without the Security privilege. Before 5.0.0-rc7, the + // cmdlet read the item without its audit entries then and returned nothing, as for an item without + // orphaned entries, and it wrote a warning for an item that it couldn't read. try { - acl = FileSystemAuditRule2.GetFileSystemAuditRules(item, !ExcludeExplicit, !ExcludeInherited, getInheritedFrom); + acl = GetAuditRules(item); + } + catch (UnauthorizedAccessException ex) + { + this.WriteError(new ErrorRecord(ex, "ReadSecurityError", ErrorCategory.PermissionDenied, p)); + continue; } catch (Exception ex) { - this.WriteWarning(string.Format("Could not read item {0}. The error was: {1}", p, ex.Message)); + this.WriteError(new ErrorRecord(ex, "ReadSecurityError", ErrorCategory.OpenError, p)); continue; } diff --git a/NTFSSecurity/AuditCmdlets/GetAudit.cs b/NTFSSecurity/AuditCmdlets/GetAudit.cs index f132702..324247c 100644 --- a/NTFSSecurity/AuditCmdlets/GetAudit.cs +++ b/NTFSSecurity/AuditCmdlets/GetAudit.cs @@ -130,9 +130,11 @@ namespace NTFSSecurity } } - private IEnumerable GetAuditRules(FileSystemInfo item) + /// + /// Reads only the SACL of an item. Without the Security privilege, this fails instead of returning no entries. + /// + protected IEnumerable GetAuditRules(FileSystemInfo item) { - // Reading only the SACL fails without the Security privilege, instead of returning no entries. var sd = new FileSystemSecurity2(item, System.Security.AccessControl.AccessControlSections.Audit); return FileSystemAuditRule2.GetFileSystemAuditRules(sd, !excludeExplicit, !excludeInherited, getInheritedFrom); } diff --git a/NTFSSecurity/ItemCmdlets/MoveItem2.cs b/NTFSSecurity/ItemCmdlets/MoveItem2.cs index 76376e8..fe7a61a 100644 --- a/NTFSSecurity/ItemCmdlets/MoveItem2.cs +++ b/NTFSSecurity/ItemCmdlets/MoveItem2.cs @@ -130,13 +130,30 @@ namespace NTFSSecurity } else { - ((DirectoryInfo)item).MoveTo(actualDestination, force ? MoveOptions.ReplaceExisting : MoveOptions.CopyAllowed, PathFormat.RelativePath); + // Without CopyAllowed, so that Windows refuses a move to another volume, which it can't do for a + // folder. Before 5.0.0-rc7, AlphaFS emulated such a move by copying and deleting: an empty folder + // was deleted without being created at the destination, and a folder with files failed with an + // error that named one of its files. + ((DirectoryInfo)item).MoveTo(actualDestination, force ? MoveOptions.ReplaceExisting : MoveOptions.None, PathFormat.RelativePath); WriteVerbose(string.Format("Directory '{0}' moved to '{1}'", resolvedPath, actualDestination)); } if (passThru) WriteObject(item); } + catch (NotSameDeviceException ex) + { + // A file gets here only with -Force, which moves without CopyAllowed; it keeps the error of Windows. + if (item is DirectoryInfo) + { + var message = string.Format("The folder '{0}' can't move to another volume, '{1}'. Copy it with Copy-Item2, then remove it with Remove-Item2.", resolvedPath, actualDestination); + WriteError(new ErrorRecord(new System.IO.IOException(message, ex), "MoveError", ErrorCategory.InvalidOperation, resolvedPath)); + } + else + { + WriteError(new ErrorRecord(ex, "MoveError", ErrorCategory.InvalidData, resolvedPath)); + } + } catch (System.IO.IOException ex) { WriteError(new ErrorRecord(ex, "MoveError", ErrorCategory.InvalidData, resolvedPath)); diff --git a/NTFSSecurity/LinkCmdlets/NewHardLink.cs b/NTFSSecurity/LinkCmdlets/NewHardLink.cs index 93ad344..642d70a 100644 --- a/NTFSSecurity/LinkCmdlets/NewHardLink.cs +++ b/NTFSSecurity/LinkCmdlets/NewHardLink.cs @@ -14,13 +14,17 @@ namespace NTFSSecurity private bool passThru; System.Reflection.MethodInfo modeMethodInfo = null; - [Parameter(Position = 1, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + // Required since 5.0.0-rc7. Before, an omitted -Path failed with an index error, and an omitted -Target meant the + // current location, which is a folder. + [Parameter(Mandatory = true, Position = 1, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] [ValidateNotNullOrEmpty] [Alias("FullName")] [FileSystemPathTransformation] public string Path { - get { return paths[0]; } + // PowerShell reads a parameter that takes pipeline input before it binds the input. Before 5.0.0-rc7, the + // empty list failed that read, so every piped object failed with GetDefaultValueFailed. + get { return paths.Count > 0 ? paths[0] : null; } set { paths.Clear(); @@ -28,7 +32,7 @@ namespace NTFSSecurity } } - [Parameter(Position = 2, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + [Parameter(Mandatory = true, Position = 2, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] [ValidateNotNullOrEmpty] [FileSystemPathTransformation] public string Target @@ -53,59 +57,94 @@ namespace NTFSSecurity protected override void ProcessRecord() { - var path = paths[0]; - - path = GetRelativePath(path); - target = GetRelativePath(target); + string path; + string targetPath; + try + { + path = GetRelativePath(paths[0]); + targetPath = GetRelativePath(target); + } + // Windows PowerShell rejects a character that Windows doesn't allow in a path, such as |, already here. + catch (ArgumentException ex) + { + WriteError(new ErrorRecord(ex, "CreateHardLinkError", ErrorCategory.InvalidArgument, paths[0])); + return; + } var root = System.IO.Path.GetPathRoot(path); - try + // Non-terminating errors, so that the links that follow in the pipeline are created as well. Before + // 5.0.0-rc7, an existing path, a missing target, or a folder as target stopped the pipeline. + FileSystemInfo temp = null; + if (TryGetFileSystemInfo2(path, out temp)) { - FileSystemInfo temp = null; + var exists = new ArgumentException(string.Format("The file '{0}' does already exist, cannot create the link", path)); + WriteError(new ErrorRecord(exists, "CreateHardLinkError", ErrorCategory.ResourceExists, path)); + return; + } - if (TryGetFileSystemInfo2(path, out temp)) - throw new ArgumentException(string.Format("The file '{0}' does already exist, cannot create the link", path)); + if (!TryGetFileSystemInfo2(targetPath, out temp)) + { + var missing = new System.IO.FileNotFoundException(string.Format("The target '{0}' does not exist, cannot create the link", targetPath), targetPath); + WriteError(new ErrorRecord(missing, "CreateHardLinkError", ErrorCategory.ObjectNotFound, path)); + return; + } - if (!TryGetFileSystemInfo2(target, out temp)) - throw new ArgumentException(string.Format("The target '{0}' does not exist, cannot create the link", target)); - else - if (temp is DirectoryInfo) - throw new ArgumentException("The target is not a file, cannot create the link"); + if (temp is DirectoryInfo) + { + var folder = new ArgumentException(string.Format("The target '{0}' is not a file, cannot create the link", targetPath)); + WriteError(new ErrorRecord(folder, "CreateHardLinkError", ErrorCategory.InvalidArgument, path)); + return; + } + + try + { + File.CreateHardlink(path, targetPath); + } + catch (Exception ex) + { + WriteError(new ErrorRecord(ex, "CreateHardLinkError", GetErrorCategory(ex), path)); + return; + } - File.CreateHardlink(path, target); + if (passThru) + { + 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; + } - if (passThru) + foreach (var link in links) { - 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) - { - var target = new PSObject(GetFileSystemInfo2(System.IO.Path.Combine(root, link.Substring(1)))); - target.Properties.Add(new PSCodeProperty("Mode", modeMethodInfo)); - WriteObject(target); - } + var name = new PSObject(GetFileSystemInfo2(System.IO.Path.Combine(root, link.Substring(1)))); + name.Properties.Add(new PSCodeProperty("Mode", modeMethodInfo)); + WriteObject(name); } } - catch (System.IO.FileNotFoundException ex) - { - WriteError(new ErrorRecord(ex, "CreateHardLinkError", ErrorCategory.WriteError, path)); - } } protected override void EndProcessing() { base.EndProcessing(); } + + // In PowerShell 7, AlphaFS rejects a character that Windows doesn't allow in a path only when it creates the link. + internal static ErrorCategory GetErrorCategory(Exception ex) + { + if (ex is UnauthorizedAccessException) + return ErrorCategory.PermissionDenied; + + if (ex is ArgumentException) + return ErrorCategory.InvalidArgument; + + return ErrorCategory.WriteError; + } } } diff --git a/NTFSSecurity/LinkCmdlets/NewSymbolicLink.cs b/NTFSSecurity/LinkCmdlets/NewSymbolicLink.cs index 091ae09..eccd85e 100644 --- a/NTFSSecurity/LinkCmdlets/NewSymbolicLink.cs +++ b/NTFSSecurity/LinkCmdlets/NewSymbolicLink.cs @@ -12,13 +12,17 @@ namespace NTFSSecurity private bool passThru; System.Reflection.MethodInfo modeMethodInfo = null; - [Parameter(Position = 1, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + // Required since 5.0.0-rc7. Before, an omitted -Path failed with an index error, and an omitted -Target meant the + // current location, so the cmdlet created a link to the current folder. + [Parameter(Mandatory = true, Position = 1, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] [ValidateNotNullOrEmpty] [Alias("FullName")] [FileSystemPathTransformation] public string Path { - get { return paths[0]; } + // PowerShell reads a parameter that takes pipeline input before it binds the input. Before 5.0.0-rc7, the + // empty list failed that read, so every piped object failed with GetDefaultValueFailed. + get { return paths.Count > 0 ? paths[0] : null; } set { paths.Clear(); @@ -26,7 +30,7 @@ namespace NTFSSecurity } } - [Parameter(Position = 2, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + [Parameter(Mandatory = true, Position = 2, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] [ValidateNotNullOrEmpty] [FileSystemPathTransformation] public string Target @@ -51,36 +55,56 @@ namespace NTFSSecurity protected override void ProcessRecord() { - var path = paths[0]; - - path = GetRelativePath(path); - target = GetRelativePath(target); - FileSystemInfo targetItem = null; - var root = System.IO.Path.GetPathRoot(path); - + string path; + string targetPath; try { - targetItem = GetFileSystemInfo2(target); + path = GetRelativePath(paths[0]); + targetPath = GetRelativePath(target); + } + // Windows PowerShell rejects a character that Windows doesn't allow in a path, such as |, already here. + catch (ArgumentException ex) + { + WriteError(new ErrorRecord(ex, "CreateSymbolicLinkError", ErrorCategory.InvalidArgument, paths[0])); + return; + } - FileSystemInfo temp; - if (TryGetFileSystemInfo2(path, out temp)) - { - throw new ArgumentException("The path does already exist, cannot create link"); - } + // Non-terminating errors, so that the links that follow in the pipeline are created as well. Before + // 5.0.0-rc7, an existing path and a failure to create the link stopped the pipeline, the cmdlet checked + // the target first, and its errors named neither path. + FileSystemInfo temp; + if (TryGetFileSystemInfo2(path, out temp)) + { + var exists = new ArgumentException(string.Format("The path '{0}' does already exist, cannot create the link", path)); + WriteError(new ErrorRecord(exists, "CreateSymbolicLinkError", ErrorCategory.ResourceExists, path)); + return; + } - File.CreateSymbolicLink(path, target, targetItem is FileInfo ? SymbolicLinkTarget.File : SymbolicLinkTarget.Directory); + FileSystemInfo targetItem; + if (!TryGetFileSystemInfo2(targetPath, out targetItem)) + { + var missing = new System.IO.FileNotFoundException(string.Format("The target '{0}' does not exist, cannot create the link", targetPath), targetPath); + WriteError(new ErrorRecord(missing, "CreateSymbolicLinkError", ErrorCategory.ObjectNotFound, path)); + return; + } - if (passThru) - { - if (targetItem is FileInfo) - WriteObject(new FileInfo(path)); - else - WriteObject(new DirectoryInfo(path)); - } + try + { + File.CreateSymbolicLink(path, targetPath, targetItem is FileInfo ? SymbolicLinkTarget.File : SymbolicLinkTarget.Directory); } - catch (System.IO.FileNotFoundException ex) + // Without the right to create symbolic links: (1314) A required privilege is not held by the client. + catch (Exception ex) + { + WriteError(new ErrorRecord(ex, "CreateSymbolicLinkError", NewHardLink.GetErrorCategory(ex), path)); + return; + } + + if (passThru) { - WriteError(new ErrorRecord(ex, "CreateSymbolicLinkError", ErrorCategory.ObjectNotFound, path)); + if (targetItem is FileInfo) + WriteObject(new FileInfo(path)); + else + WriteObject(new DirectoryInfo(path)); } } diff --git a/NTFSSecurity/NTFSSecurity.psd1 b/NTFSSecurity/NTFSSecurity.psd1 index 1681f93..d00032a 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 = 'rc6' + Prerelease = 'rc7' } } } \ No newline at end of file diff --git a/NTFSSecurity/SimpleAccessCmdlets/SimpleAccessCmdlets.cs b/NTFSSecurity/SimpleAccessCmdlets/SimpleAccessCmdlets.cs index 8cb56ff..0da312e 100644 --- a/NTFSSecurity/SimpleAccessCmdlets/SimpleAccessCmdlets.cs +++ b/NTFSSecurity/SimpleAccessCmdlets/SimpleAccessCmdlets.cs @@ -25,9 +25,9 @@ namespace NTFSSecurity set { includeRootFolder = value; } } - Dictionary> previousAcls = new Dictionary>(); + // Case-insensitive, like the paths of Windows, which can reach the cmdlet in different cases. + Dictionary> previousAcls = new Dictionary>(StringComparer.OrdinalIgnoreCase); DirectoryInfo item; - FileSystemInfo previousItem; bool isFirstFolder = true; protected override void ProcessRecord() @@ -73,16 +73,21 @@ namespace NTFSSecurity var acl = FilterAccount(FileSystemAccessRule2.GetFileSystemAccessRules(item, !ExcludeExplicit, !ExcludeInherited).Select(ace => ace.ToSimpleFileSystemAccessRule2())).ToList(); + FileSystemInfo parent = null; try { - previousItem = item.GetParent(); + parent = item.GetParent(); } catch { } - IEnumerable previousAcl = null; - if (isFirstFolder) + IEnumerable previousAcl; + + // A folder whose parent folder the cmdlet didn't report, such as the first one or a drive root, + // is reported with all of its entries. Before 5.0.0-rc7, such a folder after the first one was + // left out, or a drive root was compared with the parent of the folder before it. + if (isFirstFolder || parent == null || !previousAcls.TryGetValue(parent.FullName, out previousAcl)) { - previousAcls.Add(item.FullName, acl); + previousAcls[item.FullName] = acl; aceList.AddRange(acl); acl.ForEach(ace => WriteObject(ace)); @@ -90,32 +95,16 @@ namespace NTFSSecurity } else { - if (previousAcls.ContainsKey(previousItem.FullName)) - { - previousAcl = previousAcls[previousItem.FullName]; - previousAcls.Add(item.FullName, acl); - - List diffAcl = new List(); - - foreach (var ace in acl) - { - var equalsUser = previousAcl.Where(prevAce => prevAce.Identity == ace.Identity); - var equalsUserAndAccessType = previousAcl.Where(prevAce => prevAce.Identity == ace.Identity & prevAce.AccessControlType == ace.AccessControlType); - var equalsRights = previousAcl.Where(prevAce => (prevAce.AccessRights & ace.AccessRights) == ace.AccessRights); - var totalEqual = previousAcl.Where(prevAce => prevAce.Identity == ace.Identity & prevAce.AccessControlType == ace.AccessControlType & (prevAce.AccessRights & ace.AccessRights) == ace.AccessRights); - - if (previousAcl.Where(prevAce => - prevAce.AccessControlType == ace.AccessControlType & - (prevAce.AccessRights & ace.AccessRights) == ace.AccessRights & - prevAce.Identity == ace.Identity).Count() == 0) - { - diffAcl.Add(ace); - } - } - - aceList.AddRange(diffAcl); - diffAcl.ForEach(ace => WriteObject(ace)); - } + previousAcls[item.FullName] = acl; + + // The entries that the parent folder doesn't cover for the same account and access type + var diffAcl = acl.Where(ace => !previousAcl.Any(prevAce => + prevAce.AccessControlType == ace.AccessControlType & + (prevAce.AccessRights & ace.AccessRights) == ace.AccessRights & + prevAce.Identity == ace.Identity)).ToList(); + + aceList.AddRange(diffAcl); + diffAcl.ForEach(ace => WriteObject(ace)); } } diff --git a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml index f8e25a2..fe60c28 100644 --- a/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml +++ b/NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml @@ -4945,7 +4945,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. 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 `-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 warning names the computer that couldn't be reached. For a name of this computer, such as `localhost`, `.`, or its computer name, the local authorization manager gives the result of the named computer, so the cmdlet doesn't warn. 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. @@ -5155,6 +5155,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. Reading effective access needs the Security privilege. In a session that does not hold it, the cmdlet warns before it starts and the calculation may fail with an error. Use `Enable-Privileges` in an elevated session to enable the privilege, and `Get-Privileges` to see which privileges the session holds. When the calculation fails, the error names the cause that Windows reported, such as a security descriptor without an owner; before 5.0.0, it blamed a missing Security privilege whenever the privilege wasn't enabled. Before 5.0.0, `-ExcludeNoneAccessEntries` had no effect, and the cmdlet returned nothing without `-Path` or for `-SecurityDescriptor`. When the computer of `-ServerName` couldn't be reached, the cmdlet warned that it had calculated the result on this computer, but returned no access instead of that result. + Before 5.0.0-rc7, the warning about a computer that couldn't be reached didn't name the computer, and the cmdlet warned for every name of this computer except `localhost` in lowercase, such as `.`, `LOCALHOST`, or the computer name. @@ -5988,7 +5989,7 @@ PS C:\> Get-NTFSAudit -SecurityDescriptor $sd Security2.FileSystemSecurity2[] - Security descriptors bind to the inherited `-SecurityDescriptor` parameter, but this cmdlet does not read their audit entries. + Security descriptors that `Get-NTFSSecurityDescriptor` returned bind to `-SecurityDescriptor`, and the cmdlet examines their audit entries. @@ -6013,9 +6014,10 @@ 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. - 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 reads the security descriptor without its SACL and reports no orphaned entries at all, which looks the same as a tree that has none. - If an item cannot be read, the cmdlet writes a warning and continues with the next item. Unlike `Get-NTFSAudit`, it does not try to take ownership of the item when access is denied. + 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", like `Get-NTFSAudit`. + If the audit entries of an item can't be read, the cmdlet writes a `ReadSecurityError`, with the category `PermissionDenied` when access is denied, and continues with the next item; for a path that doesn't exist, it writes a `ReadError`. Like `Get-NTFSAudit`, it doesn't take ownership of the item, because ownership grants no access to the SACL. Before 5.0.0, the cmdlet ignored `-Account` and `-SecurityDescriptor` and wrote the entries of each item as one collection. + Before 5.0.0-rc7, the cmdlet read an item without its SACL when the Security privilege was missing and reported no orphaned entries, which looked the same as an item that has none. For an item that it couldn't read, it wrote a warning instead of an error. @@ -6385,7 +6387,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 `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. + The second simplification is that repetitions are left out. The first folder the cmdlet processes is reported with all of its entries, and so is every folder whose parent folder the cmdlet didn't report before, such as a drive root; paths that differ only in case name the same folder. For a folder whose parent folder it reported, only the entries are reported that the 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. @@ -6628,6 +6630,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. 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`. + Before 5.0.0-rc7, the cmdlet left out a folder whose parent folder it hadn't reported, unless it was the first folder, and with it all of its subfolders; a parent folder whose path differed in case counted as not reported. A drive root was left out as well, or compared with the parent folder of the folder before it, and a folder that came after its parent folder a second time failed with a `ReadError`. @@ -6787,7 +6790,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, or, with `-WhatIf`, a verbose message. + Without `-Force`, the cmdlet checks whether a file or folder already exists at the destination and writes a `DestinationFileAlreadyExists` error instead of overwriting it; a file then moves with the `CopyAllowed` option, which allows it 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`. @@ -6978,8 +6981,9 @@ 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. 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. + The cmdlet chooses between two mutually exclusive move options for a file. Without `-Force` it moves a file with `CopyAllowed`, which permits the 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 file can't move to another volume when `-Force` is specified: the cmdlet writes the `MoveError` of Windows, "(17) The system cannot move the file to a different disk drive". A folder can't move to another volume at all: the cmdlet writes a `MoveError` with the category `InvalidOperation` that names the folder and the destination, and it 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)). + Before 5.0.0-rc7, a folder moved with `CopyAllowed` as well, so a move to another volume copied and deleted it: an empty folder was deleted without being created at the destination, and a folder with files failed with an error that named one of its files. @@ -7049,15 +7053,16 @@ PS C:\Data> Get-NTFSSecurityDescriptor - The `New-NTFSHardLink` cmdlet gives an existing file an additional name. `-Path` is the new hard link that the cmdlet creates, and `-Target` is the existing file that the new link refers to. Read the command as "create Path , which points to Target ". + The `New-NTFSHardLink` cmdlet gives an existing file an additional name. `-Path` is the new hard link that the cmdlet creates, and `-Target` is the existing file that the new link refers to. Read the command as "create Path , which points to Target ". Both parameters are required. The cmdlet validates both ends before it creates the link. `-Path` must not exist yet, so the cmdlet never overwrites an existing file, and `-Target` must exist and must be a file. A folder as `-Target` is rejected, because NTFS supports hard links for files only. Relative paths are resolved against the current location. + To create several links, pipe objects with the properties `Path` and `Target` to the cmdlet, one link per object. For a link that it can't create, the cmdlet writes a non-terminating error and continues with the next object. After the link is created, both names refer to the same data on the volume. Writing through one name changes what the other name returns, and the file is only released when its last name is deleted. By default the cmdlet produces no output. With `-PassThru` it returns one object for every hard link that the file has after the operation, which includes the original name and the new link, not just the link that was created. New-NTFSHardLink - + Path Specifies the path of the new hard link that the cmdlet creates. The path must not exist yet, and it must be on the same NTFS volume as `-Target`. Relative paths are resolved against the current location. @@ -7069,7 +7074,7 @@ PS C:\Data> Get-NTFSSecurityDescriptor None - + Target Specifies the path of the existing file that the new link refers to. The target must exist and must be a file; folders are rejected, because NTFS supports hard links for files only. @@ -7107,7 +7112,7 @@ PS C:\Data> Get-NTFSSecurityDescriptor False - + Path Specifies the path of the new hard link that the cmdlet creates. The path must not exist yet, and it must be on the same NTFS volume as `-Target`. Relative paths are resolved against the current location. @@ -7119,7 +7124,7 @@ PS C:\Data> Get-NTFSSecurityDescriptor None - + Target Specifies the path of the existing file that the new link refers to. The target must exist and must be a file; folders are rejected, because NTFS supports hard links for files only. @@ -7141,6 +7146,14 @@ PS C:\Data> Get-NTFSSecurityDescriptor You can pass the path of the new link and the path of the target as strings. + + + System.Management.Automation.PSObject + + + You can pipe objects whose `Path` or `FullName` property names the new link and whose `Target` property names its target, such as the rows of a CSV file that `Import-Csv` reads. + + @@ -7164,9 +7177,10 @@ 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. + The cmdlet does not overwrite anything. If `-Path` already exists, if `-Target` is missing or is a folder, or if a path contains a character that Windows doesn't allow, such as `|`, the cmdlet writes a non-terminating `CreateHardLinkError` with the category `ResourceExists`, `ObjectNotFound`, or `InvalidArgument`, leaves the file system unchanged, and continues with the next object from the pipeline. It checks `-Path` before `-Target`, and the error for an existing `-Path`, a missing `-Target`, or a folder as `-Target` names that path. When Windows refuses the link, such as for a target on another volume, the cmdlet writes a `CreateHardLinkError` as well. 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. + Before 5.0.0-rc7, `-Path` and `-Target` were optional: without `-Path`, the cmdlet failed with an index error, and without `-Target`, it used the current location, which is a folder. It stopped with a terminating error for an existing `-Path`, a missing `-Target`, a folder as `-Target`, or a link that Windows refused, in Windows PowerShell also for a path with a character that Windows doesn't allow, and every object piped to it failed with `GetDefaultValueFailed`. @@ -7198,6 +7212,13 @@ PS C:\Data> Get-NTFSSecurityDescriptor This command lists all names of the file after the link was created, which is the same information that `-PassThru` returns. + + --------- Example 5: Create several links from a list --------- + PS C:\> Import-Csv -Path C:\Data\Links.csv | New-NTFSHardLink + + This command creates one hard link for each row of `Links.csv`, which has the columns `Path` and `Target`. For a row whose link it can't create, the cmdlet writes an error and continues with the next row. + + @@ -7232,15 +7253,16 @@ 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. + 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 ". Both parameters are required. + Before it creates the link, the cmdlet inspects the target 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. + To create several links, pipe objects with the properties `Path` and `Target` to the cmdlet, one link per object. For a link that it can't create, the cmdlet writes a non-terminating error and continues with the next object. 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. New-NTFSSymbolicLink - + Path Specifies the path of the new symbolic link that the cmdlet creates. The path must not exist yet. Relative paths are resolved against the current location. @@ -7252,7 +7274,7 @@ PS C:\Data> Get-NTFSSecurityDescriptor None - + Target Specifies the path of the existing file or folder that the new link points to. The target must exist when the link is created and determines whether the cmdlet creates a file symbolic link or a directory symbolic link. Relative paths are resolved against the current location, so the link stores an absolute target path. @@ -7290,7 +7312,7 @@ PS C:\Data> Get-NTFSSecurityDescriptor False - + Path Specifies the path of the new symbolic link that the cmdlet creates. The path must not exist yet. Relative paths are resolved against the current location. @@ -7302,7 +7324,7 @@ PS C:\Data> Get-NTFSSecurityDescriptor None - + Target Specifies the path of the existing file or folder that the new link points to. The target must exist when the link is created and determines whether the cmdlet creates a file symbolic link or a directory symbolic link. Relative paths are resolved against the current location, so the link stores an absolute target path. @@ -7324,6 +7346,14 @@ PS C:\Data> Get-NTFSSecurityDescriptor You can pass the path of the new link and the path of the target as strings. + + + System.Management.Automation.PSObject + + + You can pipe objects whose `Path` or `FullName` property names the new link and whose `Target` property names its target, such as the rows of a CSV file that `Import-Csv` reads. + + @@ -7347,7 +7377,9 @@ 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. 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. + If `-Path` already exists, `-Target` is missing, or a path contains a character that Windows doesn't allow, such as `|`, the cmdlet writes a non-terminating `CreateSymbolicLinkError` with the category `ResourceExists`, `ObjectNotFound`, or `InvalidArgument`, leaves the file system unchanged, and continues with the next object from the pipeline. It checks `-Path` before `-Target`, and the error for an existing `-Path` or a missing `-Target` names that path. When Windows refuses the link, such as with error 1314 without the right to create symbolic links, the cmdlet writes a `CreateSymbolicLinkError` as well. 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. + Before 5.0.0-rc7, `-Path` and `-Target` were optional: without `-Path`, the cmdlet failed with an index error, and without `-Target`, it created a link to the current folder. It stopped with a terminating error for an existing `-Path` or a link that Windows refused, in Windows PowerShell also for a path with a character that Windows doesn't allow, and every object piped to it failed with `GetDefaultValueFailed`. It checked `-Target` before `-Path`, and its errors for an existing `-Path` and a missing `-Target` named no path. @@ -7379,6 +7411,13 @@ PS C:\Data> Get-NTFSSecurityDescriptor This command tests a path that leads through the symbolic link. It returns `$true` when the link resolves and the file exists in the target folder. + + --------- Example 5: Create several links from a list --------- + PS C:\> Import-Csv -Path C:\Data\Links.csv | New-NTFSSymbolicLink + + This command creates one symbolic link for each row of `Links.csv`, which has the columns `Path` and `Target`. For a row whose link it can't create, the cmdlet writes an error and continues with the next row. + + diff --git a/Security2/Win32/Lib.cs b/Security2/Win32/Lib.cs index 46e42aa..94d630e 100644 --- a/Security2/Win32/Lib.cs +++ b/Security2/Win32/Lib.cs @@ -134,6 +134,36 @@ namespace Security2 } #region Win32 Wrapper + // Whether a name of -ServerName names this computer: localhost in any case, ., the NetBIOS name, the DNS host + // name, or the fully qualified domain name. It must not throw, because GetEffectiveAccess hides every exception + // of the resource manager behind a result without access. + private static bool IsLocalComputer(string serverName) + { + if (string.IsNullOrEmpty(serverName)) + { + return false; + } + + if (serverName == "." || + string.Equals(serverName, "localhost", StringComparison.OrdinalIgnoreCase) || + string.Equals(serverName, Environment.MachineName, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + try + { + var properties = System.Net.NetworkInformation.IPGlobalProperties.GetIPGlobalProperties(); + return string.Equals(serverName, properties.HostName, StringComparison.OrdinalIgnoreCase) || + (!string.IsNullOrEmpty(properties.DomainName) && + string.Equals(serverName, properties.HostName + "." + properties.DomainName, StringComparison.OrdinalIgnoreCase)); + } + catch (System.Net.NetworkInformation.NetworkInformationException) + { + return false; + } + } + private void GetEffectivePermissions_AuthzInitializeResourceManager(string serverName, out bool remoteServerAvailable) { remoteServerAvailable = false; @@ -157,7 +187,10 @@ namespace Security2 throw new Win32Exception(error); } - if (serverName == "localhost") + // The local authorization manager is the one of this computer, so its result is accurate for any name + // of this computer. Before 5.0.0-rc7, only localhost in lowercase counted, and the cmdlet warned for + // the others, such as ., the computer name, or LOCALHOST. + if (IsLocalComputer(serverName)) { remoteServerAvailable = true; } diff --git a/Tests/Access.Tests.ps1 b/Tests/Access.Tests.ps1 index 7431465..32d0fa5 100644 --- a/Tests/Access.Tests.ps1 +++ b/Tests/Access.Tests.ps1 @@ -13,6 +13,11 @@ BeforeDiscovery { # Assigning an owner other than the user or one of its groups needs the Restore privilege. $canAssignAnyOwner = Test-PrivilegeHeld -Name 'SeRestorePrivilege' $holdsSecurityPrivilege = Test-PrivilegeHeld -Name 'SeSecurityPrivilege' + # Names of this computer for -ServerName of Get-NTFSEffectiveAccess: its NetBIOS name, its DNS host name, and, in a + # domain, its fully qualified name + $ipProperties = [System.Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties() + $localComputerNames = @(@('LOCALHOST', '.', $env:COMPUTERNAME, $ipProperties.HostName) + + @(if ($ipProperties.DomainName) { '{0}.{1}' -f $ipProperties.HostName, $ipProperties.DomainName }) | Sort-Object -Unique) } BeforeAll { @@ -145,8 +150,27 @@ Describe 'Get-NTFSEffectiveAccess' { $accessErrors | Should -BeNullOrEmpty $result | Should -HaveCount 1 $result[0].AccessRights | Should -Be $expected.AccessRights - $accessWarnings.Message | Should -Contain ('The effective rights can only be computed based on group membership on this computer. ' + - 'For more accurate results, calculate effective access rights on the target computer') + # Before 5.0.0-rc7, the warning didn't name the computer. + $accessWarnings.Message | Should -Contain ("The effective rights can only be computed based on group membership on this computer, " + + "because the computer 'ntfssecurity-test.invalid' can't be reached for a remote access check. " + + 'For more accurate results, calculate effective access rights on that computer.') + } + } + + # Not every computer offers the remote interface of the authorization manager; the cmdlet then calculates the result + # with the local one, which for a name of this computer is the result of that computer. Before 5.0.0-rc7, the cmdlet + # warned that the computer couldn't be reached for every name of this computer but localhost in lowercase. + Context 'When -ServerName names this computer' { + It 'Should return the result of localhost for <_> and warn no more than for localhost' -ForEach $localComputerNames { + $expected = Get-NTFSEffectiveAccess -Path $effectiveFile -WarningVariable expectedWarnings -WarningAction SilentlyContinue -ErrorAction Stop + + $result = @(Get-NTFSEffectiveAccess -Path $effectiveFile -ServerName $_ -WarningVariable accessWarnings -WarningAction SilentlyContinue -ErrorVariable accessErrors -ErrorAction SilentlyContinue) + + $accessErrors | Should -BeNullOrEmpty + $result | Should -HaveCount 1 + $result[0].AccessRights | Should -Be $expected.AccessRights + # Without the Security privilege, the cmdlet warns about it for every name. + @($accessWarnings.Message) -join '|' | Should -Be (@($expectedWarnings.Message) -join '|') } } } @@ -326,6 +350,54 @@ Describe 'Get-NTFSSimpleAccess' { @($result | Where-Object -Property FullName -EQ -Value $child).Identity.Sid | Should -Be 'S-1-5-21-1-2-3-3101' } + # Before 5.0.0-rc7, a folder whose parent folder the cmdlet hadn't reported was left out of the result, so the + # entries of the parent here were missing. + It 'Should report all entries of a folder whose parent folder it did not report' { + $childAlone = @(Get-NTFSSimpleAccess -Path $child -IncludeRootFolder:$false -ErrorAction Stop) + $parentAlone = @(Get-NTFSSimpleAccess -Path $parent -IncludeRootFolder:$false -ErrorAction Stop) + $parentAlone | Should -Not -BeNullOrEmpty + + $result = @(Get-NTFSSimpleAccess -Path $child, $parent -IncludeRootFolder:$false -ErrorAction Stop) + + @($result | Where-Object -Property FullName -EQ -Value $child) | Should -HaveCount $childAlone.Count + @($result | Where-Object -Property FullName -EQ -Value $parent) | Should -HaveCount $parentAlone.Count + } + + # Before 5.0.0-rc7, a folder that came after its parent folder a second time failed with a ReadError, "An item + # with the same key has already been added." + It 'Should compare a folder that it gets twice with its parent folder both times' { + $result = @(Get-NTFSSimpleAccess -Path $parent, $child, $child -IncludeRootFolder:$false -ErrorVariable simpleErrors -ErrorAction SilentlyContinue) + + $simpleErrors | Should -BeNullOrEmpty + $childEntries = @($result | Where-Object -Property FullName -EQ -Value $child) + $childEntries | Should -HaveCount 2 + $childEntries | ForEach-Object -Process { $_.Identity.Sid | Should -Be 'S-1-5-21-1-2-3-3101' } + } + + # Before 5.0.0-rc7, a drive root after the first path was left out as well, because it has no parent folder; + # after another folder whose parent was reported, it was compared with that unrelated parent. The test reads the + # entries of the drive root and changes nothing there. + It 'Should report all entries of a drive root, which has no parent folder' { + $root = [IO.Path]::GetPathRoot($child) + $rootAlone = @(Get-NTFSSimpleAccess -Path $root -IncludeRootFolder:$false -ErrorAction Stop) + $rootAlone | Should -Not -BeNullOrEmpty + + $result = @(Get-NTFSSimpleAccess -Path $child, $root -IncludeRootFolder:$false -ErrorVariable simpleErrors -ErrorAction SilentlyContinue) + + $simpleErrors | Should -BeNullOrEmpty + @($result | Where-Object -Property FullName -EQ -Value $root) | Should -HaveCount $rootAlone.Count + } + + # Windows doesn't distinguish paths by case. Before 5.0.0-rc7, the cmdlet didn't recognize the parent folder of a + # folder whose path differed from it in case, and left the folder out. + It 'Should compare a folder with its parent folder also when their paths differ in case' { + $result = @(Get-NTFSSimpleAccess -Path $parent, $child.ToUpperInvariant() -IncludeRootFolder:$false -ErrorAction Stop) + + $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 report the parent folder of the first path first by default' { $result = @(Get-NTFSSimpleAccess -Path $child -ErrorAction Stop) diff --git a/Tests/Audit.Tests.ps1 b/Tests/Audit.Tests.ps1 index fc99457..f9fa2a8 100644 --- a/Tests/Audit.Tests.ps1 +++ b/Tests/Audit.Tests.ps1 @@ -242,7 +242,7 @@ Describe 'Get-NTFSOrphanedAudit' { } } - It 'Should write an error for a path that does not exist and continue with the next path' { + It 'Should write an error for a path that does not exist and continue with the next path' -Skip:(-not $canReadAudit) { $result = @(Get-NTFSOrphanedAudit -Path $missing, $orphanedFile -ErrorVariable orphanedErrors -ErrorAction SilentlyContinue) $orphanedErrors | Should -HaveCount 1 @@ -251,6 +251,19 @@ Describe 'Get-NTFSOrphanedAudit' { $result | ForEach-Object -Process { $_.FullName | Should -Be $orphanedFile } } + # Since 5.0.0-rc7, the next path has an error of its own without the Security privilege, which shows that the cmdlet + # continued with it. + It 'Should write an error for a path that does not exist and continue with the next path without the Security privilege' -Skip:$canReadAudit { + $result = @(Get-NTFSOrphanedAudit -Path $missing, $orphanedFile -ErrorVariable orphanedErrors -ErrorAction SilentlyContinue) + + $result | Should -BeNullOrEmpty + $orphanedErrors | Should -HaveCount 2 + $orphanedErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadError,*' + $orphanedErrors[0].TargetObject | Should -Be $missing + $orphanedErrors[1].FullyQualifiedErrorId | Should -BeLike 'ReadSecurityError,*' + $orphanedErrors[1].TargetObject | 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 @@ -263,12 +276,15 @@ Describe 'Get-NTFSOrphanedAudit' { $orphanedErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadSecurityError,*' } - # 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 { + # Before 5.0.0-rc7, the cmdlet read the item without its audit entries and returned nothing, as for an item without + # orphaned entries; it now writes the error of Get-NTFSAudit. + It 'Should write a ReadSecurityError without the Security privilege instead of returning nothing' -Skip:$canReadAudit { $result = @(Get-NTFSOrphanedAudit -Path $orphanedFile -ErrorVariable orphanedErrors -ErrorAction SilentlyContinue) - $orphanedErrors | Should -BeNullOrEmpty $result | Should -BeNullOrEmpty + $orphanedErrors | Should -HaveCount 1 + $orphanedErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadSecurityError,*' + $orphanedErrors[0].TargetObject | Should -Be $orphanedFile } } diff --git a/Tests/ItemCmdlets.Tests.ps1 b/Tests/ItemCmdlets.Tests.ps1 index 03f4826..760d83b 100644 --- a/Tests/ItemCmdlets.Tests.ps1 +++ b/Tests/ItemCmdlets.Tests.ps1 @@ -6,6 +6,13 @@ )] param () +BeforeDiscovery { + Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force + # A path on the administrative share of the drive of the sandboxes is another volume for Windows, like a share of a + # file server. + $canUseAdminShare = Test-AdminShareAvailable +} + BeforeAll { Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force $modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\NTFSSecurity\bin\Release\NTFSSecurity.psd1' @@ -349,6 +356,62 @@ Describe 'Copy-Item2, Move-Item2, and Remove-Item2 with several paths' { } } +Describe 'Move-Item2' { + # Windows can't move a folder to another volume. Before 5.0.0-rc7, the cmdlet let AlphaFS emulate the move by + # copying and deleting, which failed for a folder with files with an error that named a file of the source, and + # which deleted an empty folder without creating it at the destination. + It 'Should refuse to move folder to another volume and leave it in place' -Skip:(-not $canUseAdminShare) -ForEach @( + @{ Kind = 'an empty' } + @{ Kind = 'a non-empty' } + ) { + $source = New-TestSandboxItem -Sandbox $sandbox -Name 'CrossVolume' -Directory + if ($Kind -eq 'a non-empty') { + Set-Content -LiteralPath (Join-Path -Path $source -ChildPath 'File.txt') -Value 'File' + } + $destination = Join-Path -Path $sandbox -ChildPath ('Moved-{0}' -f (Split-Path -Path $source -Leaf)) + Assert-TestSandboxPath -Sandbox $sandbox -Path $destination + + Move-Item2 -Path $source -Destination (ConvertTo-TestAdminSharePath -Sandbox $sandbox -Path $destination) -ErrorVariable moveErrors -ErrorAction SilentlyContinue + + $moveErrors | Should -HaveCount 1 + $moveErrors[0].FullyQualifiedErrorId | Should -BeLike 'MoveError,*' + $moveErrors[0].CategoryInfo.Category | Should -Be 'InvalidOperation' + $moveErrors[0].Exception.Message | Should -BeLike "*'$source'*another volume*" + $moveErrors[0].TargetObject | Should -Be $source + $source | Should -Exist + $destination | Should -Not -Exist + } + + It 'Should still move a file to another volume' -Skip:(-not $canUseAdminShare) { + $source = New-TestSandboxItem -Sandbox $sandbox -Name 'CrossVolumeFile' + $destination = Join-Path -Path $sandbox -ChildPath ('Moved-{0}' -f (Split-Path -Path $source -Leaf)) + Assert-TestSandboxPath -Sandbox $sandbox -Path $destination + + Move-Item2 -Path $source -Destination (ConvertTo-TestAdminSharePath -Sandbox $sandbox -Path $destination) -ErrorAction Stop + + $source | Should -Not -Exist + Get-Content -LiteralPath $destination | Should -Be 'CrossVolumeFile' + } + + # With -Force, a file moves without CopyAllowed, which Windows refuses for another volume, as the cmdlet page says. + # The cmdlet writes that error of Windows, (17) "The system cannot move the file to a different disk drive", and + # not the error for a folder. + It 'Should write the error of Windows for a file that it moves with -Force to another volume' -Skip:(-not $canUseAdminShare) { + $source = New-TestSandboxItem -Sandbox $sandbox -Name 'CrossVolumeForce' + $destination = Join-Path -Path $sandbox -ChildPath ('Moved-{0}' -f (Split-Path -Path $source -Leaf)) + Assert-TestSandboxPath -Sandbox $sandbox -Path $destination + + Move-Item2 -Path $source -Destination (ConvertTo-TestAdminSharePath -Sandbox $sandbox -Path $destination) -Force -ErrorVariable moveErrors -ErrorAction SilentlyContinue + + $moveErrors | Should -HaveCount 1 + $moveErrors[0].FullyQualifiedErrorId | Should -BeLike 'MoveError,*' + $moveErrors[0].CategoryInfo.Category | Should -Be 'InvalidData' + '0x{0:X8}' -f $moveErrors[0].Exception.HResult | Should -Be '0x80070011' + $source | Should -Exist + $destination | Should -Not -Exist + } +} + Describe 'Copy-Item2' { Context 'When -Path is a folder with files and subfolders' { BeforeAll { 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 1e5f73d..4349ec6 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 @@ -145,5 +145,28 @@ deletes them. 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`. + +## After the release + +The tag `5.0.0-rc6` on `b51d970`, the merge of #115, published the package +to the PowerShell Gallery on 2026-10-08 at 20:40 UTC. The **Release** job +failed after the upload: `Publish-PSResource` stopped waiting for the +Gallery after 100 seconds while the Gallery accepted the package, and its +second attempt got the error 409, "already exists". So the job didn't +create the GitHub release; rerunning the failed job creates it, as +[If a release fails](../../Docs/Contributing/05-Releasing.md#if-a-release-fails) +describes. + +`Invoke-NTFSSecurityLabTest.ps1 -Version 5.0.0-rc6` downloaded the package +from the Gallery, checked it against the SHA-512 that the Gallery +publishes, and ran in both editions, 20:43 to 21:00 UTC. The SHA-256 of +`NTFSSecurity.5.0.0-rc6.nupkg` is +`83EBCADEE0698A9523661352A69B9D25F6EB2903F45A6C4FAB36F5BF4B139100`. + +The live tests came from the branch of 5.0.0-rc7, which expects the +warning of `Get-NTFSEffectiveAccess` for a computer that can't be reached +to name the computer. The package failed only that test, in the role Admin +of both editions, with exactly the text that the live tests of 5.0.0-rc6 +expect; every other test passed: Delegate 38, ServerAdmin 13, Admin 39, +and Server 72 with 1 skipped, in each edition. The fixture was removed at +21:03 UTC, and its removal checked as above. diff --git a/Tests/Lab/Acceptance-2026-10-08-5.0.0-rc7.md b/Tests/Lab/Acceptance-2026-10-08-5.0.0-rc7.md new file mode 100644 index 0000000..62b9e4f --- /dev/null +++ b/Tests/Lab/Acceptance-2026-10-08-5.0.0-rc7.md @@ -0,0 +1,149 @@ +# Lab acceptance of 5.0.0-rc7 + +Acceptance of the release candidate 5.0.0-rc7 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 holds the behavior changes that Phase 2 of the quality gate +found, which the agent decided as assumptions for the maintainer's review, +and the fixes of one review. A run of the code of `4ee01e5`, packaged +before the commit that sets the label, so that it reported 5.0.0-rc6, +passed; the review then led to `7936d9f` to `dc6e9f5`. The first run of +`dc6e9f5` started without the checkpoint of step 3, so it was repeated +after the checkpoint. This record describes the repeated run of +`dc6e9f5`, the last commit of the branch that changes the module, and +names the results of the earlier runs. + +## Candidate + +- Branch `ai/release-5.0.0-rc7`, commit + `dc6e9f5359bb02bd173dc2174dd7bca8add2df86`, 10 commits on `be04cb7` + (the head of the pull request of 5.0.0-rc6, #115). +- 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 | +| --- | --- | +| `2273126D91A1A737F0EDF8350A7E90FFC4FD7CB6A1AEA8306874FC8517349C3C` | `NTFSSecurity.5.0.0-rc7.nupkg` | +| `38B39930D92EF3FCD03FD60E05E678D5645C77BB34EA677E053F78F779ECE668` | `NTFSSecurity.zip` | +| `710C83A498700AA36DAE02272E2556EEC58655DE7CA5570CE870F64DFBAF53A9` | `NTFSSecurity\NTFSSecurity.dll` | +| `8876D0AFE2156581FE31369982DD9A117FFE38C52179409FC11AC95A9A3D68C1` | `NTFSSecurity\Security2.dll` | +| `902157ABBD2E0B76DA744A918BDD174D5226C3494908ABA75F9E5DE28AE6A008` | `NTFSSecurity\ProcessPrivileges.dll` | +| `E2077AFEB38703345AE7857C1266F8B26E167ED887BFFAC8C8169A8F267BE6E9` | `NTFSSecurity\PrivilegeControl.dll` | +| `A8DA47194AB0F71232C69D01955AD93BA73C7ECEB58D0DE800CA085D4A2E18D8` | `NTFSSecurity\AlphaFS.dll` | +| `968514234BAAF16789A560C86A6F701F69E91262F1EAB62142DDF6D05A8D2A52` | `NTFSSecurity\NTFSSecurity.psd1` | +| `3F777E9D141EE0046119DA9D5ECF88A3BC7E023726098FE2FB150528E2FB59B8` | `NTFSSecurity\NTFSSecurity.psm1` | +| `59583423241951EBE0FC2D8237D0C28C3ECC8C7CD2C115D2660F8579888632FC` | `NTFSSecurity\NTFSSecurity.Init.ps1` | +| `FB0920CC37ED858F55AFD54998DC854E27FBBE6A0177CB059CB03CFE91361197` | `NTFSSecurity\NTFSSecurity.format.ps1xml` | +| `CB6882FF91E6716605D5599E7B464C3346E461216ACED07E847621738F04FB9B` | `NTFSSecurity\NTFSSecurity.types.ps1xml` | +| `3550E659932EE61A96C6049477C14FE08F131114104BBCB9551C4B1AEA605816` | `NTFSSecurity\en-US\NTFSSecurity.dll-Help.xml` | + +## Tests without a lab + +The Pester suite of `1063b29`, 712 tests, against the same build; the +commits after `dc6e9f5` change only tests. No test failed, and every test +ran in at least one configuration. + +| Configuration | Passed | Failed | Skipped | +| --- | ---: | ---: | ---: | +| Windows PowerShell 5.1, elevated | 688 | 0 | 24 | +| PowerShell 7, elevated | 658 | 0 | 54 | +| Windows PowerShell 5.1, basic user | 612 | 0 | 100 | +| PowerShell 7, basic user | 582 | 0 | 130 | + +## 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, checked before the 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.2 to 4.9 seconds ahead of the +host. + +Checkpoints (Production) of the six machines, taken before the run: +`ntfs-rc7-dc6e9f5-before-acceptance` (16:24 UTC). + +## Results + +`Invoke-NTFSSecurityLabTest.ps1 -ModulePath ` in both +editions, 16:24 to 16:40 UTC. The module reported version 5.0.0-rc7 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. + +The earlier runs had the same counts and no failure: the code of +`4ee01e5` from 15:32 to 15:49 UTC, and the first run of `dc6e9f5`, without +a checkpoint, from 16:04 to 16:20 UTC. + +## Baseline + +The published 5.0.0-rc6 from the PowerShell Gallery, whose hash the script +checks against the one the Gallery publishes, in both editions, 20:43 to +21:00 UTC, with the same live tests. It failed one test in each edition, +the warning of `Get-NTFSEffectiveAccess` for a computer that can't be +reached, which names the computer since 5.0.0-rc7: Admin 39 passed and 1 +failed; Delegate 38, ServerAdmin 13, and Server 72 with 1 skipped passed. +The live tests find no other difference between the two versions; the +local tests cover the other changes of 5.0.0-rc7. + +## Cleanup + +`Invoke-NTFSSecurityLabTest.ps1 -RemoveFixture` at 16:21 UTC, after the +first two runs, at 16:44 UTC, after the acceptance, and at 21:03 UTC, +after the baseline. Each check +compared the lab with the 10 SIDs of the fixture's accounts and groups, +read before the removal, which found the fixture. After each 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 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. +- A folder that `Move-Item2` moves from the client to the share, another + volume on another computer: the local tests move folders to + `\\localhost\C$`, another volume over SMB on the same computer. +- The package that CI publishes for the tag: the live tests run against it + after the release, with `-Version 5.0.0-rc7`. diff --git a/Tests/Lab/NTFSSecurity.Live.Tests.ps1 b/Tests/Lab/NTFSSecurity.Live.Tests.ps1 index e8eae11..5459f9f 100644 --- a/Tests/Lab/NTFSSecurity.Live.Tests.ps1 +++ b/Tests/Lab/NTFSSecurity.Live.Tests.ps1 @@ -408,13 +408,14 @@ Describe 'Get-NTFSEffectiveAccess for a domain account on a share folder' -Tag ' } # The cmdlet page: when the remote authorization manager can't be reached, the cmdlet falls back to the local one - # and warns that the result may be inaccurate. + # and warns that the result may be inaccurate; since 5.0.0-rc7, the warning names the computer. It 'Should fall back to the authorization manager of the client and warn when -ServerName can''t be reached' { $result = @(Get-NTFSEffectiveAccess -Path $path -Account $subject -ServerName $configuration.UnreachableServerName -WarningVariable operationWarnings -WarningAction SilentlyContinue -ErrorVariable operationErrors -ErrorAction SilentlyContinue) Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty - $operationWarnings.Message | Should -Contain ('The effective rights can only be computed based on group membership on this computer. ' + - 'For more accurate results, calculate effective access rights on the target computer') + $operationWarnings.Message | Should -Contain ('The effective rights can only be computed based on group membership on this computer, ' + + "because the computer '$($configuration.UnreachableServerName)' can't be reached for a remote access check. " + + 'For more accurate results, calculate effective access rights on that computer.') $result | Should -HaveCount 1 Format-LabRight -Right $result[0].AccessRights | Should -Be (Format-LabRight -Right $configuration.EffectiveAccess.ClientRights) } diff --git a/Tests/Lab/README.md b/Tests/Lab/README.md index 2967ca5..de8fbb2 100644 --- a/Tests/Lab/README.md +++ b/Tests/Lab/README.md @@ -141,7 +141,8 @@ and record the evidence in this folder: 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). +Records: [5.0.0-rc6](Acceptance-2026-10-08-5.0.0-rc6.md), +[5.0.0-rc7](Acceptance-2026-10-08-5.0.0-rc7.md). ## Files diff --git a/Tests/Links.Tests.ps1 b/Tests/Links.Tests.ps1 index 43f1d0b..5bc938b 100644 --- a/Tests/Links.Tests.ps1 +++ b/Tests/Links.Tests.ps1 @@ -10,9 +10,7 @@ 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) + $canUseAdminShare = Test-AdminShareAvailable } BeforeAll { @@ -21,13 +19,6 @@ 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 { @@ -122,6 +113,38 @@ Describe 'New-NTFSHardLink' { $link | Should -Not -Exist } + # Before 5.0.0-rc7, an existing -Path, a missing -Target, or a folder as -Target stopped the pipeline with a + # terminating error, so the links that followed weren't created. + It 'Should write a non-terminating error for each link that it cannot create and continue with the next one' { + $target = New-TestSandboxItem -Sandbox $sandbox -Name 'ContinueTarget' + $existing = New-TestSandboxItem -Sandbox $sandbox -Name 'ContinueExisting' + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'ContinueFolder' -Directory + $missing = Join-Path -Path $sandbox -ChildPath 'ContinueMissing.txt' + $missingLink = Join-Path -Path $sandbox -ChildPath 'ContinueMissingLink.txt' + $folderLink = Join-Path -Path $sandbox -ChildPath 'ContinueFolderLink.txt' + $link = Join-Path -Path $sandbox -ChildPath 'ContinueLink.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $missing, $missingLink, $folderLink, $link + $requests = @( + [pscustomobject]@{ Path = $existing; Target = $target } + [pscustomobject]@{ Path = $missingLink; Target = $missing } + [pscustomobject]@{ Path = $folderLink; Target = $folder } + [pscustomobject]@{ Path = $link; Target = $target } + ) + + $requests | New-NTFSHardLink -ErrorVariable linkErrors -ErrorAction SilentlyContinue + + $linkErrors | Should -HaveCount 3 + $linkErrors | ForEach-Object -Process { $_.FullyQualifiedErrorId | Should -BeLike 'CreateHardLinkError,*' } + ($linkErrors | ForEach-Object -Process { $_.CategoryInfo.Category }) -join ',' | Should -Be 'ResourceExists,ObjectNotFound,InvalidArgument' + ($linkErrors | ForEach-Object -Process { $_.TargetObject }) -join '|' | Should -Be (($existing, $missingLink, $folderLink) -join '|') + # Since 5.0.0-rc7, the error for a folder as -Target names the folder. + $linkErrors[2].Exception.Message | Should -BeLike ("*'{0}'*" -f [WildcardPattern]::Escape($folder)) + $link | Should -Exist + $missingLink | Should -Not -Exist + $folderLink | Should -Not -Exist + Get-Content -LiteralPath $existing | Should -Be 'ContinueExisting' + } + # 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) { @@ -129,7 +152,7 @@ Describe 'New-NTFSHardLink' { $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) + $result = @(New-NTFSHardLink -Path (ConvertTo-TestAdminSharePath -Sandbox $sandbox -Path $link) -Target (ConvertTo-TestAdminSharePath -Sandbox $sandbox -Path $target) -PassThru -ErrorVariable linkErrors -ErrorAction SilentlyContinue) $link | Should -Exist $linkErrors | Should -HaveCount 1 @@ -205,7 +228,7 @@ Describe 'Get-NTFSHardLink' { 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 + $sharePath = ConvertTo-TestAdminSharePath -Sandbox $sandbox -Path $file $result = @(Get-NTFSHardLink -Path $sharePath, $other -ErrorVariable linkErrors -ErrorAction SilentlyContinue) @@ -309,17 +332,128 @@ Describe 'New-NTFSSymbolicLink' { Test-Path2 -Path $link | Should -BeFalse } + # Before 5.0.0-rc7, an existing -Path stopped the pipeline with a terminating error. The cmdlet checks the paths + # before it creates a link, so this runs without the right to create symbolic links as well. + It 'Should write a non-terminating error for an existing -Path and continue with the next link' { + $target = New-TestSandboxItem -Sandbox $sandbox -Name 'SymbolicContinueTarget' + $existing = New-TestSandboxItem -Sandbox $sandbox -Name 'SymbolicContinueExisting' + $missing = Join-Path -Path $sandbox -ChildPath 'SymbolicContinueMissing.txt' + $link = Join-Path -Path $sandbox -ChildPath 'SymbolicContinueLink.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $missing, $link + $requests = @( + [pscustomobject]@{ Path = $existing; Target = $target } + [pscustomobject]@{ Path = $link; Target = $missing } + ) + + $requests | New-NTFSSymbolicLink -ErrorVariable linkErrors -ErrorAction SilentlyContinue + + $linkErrors | Should -HaveCount 2 + $linkErrors | ForEach-Object -Process { $_.FullyQualifiedErrorId | Should -BeLike 'CreateSymbolicLinkError,*' } + ($linkErrors | ForEach-Object -Process { $_.CategoryInfo.Category }) -join ',' | Should -Be 'ResourceExists,ObjectNotFound' + (Get-Item -LiteralPath $existing -Force).LinkType | Should -BeNullOrEmpty + 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 { + # localized, so the test compares the HRESULT of that error. Before 5.0.0-rc7, the error was terminating. + It 'Should write a non-terminating error 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 + New-NTFSSymbolicLink -Path $link -Target $target -ErrorVariable linkErrors -ErrorAction SilentlyContinue - $thrown.FullyQualifiedErrorId | Should -BeLike '*,NTFSSecurity.NewSymbolicLink' - '0x{0:X8}' -f $thrown.Exception.HResult | Should -Be '0x80070522' + $linkErrors | Should -HaveCount 1 + $linkErrors[0].FullyQualifiedErrorId | Should -BeLike 'CreateSymbolicLinkError,*' + '0x{0:X8}' -f $linkErrors[0].Exception.HResult | Should -Be '0x80070522' Test-Path2 -Path $link | Should -BeFalse } } + +# Each error names its item, so that the errors of many links can be told apart. Before 5.0.0-rc7, the errors of +# New-NTFSSymbolicLink for an existing -Path and a missing -Target named no path. No link is created, so the tests run +# without the right to create symbolic links as well. +Describe 'Errors of the cmdlets that create links' { + It ' should name the existing -Path and the missing -Target in its errors' -ForEach @( + @{ Command = 'New-NTFSHardLink' } + @{ Command = 'New-NTFSSymbolicLink' } + ) { + $target = New-TestSandboxItem -Sandbox $sandbox -Name 'NamedTarget' + $existing = New-TestSandboxItem -Sandbox $sandbox -Name 'NamedExisting' + $missing = Join-Path -Path $sandbox -ChildPath ('NamedMissing-{0}.txt' -f [guid]::NewGuid().ToString('N').Substring(0, 8)) + $link = Join-Path -Path $sandbox -ChildPath ('NamedLink-{0}.txt' -f [guid]::NewGuid().ToString('N').Substring(0, 8)) + Assert-TestSandboxPath -Sandbox $sandbox -Path $missing, $link + $requests = @( + [pscustomobject]@{ Path = $existing; Target = $target } + [pscustomobject]@{ Path = $link; Target = $missing } + ) + + $requests | & $Command -ErrorVariable linkErrors -ErrorAction SilentlyContinue + + $linkErrors | Should -HaveCount 2 + $linkErrors[0].Exception.Message | Should -BeLike ("*'{0}'*" -f [WildcardPattern]::Escape($existing)) + $linkErrors[1].Exception.Message | Should -BeLike ("*'{0}'*" -f [WildcardPattern]::Escape($missing)) + Test-Path2 -Path $link | Should -BeFalse + } + + # Both cmdlets check -Path before -Target. Before 5.0.0-rc7, New-NTFSSymbolicLink checked -Target first and + # reported a missing target instead. + It ' should report an existing -Path before a missing -Target' -ForEach @( + @{ Command = 'New-NTFSHardLink' } + @{ Command = 'New-NTFSSymbolicLink' } + ) { + $existing = New-TestSandboxItem -Sandbox $sandbox -Name 'FirstExisting' + $missing = Join-Path -Path $sandbox -ChildPath ('FirstMissing-{0}.txt' -f [guid]::NewGuid().ToString('N').Substring(0, 8)) + Assert-TestSandboxPath -Sandbox $sandbox -Path $missing + + & $Command -Path $existing -Target $missing -ErrorVariable linkErrors -ErrorAction SilentlyContinue + + $linkErrors | Should -HaveCount 1 + $linkErrors[0].CategoryInfo.Category | Should -Be 'ResourceExists' + $linkErrors[0].TargetObject | Should -Be $existing + Get-Content -LiteralPath $existing | Should -Be 'FirstExisting' + } +} + +# Windows PowerShell rejects a character that Windows doesn't allow in a path, such as |, before Windows sees the path. +# Before 5.0.0-rc7, that stopped the pipeline in Windows PowerShell, and PowerShell 7 reported it as a WriteError. +Describe 'Paths that Windows does not allow in the cmdlets that create links' { + It ' should write a non-terminating InvalidArgument error and continue with the next link' -ForEach @( + @{ Command = 'New-NTFSHardLink'; ErrorId = 'CreateHardLinkError' } + @{ Command = 'New-NTFSSymbolicLink'; ErrorId = 'CreateSymbolicLinkError' } + ) { + $target = New-TestSandboxItem -Sandbox $sandbox -Name 'InvalidTarget' + $existing = New-TestSandboxItem -Sandbox $sandbox -Name 'InvalidExisting' + $invalid = Join-Path -Path $sandbox -ChildPath 'Invalid|Link.txt' + # The second link exists, so that the cmdlet rejects it before it creates anything, also without the right to + # create symbolic links; its error shows that the cmdlet went on. + $requests = @( + [pscustomobject]@{ Path = $invalid; Target = $target } + [pscustomobject]@{ Path = $existing; Target = $target } + ) + + $requests | & $Command -ErrorVariable linkErrors -ErrorAction SilentlyContinue + + $linkErrors | Should -HaveCount 2 + $linkErrors | ForEach-Object -Process { $_.FullyQualifiedErrorId | Should -BeLike "$ErrorId,*" } + ($linkErrors | ForEach-Object -Process { $_.CategoryInfo.Category }) -join ',' | Should -Be 'InvalidArgument,ResourceExists' + $linkErrors[0].TargetObject | Should -Be $invalid + Get-Content -LiteralPath $existing | Should -Be 'InvalidExisting' + } +} + +# Before 5.0.0-rc7, both parameters were optional. Without -Path, the cmdlets failed with an index error; without -Target, +# they used the current location, so New-NTFSSymbolicLink -Path Link created a link to the current folder. +Describe 'Parameters of the cmdlets that create links' { + It ' should require -' -ForEach @( + @{ Command = 'New-NTFSHardLink'; Parameter = 'Path' } + @{ Command = 'New-NTFSHardLink'; Parameter = 'Target' } + @{ Command = 'New-NTFSSymbolicLink'; Parameter = 'Path' } + @{ Command = 'New-NTFSSymbolicLink'; Parameter = 'Target' } + ) { + $sets = @((Get-Command -Name $Command).Parameters[$Parameter].ParameterSets.Values) + + $sets | Should -Not -BeNullOrEmpty + $sets | ForEach-Object -Process { $_.IsMandatory | Should -BeTrue } + } +} diff --git a/Tests/Repository.Tests.ps1 b/Tests/Repository.Tests.ps1 index d13c8cd..07eeba6 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', '5.0.0-rc5' + $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', '5.0.0-rc6' $publishedVersions | Should -Not -Contain $version } diff --git a/Tests/TestHelpers.Tests.ps1 b/Tests/TestHelpers.Tests.ps1 index 1daf2f4..713ef18 100644 --- a/Tests/TestHelpers.Tests.ps1 +++ b/Tests/TestHelpers.Tests.ps1 @@ -13,6 +13,8 @@ BeforeDiscovery { $canAssignAnyOwner = Test-PrivilegeHeld -Name 'SeRestorePrivilege' # Reading and writing audit entries needs the Security privilege. $holdsSecurityPrivilege = Test-PrivilegeHeld -Name 'SeSecurityPrivilege' + $isElevated = Test-IsElevated + $canUseAdminShare = Test-AdminShareAvailable } BeforeAll { @@ -268,4 +270,38 @@ Describe 'Test helpers' { Test-PrivilegeHeld -Name 'SeNoSuchPrivilege' | Should -BeFalse } } + + # The administrative share of a drive, such as \\localhost\C$, is a network path and another volume for Windows. + Context 'ConvertTo-TestAdminSharePath and Test-AdminShareAvailable' { + BeforeAll { + $sandbox = New-TestSandbox -Name 'Helpers' + } + + AfterAll { + Remove-TestSandbox -Sandbox $sandbox + } + + It 'Should return the path of an item in the sandbox on the administrative share of its drive' { + $path = Join-Path -Path $sandbox -ChildPath 'Folder\File.txt' + + ConvertTo-TestAdminSharePath -Sandbox $sandbox -Path $path | + Should -Be ('\\localhost\' + $path.Substring(0, 1) + '$' + $path.Substring(2)) + } + + It 'Should reach the same item through the administrative share' -Skip:(-not $canUseAdminShare) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'AdminShare' + + Get-Content -LiteralPath (ConvertTo-TestAdminSharePath -Sandbox $sandbox -Path $file) | Should -Be 'AdminShare' + } + + It 'Should refuse a path outside the sandbox' { + { ConvertTo-TestAdminSharePath -Sandbox $sandbox -Path "$sandbox-Other\File.txt" } | + Should -Throw -ExpectedMessage 'Refusing to change*' + } + + # Only administrators can open the administrative shares. + It 'Should not offer the administrative share without elevation' -Skip:$isElevated { + Test-AdminShareAvailable | Should -BeFalse + } + } } diff --git a/Tests/TestHelpers.psm1 b/Tests/TestHelpers.psm1 index 6a15057..89c7ab9 100644 --- a/Tests/TestHelpers.psm1 +++ b/Tests/TestHelpers.psm1 @@ -381,6 +381,47 @@ function Test-PrivilegeHeld { Where-Object -Property Name -EQ -Value $Name) } +function Test-AdminShareAvailable { + <# + .SYNOPSIS + Returns $true when the process can open the administrative share of the drive of the sandboxes, such as + \\localhost\C$, which Windows treats as a network path and as another volume. Only administrators can. + #> + [CmdletBinding()] + [OutputType([bool])] + param () + + $drive = [IO.Path]::GetTempPath().Substring(0, 1) + (Test-IsElevated) -and [bool] (Test-Path -LiteralPath ('\\localhost\{0}$\' -f $drive) -ErrorAction SilentlyContinue) +} + +function ConvertTo-TestAdminSharePath { + <# + .SYNOPSIS + Returns the path of an item in a sandbox on the administrative share of its drive, such as + \\localhost\C$\Users\...\File.txt. It checks the local path with Assert-TestSandboxPath first, so that a test + reaches only items of its sandbox through the share. + .PARAMETER Sandbox + The sandbox folder that New-TestSandbox returned. + .PARAMETER Path + The full local path of the item. + #> + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory)] + [string] + $Sandbox, + + [Parameter(Mandatory)] + [string] + $Path + ) + + Assert-TestSandboxPath -Sandbox $Sandbox -Path $Path + '\\localhost\{0}${1}' -f $Path.Substring(0, 1), $Path.Substring(2) +} + Export-ModuleMember -Function New-TestSandbox, Assert-TestSandboxPath, Remove-TestSandbox, New-TestSandboxItem, Block-TestReadPermission, Block-TestWritePermission, Add-TestDenyRule, Set-TestOwner, Test-IsElevated, - Test-PrivilegeHeld + Test-PrivilegeHeld, Test-AdminShareAvailable, ConvertTo-TestAdminSharePath