Browse Source

Merge pull request #115 from raandree/ai/release-5.0.0-rc6

Release 5.0.0-rc6: quality gate Phase 2
pull/121/head 5.0.0-rc6
Raimund Andrée 3 days ago
committed by GitHub
parent
commit
b51d970c72
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 215
      .github/scripts/Invoke-TestsAsBasicUser.ps1
  2. 12
      .github/workflows/ci.yml
  3. 104
      .memory-bank/activeContext.md
  4. 37
      .memory-bank/decisions/0021-quality-gate-before-5.0.0.md
  5. 123
      .memory-bank/progress.md
  6. 28
      .memory-bank/systemPatterns.md
  7. 85
      .memory-bank/techContext.md
  8. 59
      CHANGELOG.md
  9. 2
      Docs/Cmdlets/Add-NTFSAccess.md
  10. 2
      Docs/Cmdlets/Clear-NTFSAccess.md
  11. 6
      Docs/Cmdlets/Copy-Item2.md
  12. 4
      Docs/Cmdlets/Get-NTFSAccess.md
  13. 2
      Docs/Cmdlets/Get-NTFSAudit.md
  14. 4
      Docs/Cmdlets/Get-NTFSEffectiveAccess.md
  15. 4
      Docs/Cmdlets/Get-NTFSHardLink.md
  16. 2
      Docs/Cmdlets/Get-NTFSOrphanedAccess.md
  17. 4
      Docs/Cmdlets/Get-NTFSSimpleAccess.md
  18. 8
      Docs/Cmdlets/Move-Item2.md
  19. 2
      Docs/Cmdlets/New-NTFSHardLink.md
  20. 6
      Docs/Cmdlets/New-NTFSSymbolicLink.md
  21. 2
      Docs/Cmdlets/Remove-NTFSAccess.md
  22. 4
      Docs/Cmdlets/Set-NTFSSecurityDescriptor.md
  23. 2
      Docs/Cmdlets/Test-Path2.md
  24. 25
      Docs/Concepts.md
  25. 13
      Docs/Contributing/02-Writing.md
  26. 5
      Docs/Contributing/05-Releasing.md
  27. 4
      Docs/FAQ.md
  28. 3
      NTFSSecurity/AccessCmdlets/GetOrphanedAccess.cs
  29. 101
      NTFSSecurity/BaseCmdlets.cs
  30. 16
      NTFSSecurity/ItemCmdlets/CopyItem2.cs
  31. 16
      NTFSSecurity/ItemCmdlets/MoveItem2.cs
  32. 16
      NTFSSecurity/LinkCmdlets/GetHardLink.cs
  33. 15
      NTFSSecurity/LinkCmdlets/NewHardLink.cs
  34. 2
      NTFSSecurity/NTFSSecurity.psd1
  35. 6
      NTFSSecurity/OtherCmdlets.cs
  36. 49
      NTFSSecurity/PathCmdlets/TestPath2.cs
  37. 20
      NTFSSecurity/SecurityDescriptorCmdlets/SetSecurityDescriptor.cs
  38. 4
      NTFSSecurity/SimpleAccessCmdlets/SimpleAccessCmdlets.cs
  39. 60
      NTFSSecurity/en-US/NTFSSecurity.dll-Help.xml
  40. 18
      Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.GetFileSystemAccessRules.cs
  41. 6
      Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.cs
  42. 20
      Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.GetFileSystemAuditRules.cs
  43. 6
      Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.cs
  44. 17
      Security2/FileSystem/FileSystemSecurity2.cs
  45. 4
      Security2/FileSystem/SimpleFileSystemAccessRule.cs
  46. 41
      Security2/Win32/Lib.cs
  47. 345
      Tests/Access.Tests.ps1
  48. 143
      Tests/Audit.Tests.ps1
  49. 2
      Tests/Inheritance.Tests.ps1
  50. 266
      Tests/ItemCmdlets.Tests.ps1
  51. 149
      Tests/Lab/Acceptance-2026-10-08-5.0.0-rc6.md
  52. 188
      Tests/Lab/Invoke-NTFSSecurityLabTest.ps1
  53. 417
      Tests/Lab/NTFSSecurity.Live.Tests.ps1
  54. 50
      Tests/Lab/README.md
  55. 281
      Tests/Links.Tests.ps1
  56. 22
      Tests/OutputTypes.Tests.ps1
  57. 177
      Tests/Owner.Tests.ps1
  58. 232
      Tests/PathErrors.Tests.ps1
  59. 143
      Tests/Privileges.Tests.ps1
  60. 33
      Tests/Repository.Tests.ps1
  61. 98
      Tests/SecurityDescriptor.Tests.ps1
  62. 165
      Tests/SecurityDescriptorSets.Tests.ps1
  63. 59
      Tests/TestHelpers.Tests.ps1
  64. 48
      Tests/TestHelpers.psm1

215
.github/scripts/Invoke-TestsAsBasicUser.ps1

@ -0,0 +1,215 @@
<#
.SYNOPSIS
Runs the Pester tests as a basic user, without the privileges and the Administrators group of the current account.
.DESCRIPTION
Some tests need a session without the Security, Restore, or Create Symbolic Link privilege, and skip in an elevated
session such as that of a GitHub runner. This script derives a token of the SAFER level Normal User from the token
of the current process, as runas /trustlevel:0x20000 does: the Administrators group is deny-only, and only the
privilege to bypass traverse checking stays. It starts Invoke-Tests.ps1 in the same PowerShell edition with that
token, waits for it, prints its output, and fails if the tests failed. The job summary of GitHub Actions gets the
results through a file of this account, which the restricted token can write.
.PARAMETER ResultPath
Specifies the path of the result file in the NUnit format.
.PARAMETER Title
Specifies the heading of the test results in the job summary. It can't contain a double quote, a percent sign, or a
line break, which would change the command line of cmd.exe.
.EXAMPLE
.\.github\scripts\Invoke-TestsAsBasicUser.ps1 -ResultPath TestResults\WindowsPowerShell-BasicUser.xml -Title 'Windows PowerShell 5.1 as a basic user'
Runs the tests in Windows PowerShell 5.1 as a basic user.
#>
[CmdletBinding()]
param (
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string]
$ResultPath,
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
# cmd.exe gets the title in a quoted argument, expands environment variables also inside quotes, and ends the
# command at a line break. \z, unlike $, doesn't match before a final line feed.
[ValidatePattern('\A[^"%\r\n]+\z')]
[string]
$Title
)
$ErrorActionPreference = 'Stop'
Add-Type -TypeDefinition @'
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
public static class NTFSSecurityBasicUserProcess
{
private const uint SaferScopeIdUser = 2;
private const uint SaferLevelIdNormalUser = 0x20000;
private const uint SaferLevelOpen = 1;
private const uint CreateNoWindow = 0x08000000;
private const uint Infinite = 0xFFFFFFFF;
private const uint WaitObject0 = 0;
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct StartupInfo
{
public int cb;
public string lpReserved;
public string lpDesktop;
public string lpTitle;
public int dwX;
public int dwY;
public int dwXSize;
public int dwYSize;
public int dwXCountChars;
public int dwYCountChars;
public int dwFillAttribute;
public int dwFlags;
public short wShowWindow;
public short cbReserved2;
public IntPtr lpReserved2;
public IntPtr hStdInput;
public IntPtr hStdOutput;
public IntPtr hStdError;
}
[StructLayout(LayoutKind.Sequential)]
private struct ProcessInformation
{
public IntPtr hProcess;
public IntPtr hThread;
public int dwProcessId;
public int dwThreadId;
}
[DllImport("advapi32.dll", SetLastError = true)]
private static extern bool SaferCreateLevel(uint scopeId, uint levelId, uint openFlags, out IntPtr levelHandle, IntPtr reserved);
[DllImport("advapi32.dll", SetLastError = true)]
private static extern bool SaferComputeTokenFromLevel(IntPtr levelHandle, IntPtr inAccessToken, out IntPtr outAccessToken, uint flags, IntPtr reserved);
[DllImport("advapi32.dll", SetLastError = true)]
private static extern bool SaferCloseLevel(IntPtr levelHandle);
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern bool CreateProcessAsUser(IntPtr token, string applicationName, string commandLine, IntPtr processAttributes, IntPtr threadAttributes, bool inheritHandles, uint creationFlags, IntPtr environment, string currentDirectory, ref StartupInfo startupInfo, out ProcessInformation processInformation);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool GetExitCodeProcess(IntPtr process, out uint exitCode);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool CloseHandle(IntPtr handle);
// Starts the command line with a token of the SAFER level Normal User, waits for it, and returns its exit code.
public static int Run(string applicationName, string commandLine, string currentDirectory)
{
IntPtr level;
if (!SaferCreateLevel(SaferScopeIdUser, SaferLevelIdNormalUser, SaferLevelOpen, out level, IntPtr.Zero))
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
IntPtr token = IntPtr.Zero;
try
{
if (!SaferComputeTokenFromLevel(level, IntPtr.Zero, out token, 0, IntPtr.Zero))
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
var startupInfo = new StartupInfo();
startupInfo.cb = Marshal.SizeOf(typeof(StartupInfo));
ProcessInformation processInformation;
if (!CreateProcessAsUser(token, applicationName, commandLine, IntPtr.Zero, IntPtr.Zero, false, CreateNoWindow, IntPtr.Zero, currentDirectory, ref startupInfo, out processInformation))
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
try
{
if (WaitForSingleObject(processInformation.hProcess, Infinite) != WaitObject0)
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
uint exitCode;
if (!GetExitCodeProcess(processInformation.hProcess, out exitCode))
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
return (int)exitCode;
}
finally
{
CloseHandle(processInformation.hThread);
CloseHandle(processInformation.hProcess);
}
}
finally
{
if (token != IntPtr.Zero)
{
CloseHandle(token);
}
SaferCloseLevel(level);
}
}
}
'@
$repositoryPath = (Resolve-Path -LiteralPath (Join-Path -Path $PSScriptRoot -ChildPath '..\..')).ProviderPath
$resultFullPath = [IO.Path]::GetFullPath((Join-Path -Path $repositoryPath -ChildPath $ResultPath))
$resultFolder = Split-Path -Path $resultFullPath -Parent
if (-not (Test-Path -LiteralPath $resultFolder)) {
New-Item -ItemType Directory -Path $resultFolder | Out-Null
}
$runFolder = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath ('NTFSSecurity.BasicUser-{0}' -f [guid]::NewGuid().ToString('N').Substring(0, 8))
New-Item -ItemType Directory -Path $runFolder | Out-Null
$logPath = Join-Path -Path $runFolder -ChildPath 'Output.log'
$summaryPath = Join-Path -Path $runFolder -ChildPath 'Summary.md'
# In the temp folder of the account, which the restricted token can write, unlike maybe the folder of the repository
$runResultPath = Join-Path -Path $runFolder -ChildPath 'Result.xml'
$executable = (Get-Process -Id $PID).Path
$testScript = Join-Path -Path $PSScriptRoot -ChildPath 'Invoke-Tests.ps1'
# cmd redirects the output of the tests, which have no console of their own.
$commandLine = 'cmd.exe /d /s /c ""{0}" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "{1}" -ResultPath "{2}" -Title "{3}" > "{4}" 2>&1"' -f
$executable, $testScript, $runResultPath, $Title, $logPath
$stepSummary = $env:GITHUB_STEP_SUMMARY
$env:GITHUB_STEP_SUMMARY = $summaryPath
try {
"Running the tests as a basic user: $commandLine"
$exitCode = [NTFSSecurityBasicUserProcess]::Run((Join-Path -Path $env:SystemRoot -ChildPath 'System32\cmd.exe'), $commandLine, $repositoryPath)
}
finally {
$env:GITHUB_STEP_SUMMARY = $stepSummary
}
if (Test-Path -LiteralPath $logPath) {
Get-Content -LiteralPath $logPath
}
if ($stepSummary -and (Test-Path -LiteralPath $summaryPath)) {
$encoding = New-Object -TypeName 'System.Text.UTF8Encoding' -ArgumentList $false
[IO.File]::AppendAllText($stepSummary, [IO.File]::ReadAllText($summaryPath), $encoding)
}
if (Test-Path -LiteralPath $runResultPath) {
Copy-Item -LiteralPath $runResultPath -Destination $resultFullPath -Force
}
Remove-Item -LiteralPath $runFolder -Recurse -Force
if ($exitCode -ne 0) {
throw "The tests as a basic user failed with exit code $exitCode."
}

12
.github/workflows/ci.yml

@ -121,6 +121,18 @@ jobs:
shell: pwsh
run: ./.github/scripts/Invoke-Tests.ps1 -ResultPath TestResults/PowerShell7.xml -Title 'PowerShell 7'
# Without the privileges and the Administrators group of the runner account, so the tests that an elevated
# session skips run as well
- name: Run the tests in Windows PowerShell 5.1 as a basic user
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
shell: powershell
run: .\.github\scripts\Invoke-TestsAsBasicUser.ps1 -ResultPath TestResults\WindowsPowerShell-BasicUser.xml -Title 'Windows PowerShell 5.1 as a basic user'
- name: Run the tests in PowerShell 7 as a basic user
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
shell: pwsh
run: ./.github/scripts/Invoke-TestsAsBasicUser.ps1 -ResultPath TestResults/PowerShell7-BasicUser.xml -Title 'PowerShell 7 as a basic user'
- name: Upload the test results
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1

104
.memory-bank/activeContext.md

@ -1,6 +1,6 @@
---
status: current
last-verified: 2026-10-07
last-verified: 2026-10-08
owner: active-agent
source: current task evidence
---
@ -9,50 +9,70 @@ source: current task evidence
## Current focus
5.0.0-rc5, prepared on the branch `ai/release-5.0.0-rc5`: the live tests
in `Tests\Lab` (Decision 20) and the fix of
`Get-NTFSEffectiveAccess -ServerName` that they found. The maintainer
pushes the branch, opens and merges the pull request, and tags
`5.0.0-rc5`; CI publishes it (Decision 12). Then, with the tester feedback
in #34, he decides on 5.0.0. After 5.0.0 the repository is archived in
favor of WindowsAccessControl (Decision 18).
Phase 2 of the quality gate before 5.0.0 (Decision 21) is complete on the
local branch `ai/release-5.0.0-rc6`, candidate `7b0781f`. The maintainer
pushes the branch, merges the pull request after CI, and tags 5.0.0-rc6.
Then Phase 3 (live tests on more operating systems) and 5.0.0; after
5.0.0 the repository is archived in favor of WindowsAccessControl
(Decision 18).
## Evidence
- 2026-10-07: the live tests ran in the lab of WindowsAccessControl
(`F1ADC1`, `F1AFile2`, `F1AFile1` in `a.forest1.net`) against the Gallery
packages of 5.0.0-rc2 and 5.0.0-rc4 and against the branch build, in
Windows PowerShell 5.1 and PowerShell 7, with the same results in both:
- Case 1 (#34 over SMB): rc2 fails `Add-NTFSAccess`, `Clear-NTFSAccess`,
and `Set-NTFSSecurityDescriptor` with error 1307, on folders with and
without the auto-inherit flag; the other four cmdlets succeed in rc2
too. rc4 passes all seven and keeps Administrators as the owner.
- Case 2: the administrators of the file server read, add, and remove
audit entries over SMB; the delegated account gets the errors that the
cmdlet pages describe, and the folders stay unchanged. Same in rc2.
- Case 3: with `-ServerName` of the file server, the result includes its
local group, without a warning; without it, only the domain groups
count. With a computer that can't be reached, rc2 and rc4 returned no
access, because error 1722 was swallowed; fixed on the branch.
- Case 4, long paths on the share, and #108 pass; #108 fails in rc2.
- The branch build passes every live test, and the 499 tests of `Tests`
in both editions.
- One `security-reviewer` pass approved the branch with minor findings;
the assertion of the fallback warning and the path guard of the live
tests were hardened. Deferred: the bare `catch` in
`Win32.GetEffectiveAccess`, which still swallows any other error of the
remote initialization (not reproducible: for a user who isn't an
administrator of the file server, the cmdlet writes "Access is denied");
the unchecked `AUTHZ_ACCESS_REPLY.Error`; a fallback warning that names
the server and the error, which would change behavior (Decision 16).
- #34: no report from the tester by 16:00 UTC on 2026-10-07; he announced
results against two file servers, one of them IBM ESS, for that day.
- The lab keeps the accounts, the share, and the folders of the last run;
`Invoke-NTFSSecurityLabTest.ps1 -RemoveFixture` removes them.
- 2026-10-08, Phase 2 on `ai/release-5.0.0-rc6`, 31 commits on `fcb370e`
(5.0.0-rc5); the candidate is `7b0781f`:
- The suite has 684 tests. Elevated: 662 passed and 22 skipped in
Windows PowerShell 5.1, 632 and 52 in PowerShell 7. As a basic user
through `Invoke-TestsAsBasicUser.ps1`: 590 and 94, 560 and 124. No
failure, and no test is skipped in all four configurations; CI runs
all four since this branch.
- C# coverage of all four configurations (AltCover without `--save`,
`techContext.md`): 68.1% of the lines and 44.3% of the branches on
`1b9edbb`; rc5 58.1% and 38.0%. The earlier figures counted one of
the four runs. Of the 972 points that no test ran at `e2b6b24`
(without the classes that no cmdlet calls), 400 are code that nothing
calls, 107 defensive guards, 74 need a failure of Windows, 4 need the
lab, and 387 are reachable; 51 of those ran in runs that the first
measurement missed, and the top items of the rest are in
`progress.md`, open work 7.
- Fixed test-first: `Get-NTFSSimpleAccess` (`ReadData`, the parent of a
relative path); `Copy-Item2` and `Move-Item2` (a folder at the
destination, the missing destination folder of #21, also with
`-WhatIf`); the hard-link cmdlets on shares and at folders;
`Set-NTFSSecurityDescriptor -PassThru` (R5); the error ID of
`Get-NTFSOrphanedAccess`; relative paths that start with a dot, which
every cmdlet shortened by two characters (`Remove-Item2 .x` removed
another item); comparing entries and descriptors
(`InvalidCastException`, also `Compare-Object` in PowerShell 7) and
their conversions; `InheritedFrom` with `-ExcludeExplicit` and for a
descriptor with audit entries (`ArgumentOutOfRangeException`).
`Copy-Item2` no longer creates the missing folders of a destination
for a folder (assumption, flagged for the maintainer).
- Live tests: cases 4b to 9 and accounts of `b.forest1.net`,
`forest2.net`, and `forest3.net`. The lab acceptance passed for
`acfe3af`, `1b9edbb`, and `7b0781f` (326 tests each, none failed;
`Tests/Lab/Acceptance-2026-10-08-5.0.0-rc6.md`). Baseline: the
published 5.0.0-rc5 fails only the two hard-link tests of case 8. The
fixture was removed and the removal checked after each run; three
checkpoints `ntfs-rc6-*-before-acceptance` stay on six machines.
- `Get-NTFSEffectiveAccess -ServerName` needs administrators or
Access Control Assistance Operators on the named computer (lab probe,
documented).
- Two `security-reviewer` passes approved the branch with no Blocker or
Major finding: `fcb370e..e2b6b24` (findings 1, 4, 5, 10 fixed in
`acfe3af`) and `e2b6b24..1b9edbb` (findings 1, 2, 6, 7 fixed in
`7b0781f`). Not fixed: a failed privilege disable isn't tried again,
and a non-qualified ACE could shift `InheritedFrom` (neither
reproducible); `New-NTFSHardLink` stays terminating for a folder (a
behavior change for the maintainer).
- #34: no reply from the tester since 2026-10-06.
## Next step
The maintainer runs the push and pull request commands of the session of
2026-10-07, merges, and tags `5.0.0-rc5`. Then check the published package
with `Invoke-NTFSSecurityLabTest.ps1 -Version 5.0.0-rc5`, wait for the #34
feedback, and release 5.0.0 as `progress.md` describes.
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).

37
.memory-bank/decisions/0021-quality-gate-before-5.0.0.md

@ -0,0 +1,37 @@
---
status: accepted
date: 2026-10-08
last-verified: 2026-10-08
owner: shared
source: maintainer decision of 2026-10-08
---
# Decision 21: A quality gate before 5.0.0
- Choice: 5.0.0 ships only at the highest quality, with everything tested
(maintainer, 2026-10-08). The gate has three phases:
1. Measure 5.0.0-rc5: done on 2026-10-08 (`progress.md`).
2. Add tests until every code path is tested or explained, live tests
for the remaining cmdlets, and test-first fixes of the known defects;
release them as 5.0.0-rc6. The maintainer approved it on 2026-10-08.
3. Run the live tests on more operating systems, such as a Windows 11
client and Server 2019 and 2022 file servers, then release 5.0.0.
- Exit criteria for 5.0.0, as proposed on 2026-10-08:
- Every cmdlet and parameter set has behavior tests, error paths
included.
- No test is skipped in every configuration that runs.
- The C# coverage is measured, and every path that no test runs is
tested or explained.
- Every known defect is fixed, or accepted by the maintainer and listed
in the release notes.
- The published package passes the live tests on every operating system
of the matrix.
- Rationale: rc5 passed every test that ran, but the tests ran 55.9% of
the code lines and 37.4% of the branches; five cmdlets had no tests of
their own, and 19 cmdlets never ran over SMB. (That measurement counted
only one of the four test runs; with all four, rc5 runs 58.1% of the
lines and 38.0% of the branches, see `techContext.md`.)
- Open: behavior changes found on the way stay the maintainer's decision
(Decision 16); so do the 244 lines of classes that no cmdlet calls, the
operating systems of Phase 3, and how to cover file servers that aren't
Windows (#34).

123
.memory-bank/progress.md

@ -1,6 +1,6 @@
---
status: current
last-verified: 2026-10-07
last-verified: 2026-10-08
owner: active-agent
source: repository evidence
---
@ -9,15 +9,15 @@ source: repository evidence
## Current status
5.0.0-rc4 is on the PowerShell Gallery and in the GitHub releases,
published by CI from the tag `5.0.0-rc4` on `master` (`01d9264`, the merge
of #113) on 2026-10-06 (Decision 12). It adds to 5.0.0-rc3 the fixes of the
issues #41, #108, #109, and #111 and of the leftovers of the rc3 review.
5.0.0-rc5 is prepared on the branch `ai/release-5.0.0-rc5`: the live tests
in a lab (Decision 20) and the fix of `Get-NTFSEffectiveAccess -ServerName`
that they found. The stable Gallery version is still 4.2.6. NTFSSecurity
will be archived soon; its users move to WindowsAccessControl
(Decision 18).
5.0.0-rc5 is on the PowerShell Gallery and in the GitHub releases,
published by CI on 2026-10-08 from the tag `5.0.0-rc5` on `master`
(`fcb370e`, the merge of #114; Decision 12). Phase 2 of the quality gate
(Decision 21) is complete on the local branch `ai/release-5.0.0-rc6`: the
candidate 5.0.0-rc6 (`acfe3af`) passed the suite in four configurations,
one `security-reviewer` pass, and the lab acceptance. It waits for the
maintainer to push, merge, and tag it; Phase 3 follows. The stable Gallery
version is still 4.2.6. NTFSSecurity will be archived soon; its users move
to WindowsAccessControl (Decision 18).
## Recent milestones
@ -68,6 +68,38 @@ will be archived soon; its users move to WindowsAccessControl
to fix it test-first in 5.0.0-rc5; the branch build passes all live tests
and the suite. One `security-reviewer` pass approved it with minor
findings (`activeContext.md`).
- 2026-10-08: #114 merged (`fcb370e`); the tag `5.0.0-rc5` published it to
the Gallery and the GitHub releases, whose `NTFSSecurity.zip` holds the
same 11 files. Phase 1 of the quality gate (Decision 21) measured rc5:
the published package passes the live tests in both editions; the 11
tests that need a session without the Security privilege pass as a basic
user, so every test runs in at least one configuration, but CI runs only
elevated; the suite runs 55.9% of the C# lines and 37.4% of the branches.
The maintainer approved Phase 2.
- 2026-10-08: Phase 2, step 1 on `ai/release-5.0.0-rc6` (local): tests for
`Set-NTFSOwner`, `Test-Path2`, `Get-DiskSpace`, and the link cmdlets
(suite: 555 tests). Fixed test-first: the privileges stayed enabled after
an early stop; `Test-Path2` stopped for invalid characters in Windows
PowerShell; and, from one `security-reviewer` pass, the privilege cleanup
decided on stale states, a defect since 4.2.6. The page of
`New-NTFSSymbolicLink` was corrected after a lab check of Developer Mode.
- 2026-10-08: Phase 2 finished on `ai/release-5.0.0-rc6` (local). Tests for
`Get-NTFSOrphanedAudit`, `Get-NTFSSimpleAccess`, the
`-SecurityDescriptor` parameter sets, the error contracts of all path
cmdlets, and #110. Fixed test-first: `Get-NTFSSimpleAccess` (`ReadData`,
relative paths), `Copy-Item2` and `Move-Item2` (folder conflicts, the
missing destination folder of #21), the hard-link cmdlets on shares,
`Set-NTFSSecurityDescriptor -PassThru` (R5), and the error ID of
`Get-NTFSOrphanedAccess`; from the coverage report, relative paths that
start with a dot (every cmdlet acted on the item without the first two
characters), comparing output objects (`InvalidCastException`), and
`InheritedFrom`. CI runs the suite as a basic user too; the live tests
cover all cmdlet groups and accounts of three more domains. Suite: 677
tests, none failed, none skipped in every configuration; C# coverage
68.1% of the lines and 44.3% of the branches (rc5: 58.1% and 38.0%,
measured again; the first measurements counted one of four runs). Two
`security-reviewer` passes; the lab acceptance of `7b0781f` passed
(`Tests/Lab/Acceptance-2026-10-08-5.0.0-rc6.md`).
## Stable capabilities
@ -82,15 +114,20 @@ will be archived soon; its users move to WindowsAccessControl
## Open work
1. Publish 5.0.0-rc5 (merge, then tag), check its package in the lab with
`Tests\Lab\Invoke-NTFSSecurityLabTest.ps1 -Version 5.0.0-rc5`, and then
release 5.0.0 through CI (Decision 12) with the tester feedback in #34:
remove the label, date `[Unreleased]` as `[5.0.0]`, add `5.0.0-rc5` to
`$publishedVersions`, and tag `5.0.0` (steps in
`Docs/Contributing/05-Releasing.md`). #34 stays open with Bug and Help
Wanted until a tester with a file server that refuses the owner
confirms the fix, or until 5.0.0 ships.
2. Issues: #110 (tests) is the open follow-up of the review findings; #68
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
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,
@ -104,11 +141,51 @@ will be archived soon; its users move to WindowsAccessControl
`AUTHZ_ACCESS_REPLY.Error`, a fallback warning without the server name,
and hardening of the lab controller (guards in the setup blocks,
interpolated `-EncodedCommand` paths, CredSSP by IP address, the
password string in memory, disabling the role accounts after a run).
4. `pwsh` 7.6.1 crashed three times during test runs on the ARM64
password string in memory, disabling the role accounts after a run); of
rc6, a privilege that fails to be disabled isn't tried again by
`Dispose` (finding 2, not reproducible).
4. Behavior changes found in Phase 2, for the maintainer (Decision 16):
`Get-NTFSOrphanedAudit` returns nothing without the Security privilege,
while `Get-NTFSAudit` writes `ReadSecurityError`;
`Get-NTFSSimpleAccess` skips a folder whose parent it didn't process;
`New-NTFSSymbolicLink` could create links without the privilege in
Developer Mode (flag `SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE`, with
a fallback before Windows 10 1703); `-WhatIf` names a conflict in a
verbose message instead of a warning (R7); the fallback warning of
`Get-NTFSEffectiveAccess` doesn't name the server; `Move-Item2` can't
move a folder to another volume; `New-NTFSHardLink` stops with a
terminating error for a folder, unlike `Get-NTFSHardLink` (rc6 review,
finding 11). Taken as an assumption in rc6, for review: `Copy-Item2`
no longer creates the missing folders of a destination for a folder.
Found by the coverage report of rc6: `-Target` of the link cmdlets is
optional and means the current location when it's omitted; equality of
entries and descriptors is the identity of the wrapped .NET object, so
two reads of the same entry differ for `Compare-Object` and
`Select-Object -Unique` (value equality would be a behavior change); 400
points of code that nothing calls besides the 244 lines of unused
classes (Phase 3).
5. `pwsh` 7.6.1 crashed three times during test runs on the ARM64
workstation (x64 emulation), without module frames; none of the CI runs
on native x64 on 2026-10-05 crashed.
5. Optional for the maintainer: delete the AppVeyor project and revoke its
6. Optional for the maintainer: delete the AppVeyor project and revoke its
GitHub authorization, restrict wiki editing to collaborators, ask
`Sup3rlativ3` to delete the Read the Docs project, and delete the branch
`test/transfer`.
`test/transfer`. In the lab, delete the checkpoints
`ntfs-rc6-*-before-acceptance` of the six machines when they are no
longer needed.
7. Reachable code that no test runs (coverage report of rc6, ranked by
impact; about 300 points): `Remove-Item2` on folders (`-Recurse`,
`-Force`, `DeleteError`); the owner restore after taking ownership
(`RestoreOwnerError`); the inheritance cmdlets on folders and
`Set-NTFSInheritance -AccessInheritanceEnabled $true`; the mapping of
all 13 `-AppliesTo` values and the flag parameters of
`Remove-NTFSAccess`, `Add-NTFSAudit`, and `Remove-NTFSAudit`; the
switches and errors of `Get-ChildItem2`; the table views and
`InheritedFrom` in them; `Move-Item2 -Force`; account input errors;
`-PassThru` after success of the audit and inheritance cmdlets;
`Set-NTFSSecurityDescriptor` failures; the audit cmdlets without the
Security privilege on a local item; `Get-NTFSEffectiveAccess` for an
unresolvable SID; failed ownership retries of `Clear-NTFSAccess` and
`Set-NTFSInheritance`. A display limit, not a defect: a conditional ACE
shows as an unconditional entry, because the .NET rules have no
condition.

28
.memory-bank/systemPatterns.md

@ -1,6 +1,6 @@
---
status: current
last-verified: 2026-10-07
last-verified: 2026-10-08
owner: active-agent
source: repository evidence
---
@ -35,7 +35,15 @@ NTFSSecurity.dll ── cmdlets ──> Security2.dll (FileSystemAccessRule2,
ownership and restores the previous owner on every exit path.
- `BaseCmdletWithPrivControl` enables Backup, Restore, TakeOwnership, and
Security in `BeginProcessing` when `PrivateData.EnablePrivileges` is
`$true`, and disables the ones it enabled in `EndProcessing`.
`$true`, and disables the ones it enabled in `EndProcessing` and, since
5.0.0-rc6, in `Dispose`: PowerShell skips `EndProcessing` when a later
command, such as `Select-Object -First`, or a terminating error stops the
pipeline, but calls `Dispose`. `Enable-Privileges` keeps them
(`KeepEnabledPrivileges`). The cleanup reads the current state of each
privilege, because another command in the pipeline can have changed it,
and tries every privilege even when one fails: `EndProcessing` warns,
`Dispose` stays silent, because PowerShell ignores exceptions thrown
there and no stream is open anymore.
- `PrivateData` switches: `EnablePrivileges`, `GetInheritedFrom`,
`GetFileSystemModeProperty`, `IdentifyHardLinks`, `ShowAccountSid`.
- Cmdlets accept `-Path` (alias `FullName`) or `-SecurityDescriptor`; the
@ -67,6 +75,7 @@ Each Decision record is a file in `decisions/`; read only the relevant ones.
| 18 | [NTFSSecurity will be archived](decisions/0018-archive-for-windowsaccesscontrol.md) |
| 19 | [Cmdlets write only the sections that they change](decisions/0019-write-only-changed-sections.md) |
| 20 | [Live tests in a lab live in Tests\Lab](decisions/0020-live-tests-in-tests-lab.md) |
| 21 | [A quality gate before 5.0.0](decisions/0021-quality-gate-before-5.0.0.md) |
## Patterns
@ -90,9 +99,20 @@ Each Decision record is a file in `decisions/`; read only the relevant ones.
- A test that changes files, links, or security descriptors uses
`Tests\TestHelpers.psm1`: its own sandbox, `Assert-TestSandboxPath`
before each change, `Remove-TestSandbox`. Cases that need a privilege
skip with `Test-PrivilegeHeld` and run in CI (elevated). `Block-Test*`
make a read or a write fail without elevation; `Set-TestOwner` with
skip with `Test-PrivilegeHeld`, cases that need its absence skip when
elevated; CI runs the suite elevated and as a basic user in both
editions, so each case runs somewhere. `Block-Test*` make a read or a
write fail without elevation; `Set-TestOwner` with
`EnablePrivileges = $false` reproduces an owner the user can't assign.
- Fixtures write a DACL with `SetAccessControl`, never with `Set-Acl`:
`Set-Acl` compares `AreAuditRulesProtected` of the new descriptor with
`AreAccessRulesProtected` of the item (`FileSystemSecurity.cs` of
PowerShell), so for an item with a protected DACL it writes the audit
section too. Without the Security privilege that fails with
`PrivilegeNotHeldException`; with it, `Set-Acl` writes every section and
drops the audit entries. Windows PowerShell has
`FileInfo`/`DirectoryInfo.SetAccessControl`; PowerShell 7 has
`[System.IO.FileSystemAclExtensions]::SetAccessControl`.
- `Get-Help -Online` tests run only in Windows PowerShell, which honors the
hook `BypassOnlineHelpRetrieval`. `Manifest.Tests.ps1` and
`Release.Tests.ps1` check the manifest, the version (Decision 10), the

85
.memory-bank/techContext.md

@ -1,6 +1,6 @@
---
status: current
last-verified: 2026-10-07
last-verified: 2026-10-08
owner: active-agent
source: repository evidence
---
@ -72,20 +72,24 @@ source: repository evidence
- The third workstation (`ExHost`, a Windows Server 2025 VM, x64, used since
2026-10-07) runs the agent session elevated and hosts the AutomatedLab lab
`WindowsAccessControlLab` (Decision 20) with Hyper-V and AutomatedLab
5.61.704. It has no NuGet cache, platyPS, or GitHub CLI: check each
5.61.704. It has no NuGet cache or platyPS: check each
nuget.org package against the SHA-512 `packageHash` of its catalog entry
(`https://api.nuget.org/v3/registration5-semver1/<id>/<version>.json`,
then `catalogEntry`), and each Gallery package against `PackageHash` of
`api/v2/Packages(Id='<id>',Version='<version>')`. Pester 5.7.1 is in
`V:\Git\WindowsAccessControl\output\RequiredModules`; read issues and pull
requests through the GitHub REST API. The lab domains `a.forest1.net` and
`b.forest1.net` had a maximum password age of 42 days, so the password of
`install` expired on 2026-09-15 and AutomatedLab got access denied; it
never expires since 2026-10-07, as in `forest1.net`.
`V:\Git\WindowsAccessControl\output\RequiredModules`. The GitHub CLI
2.102.0 is in `C:\Program Files\GitHub CLI`, outside the PATH, and signed
in as `raandree` since 2026-10-08; `Block-RemoteMutation` denies its
mutating commands, so the agent uses it read-only. The lab domains
`a.forest1.net` and `b.forest1.net` had a maximum password age of 42
days, so the password of `install` expired on 2026-09-15 and AutomatedLab
got access denied; it never expires since 2026-10-07, as in
`forest1.net`.
## Constraints
- `ModuleVersion` is `5.0.0` with the prerelease label `rc5`.
- `ModuleVersion` is `5.0.0` with the prerelease label `rc6` on the branch
`ai/release-5.0.0-rc6` (`rc5` on `master`).
The latest stable tag and Gallery release is `4.2.6`. The manifest
requires PowerShell 5.1 and .NET Framework 4.5.2, uses `RootModule`, and
lists exactly 36 cmdlets; `Test-ModuleManifest` passes in Windows
@ -96,7 +100,8 @@ source: repository evidence
- PowerShell Gallery versions (publish dates): 4.0.0 (2015-08-19), 4.2.2
(2016-05-18), 4.2.3 (2016-05-19), 4.2.4 (2018-08-13), 4.2.5 (2019-07-11),
4.2.6 (2019-07-12), none with release notes; 5.0.0-rc1 (2026-10-04),
5.0.0-rc2 (2026-10-05), 5.0.0-rc3 and 5.0.0-rc4 (2026-10-06), published
5.0.0-rc2 (2026-10-05), 5.0.0-rc3 and 5.0.0-rc4 (2026-10-06), 5.0.0-rc5
(2026-10-08), published
by CI. Older versions were released on CodePlex only, and their dates are
lost. The git history starts on 2016-10-10, when the project moved from
CodePlex.
@ -139,7 +144,9 @@ source: repository evidence
then: 01 `Update-MarkdownHelp` and fail on `git diff -- Docs/Cmdlets`; 02
`Get-MarkdownLink -BrokenOnly`; 03 regenerate the help file and fail on
`git status --porcelain -- NTFSSecurity/en-US`; 04 `Invoke-Tests.ps1` in
Windows PowerShell 5.1 and in PowerShell 7. Job `wiki` on `ubuntu-latest`
Windows PowerShell 5.1 and in PowerShell 7, then
`Invoke-TestsAsBasicUser.ps1` in both editions (since 5.0.0-rc6). Job
`wiki` on `ubuntu-latest`
(read-only) clones the wiki (`gh auth setup-git` with the built-in token),
runs `Export-WikiContent.ps1`, and lists the changed pages in the job
summary; job `publish-wiki` (`contents: write`) repeats that and publishes,
@ -159,7 +166,8 @@ source: repository evidence
ci.yml`, `gh pr checks <number>`, and `gh run view <id> --log-failed`
(read-only).
- Workflow lint: actionlint (download the release zip into `$env:TEMP` and
check its SHA-256 against the checksum file); PowerShell steps check
check its SHA-256 against the checksum file; 1.7.12 on 2026-10-08);
PowerShell steps check
`$LASTEXITCODE` after every native command, because GitHub checks only
the last one.
- Run platyPS in Windows PowerShell 5.1 to avoid PowerShell 7.4+
@ -171,16 +179,61 @@ source: repository evidence
PowerShell 5.1: the launcher starts `pwsh`, and its payload runs
`powershell.exe -NoProfile -EncodedCommand` with Pester imported by full
path. A run without `bin\Release\en-US` must fail.
- Tests that run only without a privilege skip in CI and in an elevated
session. Run them as a basic user with `runas /trustlevel:0x20000`, and
give Windows PowerShell its own `PSModulePath`; that token holds one
privilege, so the `Enable-Privileges -PassThru` count test fails there.
- Tests that run only without a privilege skip in an elevated session.
`.github\scripts\Invoke-TestsAsBasicUser.ps1` runs the suite from an
elevated session with a token of the SAFER level Normal User, like
`runas /trustlevel:0x20000`, and CI runs it in both editions. For a
single file, `runas /trustlevel:0x20000` works too; give Windows
PowerShell its own `PSModulePath`, and note that `runas` returns at once,
so the script it starts writes its own log. Both tokens hold only the
privilege to bypass traverse checking.
Pester reports a skipped `-ForEach` test under its template name, such as
`<_> should ...`, and a test that ran under the expanded name: compare
runs by template.
- C# coverage (Decision 21): AltCover 9.0.145 (`tools\net472\AltCover.exe`
of the nuget.org package) instruments a copy of the local Release build,
which has the PDB files that the published package lacks:
`--reportFormat=OpenCover`, AlphaFS and `System.Management.Automation`
excluded with `--assemblyFilter`, and no `--save`: then every process
writes its hits into the report when it exits. With `--save`, each
process writes a recorder file, and `runner --collect` keeps only the
first one (verified 2026-10-08), so the numbers measured that way held
only the main process of the elevated Windows PowerShell run. Put the
instrumented module in `NTFSSecurity\bin\Release` of a `git worktree`,
run `.github\scripts\Invoke-Tests.ps1` elevated and
`Invoke-TestsAsBasicUser.ps1` in both editions, then
`AltCover.exe runner --collect --recorderDirectory=<the instrumented
folder>`, which recalculates the summary of the report from the hits.
All four configurations, 2026-10-08: the rc5 tree 58.1% of the lines
(2,020 of 3,476) and 38.0% of the branches (711 of 1,873), 62.5% without
244 lines in classes that no cmdlet calls; the rc6 candidate (`1b9edbb`)
68.1% of the lines (2,412 of 3,540) and 44.3% of the branches (850 of
1,918), 73.2% without those classes, the `NTFSSecurity` assembly 78.1%.
The earlier figures, 55.9% for rc5 and 65.6% for rc6, used `--save`.
- Live tests (Decision 20): in an elevated Windows PowerShell 5.1 session
on the lab host, `Tests\Lab\Invoke-NTFSSecurityLabTest.ps1` with
`-Version` for Gallery packages or `-ModulePath` for a build; it writes
the results to `$env:TEMP\NTFSSecurityLab\Results`. A run of two versions
in both editions takes about 30 minutes; `-RemoveFixture` removes its
accounts, share, and folders from the lab.
accounts, share, and folders from the lab. For a check on the client as
an account without administrator rights, use `NtfsLiveServerAdmin`
(Remote Management Users on the client, CredSSP by IP address like the
controller): reset its password on the PDC emulator to a random value
in memory; the next run of the controller sets a new one anyway.
- Lab acceptance of a candidate (modeled on the WindowsAccessControl
handoff 07): build once, package it with `New-ModulePackage.ps1`, and
record the SHA-256 of the packages and module files; check WinRM, LDAP
(RootDSE), Kerberos (`klist get`), the secure channel, and the clock of
the six VMs; take a Production checkpoint named
`ntfs-<label>-<commit>-before-acceptance` of `F1ADC1`, `F1BDC1`,
`F2DC1`, `F3DC1`, `F1AFile1`, and `F1AFile2`; run the controller with
`-ModulePath` of the extracted `NTFSSecurity.zip` in both editions; then
`-RemoveFixture` and check that the accounts, share, folders, group
memberships, and profiles are gone.
- `Get-NTFSEffectiveAccess -ServerName`: the authorization manager of the
named computer answers only its administrators and the members of its
group Access Control Assistance Operators (S-1-5-32-579); others get
"Access is denied" (5). Lab probe of 2026-10-08 on `F1AFile2`.
- Markdown lint: `npx markdownlint-cli2` with `MD013` limited to prose
(tables, code, and headings excluded) on the conceptual pages; for
`CHANGELOG.md` also `MD024` with `siblings_only: true`, because every

59
CHANGELOG.md

@ -73,6 +73,10 @@ The format is based on
also an unchanged owner, which failed with error 1307 where the account
may not assign that owner
([#34](https://github.com/raandree/NTFSSecurity/issues/34))
- Document that `Get-NTFSEffectiveAccess -ServerName` works only for the
administrators of the named computer and the members of its group Access
Control Assistance Operators; any other account gets the error "Access is
denied" and no result
### Deprecated
@ -269,5 +273,60 @@ The format is based on
- Fix `Get-NTFSEffectiveAccess`, which returned no access when the computer
of `-ServerName` couldn't be reached, although it warned that it had
calculated the result on this computer; it now returns that result
- Fix `Test-Path2`, which stopped with the terminating error "Illegal
characters in path" in Windows PowerShell for a path with a character
that Windows doesn't allow in names, such as `|`; it now returns `$false`
for such a path, as in PowerShell 7, and writes the reason as a debug
message
- Fix the cmdlets that enable the Backup, Restore, Take Ownership, and
Security privileges, which left them enabled in the session when a later
command, such as `Select-Object -First`, or a terminating error stopped
the pipeline early; they now disable them also then
- Fix the cmdlets that enable the privileges, which stopped with the error
"Priviledge already disabled" and left the other privileges enabled when
another command in the pipeline, such as `Disable-Privileges`, had
disabled one of them; a privilege that they can't disable now gives a
warning, and they still disable the others
- Fix `Get-NTFSSimpleAccess`, which showed no rights for an entry that
grants only `ReadData`, and which left out the parent folder of a
relative path with a single folder name, although `-IncludeRootFolder` is
on by default
- Fix `Copy-Item2` and `Move-Item2`, which didn't detect a folder at the
destination, so that a copy failed in the middle after it had copied a
part of the folder, and which reported a missing destination folder with
an error that named the source item
([#21](https://github.com/raandree/NTFSSecurity/issues/21)); they now
write `DestinationFileAlreadyExists` and an error that names the missing
folder, and with `-WhatIf` a verbose message that names it. `Copy-Item2`
no longer creates the missing folders of the destination when it copies a
folder, which the prereleases of 5.0.0 did
- Fix `Get-NTFSHardLink`, which stopped for all remaining paths at a folder
and at a file on a network share, where Windows can't list the names of
a file, and `New-NTFSHardLink -PassThru`, which stopped with a
terminating error on a share after it had created the link; both now
write a non-terminating `GetHardLinkError`
- Fix `-PassThru` of `Set-NTFSSecurityDescriptor`, which returned nothing
for a descriptor that the cmdlet wrote as the owner, and which turned a
failed read after a successful write into another attempt of the write
and a `WriteSdError`; it now writes a `ReadSecurityError` for that read
- Fix `Get-NTFSOrphanedAccess`, which reported an item that it couldn't
read as an `AddAceError`; it now writes a `ReadSecurityError`, like
`Get-NTFSAccess`
- Fix every cmdlet for a relative path that starts with a dot but not with
`.\`, such as `.gitignore`: the cmdlets dropped its first two characters
and read, changed, or removed the item with the shorter name, such as
`itignore`, when one existed
- Fix comparing the objects of the access, audit, and security descriptor
cmdlets: `-eq` and `-contains`, and in PowerShell 7 also
`Select-Object -Unique` and `Compare-Object`, stopped with an
`InvalidCastException`, and a security descriptor as the key of a
hashtable with a `NullReferenceException`. Two objects are now equal when
they hold the same entry or descriptor, as in .NET. Converting a security
descriptor to `FileSecurity` or `DirectorySecurity` returned `$null`
- Fix `InheritedFrom` of `Get-NTFSAccess` and `Get-NTFSAudit`: with
`-ExcludeExplicit`, each inherited entry showed the source of another
entry, and `Get-NTFSAccess -SecurityDescriptor` stopped with an
`ArgumentOutOfRangeException` for a descriptor with audit entries, such
as one that `Get-NTFSSecurityDescriptor` reads in an elevated session
[Unreleased]: https://github.com/raandree/NTFSSecurity/compare/4.2.6...HEAD

2
Docs/Cmdlets/Add-NTFSAccess.md

@ -288,7 +288,7 @@ With `-PassThru`, the cmdlet writes all access control entries, explicit and inh
When the module setting `EnablePrivileges` is `$true` (the default in the `PrivateData` section of NTFSSecurity.psd1), this cmdlet tries to enable the Backup, Restore, Take Ownership, and Security privileges while it runs and disables the privileges it enabled when it finishes. These privileges are only available in an elevated session of an account that holds them, such as a member of the local Administrators group. If a privilege cannot be enabled, the cmdlet continues without it and writes a debug message.
If the ACL of an item cannot be written because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. Changing the owner of an item requires the Take Ownership and Restore privileges, so this fallback only succeeds in an elevated session of an account that holds them.
If the ACL of an item cannot be written because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. That fallback only succeeds when the account can take ownership of the item, through the Take Ownership right on the item or the Take Ownership privilege, and can set the previous owner back, which needs the Restore privilege unless that owner is the account itself or one of its groups. When the owner changes, Windows removes the entries for OWNER RIGHTS of the item.
In the `Path` parameter sets, the cmdlet reads and writes only the DACL of the item and leaves its owner, its group, and its SACL as they are. Before 5.0.0, it also wrote the owner back, which failed with error 1307, "This security ID may not be assigned as the owner of this object", when the account may not assign that owner, such as on some file servers. In an elevated session, it could also store the inherited entries of the item as explicit entries.

2
Docs/Cmdlets/Clear-NTFSAccess.md

@ -143,7 +143,7 @@ The cmdlet writes nothing. Use `Get-NTFSAccess` to inspect the result.
When the module setting `EnablePrivileges` is `$true` (the default in the `PrivateData` section of NTFSSecurity.psd1), this cmdlet tries to enable the Backup, Restore, Take Ownership, and Security privileges while it runs and disables the privileges it enabled when it finishes. These privileges are only available in an elevated session of an account that holds them, such as a member of the local Administrators group. If a privilege cannot be enabled, the cmdlet continues without it and writes a debug message.
If the ACL of an item cannot be written because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. Changing the owner of an item requires the Take Ownership and Restore privileges, so this fallback only succeeds in an elevated session of an account that holds them.
If the ACL of an item cannot be written because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. That fallback only succeeds when the account can take ownership of the item, through the Take Ownership right on the item or the Take Ownership privilege, and can set the previous owner back, which needs the Restore privilege unless that owner is the account itself or one of its groups. When the owner changes, Windows removes the entries for OWNER RIGHTS of the item.
In the `Path` parameter set, the cmdlet reads and writes only the DACL of the item and leaves its owner, its group, and its SACL as they are. Before 5.0.0, it also wrote the owner back, which failed with error 1307, "This security ID may not be assigned as the owner of this object", when the account may not assign that owner, such as on some file servers.

6
Docs/Cmdlets/Copy-Item2.md

@ -24,7 +24,7 @@ The `Copy-Item2` cmdlet copies the items in `-Path` to the location in `-Destina
How `-Destination` is interpreted depends on what is already there. If the value names an existing folder, the cmdlet keeps the name of the source item and copies it into that folder. In every other case the value is the full path of the new item, which lets you copy and rename in one step. `-Destination` is resolved against the current location once, when the cmdlet starts.
Without `-Force`, the cmdlet checks whether the destination file already exists and writes a `DestinationFileAlreadyExists` error instead of overwriting it. With `-WhatIf`, it names an existing destination file in a verbose message instead; before 5.0.0, it wrote the error also with `-WhatIf`. With `-Force`, an existing file is replaced. Relative paths and the `.` and `..` notations in `-Path` are resolved against the current location, and wildcard characters are not supported.
Without `-Force`, the cmdlet checks whether a file or folder already exists at the destination and writes a `DestinationFileAlreadyExists` error instead of overwriting it or merging into it. With `-WhatIf`, it names an existing destination in a verbose message instead; before 5.0.0, it wrote the error also with `-WhatIf`. With `-Force`, an existing file is replaced, and a folder is copied into an existing folder of the same name, replacing the files that exist in both. The folder that is to contain the new item must exist; otherwise the cmdlet writes an error that names that folder, or, with `-WhatIf`, a verbose message. Relative paths and the `.` and `..` notations in `-Path` are resolved against the current location, and wildcard characters are not supported.
The cmdlet supports `-WhatIf` and `-Confirm`, and it writes nothing to the pipeline unless you specify `-PassThru $true`.
@ -98,7 +98,7 @@ Accept wildcard characters: False
### -Force
Indicates that the cmdlet overwrites an existing destination file. Without `-Force`, an existing file causes the error `DestinationFileAlreadyExists` and the item is not copied.
Indicates that the cmdlet overwrites an existing destination file, and copies a folder into an existing folder of the same name. Without `-Force`, an existing file or folder at the destination causes the error `DestinationFileAlreadyExists` and the item is not copied.
```yaml
Type: SwitchParameter
@ -192,7 +192,7 @@ Before 5.0.0, `-PassThru` also wrote the item when `-WhatIf` or a declined confi
Before 5.0.0, copying a folder that contained files failed with a `CopyError` that reported a `DirectoryNotFoundException` for the first file in the folder.
If a path in `-Path` does not exist or the destination file exists and `-Force` is missing, the cmdlet writes a non-terminating error and continues with the next path. Before 5.0.0, it skipped the remaining paths that were passed in the same call.
If a path in `-Path` does not exist, a file or folder exists at the destination and `-Force` is missing, or the folder that is to contain the copy does not exist, the cmdlet writes a non-terminating error and continues with the next path. Before 5.0.0, it skipped the remaining paths that were passed in the same call. Before 5.0.0, it also didn't detect an existing destination folder, so that the copy failed in the middle with a `CopyError` after it had copied a part of the folder; it reported a missing destination folder as a `DirectoryNotFoundException` that named the source item; and, in the prereleases of 5.0.0, it created the missing folders of the destination for a folder.
## RELATED LINKS

4
Docs/Cmdlets/Get-NTFSAccess.md

@ -180,12 +180,14 @@ One object per access control entry, with the account, the rights, the access ty
When the module setting `EnablePrivileges` is `$true` (the default in the `PrivateData` section of NTFSSecurity.psd1), this cmdlet tries to enable the Backup, Restore, Take Ownership, and Security privileges while it runs and disables the privileges it enabled when it finishes. These privileges are only available in an elevated session of an account that holds them, such as a member of the local Administrators group. If a privilege cannot be enabled, the cmdlet continues without it and writes a debug message.
If the ACL of an item cannot be read because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. Changing the owner of an item requires the Take Ownership and Restore privileges, so this fallback only succeeds in an elevated session of an account that holds them.
If the ACL of an item cannot be read because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. Reading the owner, which that fallback needs first, requires the same Read Permissions right as reading the ACL, so the cmdlet then writes a non-terminating `ReadSecurityError` and continues with the next item.
Entries whose account cannot be translated into a name are returned with their SID. Use `Get-NTFSOrphanedAccess` to list only those entries.
Before 5.0.0, after a path whose ACL could not be read, the cmdlet returned the entries of the previous item again.
Before 5.0.0-rc6, with `-ExcludeExplicit`, each inherited entry showed the `InheritedFrom` path of another entry, and for a security descriptor with audit entries, such as one that `Get-NTFSSecurityDescriptor` reads in an elevated session, the cmdlet stopped with an `ArgumentOutOfRangeException`.
For the root of a drive, such as `C:\`, or of a volume, such as `\\?\Volume{GUID}\`, the cmdlets that read and change security use the root folder of the volume, like Explorer, `icacls`, and `Get-Acl`. Before 5.0.0, they read and changed the security descriptor of the drive itself, a device object with other entries.
## RELATED LINKS

2
Docs/Cmdlets/Get-NTFSAudit.md

@ -185,6 +185,8 @@ If reading the audit entries is denied, the cmdlet writes a `ReadSecurityError`
Before 5.0.0, the cmdlet returned no entries and no error without the Security privilege, and after a path whose security descriptor could not be read, it returned the entries of the previous item again. The `InheritanceEnabled` property of the entries also reported whether the access entries were inherited instead of the audit entries.
Before 5.0.0-rc6, with `-ExcludeExplicit`, each inherited entry showed the `InheritedFrom` path of another entry.
## RELATED LINKS
[Add-NTFSAudit](Add-NTFSAudit.md)

4
Docs/Cmdlets/Get-NTFSEffectiveAccess.md

@ -31,7 +31,7 @@ Calculates the rights an account really has on a file or a folder and writes the
The calculation covers the NTFS permissions of the item only. Share permissions are stored in a separate security descriptor and are not part of the result, so access over a network share can be more restrictive than this cmdlet reports.
When `-Account` is omitted, the account that runs the session is used. `-ServerName` selects the computer whose authorization manager resolves the group memberships of the account and defaults to `localhost`; when the remote authorization manager of the named computer cannot be reached, the cmdlet falls back to the local one and warns that the result is based on the group memberships known on this computer and may be inaccurate. Reading effective access relies on the Security privilege, and the cmdlet warns when the account does not hold it or the privilege is disabled.
When `-Account` is omitted, the account that runs the session is used. `-ServerName` selects the computer whose authorization manager resolves the group memberships of the account and defaults to `localhost`; when the remote authorization manager of the named computer cannot be reached, the cmdlet falls back to the local one and warns that the result is based on the group memberships known on this computer and may be inaccurate. The authorization manager of the named computer answers only the administrators of that computer and the members of its local group Access Control Assistance Operators; for any other account, the cmdlet writes an error and doesn't fall back. Reading effective access relies on the Security privilege, and the cmdlet warns when the account does not hold it or the privilege is disabled.
When `-Path` is omitted, the cmdlet calculates the effective access to the current location. In the `SecurityDescriptor` parameter set, it calculates the effective access from a `Security2.FileSystemSecurity2` object that `Get-NTFSSecurityDescriptor` returned, without reading the item again.
@ -139,7 +139,7 @@ Accept wildcard characters: False
### -ServerName
Specifies the computer whose authorization manager resolves the group memberships of the account. The default is `localhost`. Name the computer that stores the item when you query a network path, because the group memberships known there determine the result; if that computer cannot be reached, the cmdlet falls back to the local authorization manager and warns that the result may be inaccurate.
Specifies the computer whose authorization manager resolves the group memberships of the account. The default is `localhost`. Name the computer that stores the item when you query a network path, because the group memberships known there determine the result; if that computer cannot be reached, the cmdlet falls back to the local authorization manager and warns that the result may be inaccurate. The authorization manager of a computer answers only its administrators and the members of its local group Access Control Assistance Operators; for any other account, the cmdlet writes a non-terminating `GetEffectiveAccessError` with the message "Access is denied" and returns no result for the item.
```yaml
Type: String

4
Docs/Cmdlets/Get-NTFSHardLink.md

@ -23,7 +23,7 @@ On an NTFS volume, a file is a block of data that one or more directory entries,
A file that has only one name returns a single object. A file that has additional hard links returns one object per name, which lets you find all the places on the volume from which the same data is reachable. The file system reports the links relative to the root of the volume, and the cmdlet combines them with the root of the path you specify, so the result contains full paths. All hard links of a file are always on the same volume as the file.
`-Path` must point to a file. A folder causes an error, because NTFS does not support hard links to folders. If you omit `-Path`, the cmdlet falls back to the current location, which is a folder and therefore produces the same error, so always pass the path of a file.
`-Path` must point to a file. A folder causes a non-terminating `GetHardLinkError`, because NTFS does not support hard links to folders, and the cmdlet continues with the next path. If you omit `-Path`, the cmdlet falls back to the current location, which is a folder and therefore produces the same error, so always pass the path of a file.
The parameter accepts an array of paths and takes pipeline input by value and by property name through its `FullName` alias. `Get-ChildItem2` adds a `HardLinkCount` property to each file as long as the `IdentifyHardLinks` entry in the `PrivateData` section of the module manifest is `$true`, which lets you select the files that have more than one name before you resolve them.
@ -102,6 +102,8 @@ The cmdlet never writes folder objects, because it rejects folders with the erro
Hard links exist only within a single NTFS volume. Every object that this cmdlet returns therefore refers to a path on the volume of the file that you passed in.
Windows can't list the names of a file on a network share, also when the share lies on an NTFS volume of the file server. For such a file the cmdlet writes a non-terminating `GetHardLinkError` with the message "The request is not supported" and continues with the next path; run the cmdlet on the file server itself instead. Before 5.0.0, a file on a network share and a folder stopped the cmdlet with a terminating error, so that it skipped the remaining paths.
Because all hard links of a file share the same data, they also share the file content, the file size, and the time stamps. The security descriptor is stored with the file as well, so changing permissions through one name changes them for every name.
The cmdlet resolves paths through the AlphaFS library and therefore also works with paths that exceed the 260-character `MAX_PATH` limit.

2
Docs/Cmdlets/Get-NTFSOrphanedAccess.md

@ -172,7 +172,7 @@ One object per orphaned access control entry. The `Account` property holds the u
When the module setting `EnablePrivileges` is `$true` (the default in the `PrivateData` section of NTFSSecurity.psd1), this cmdlet tries to enable the Backup, Restore, Take Ownership, and Security privileges while it runs and disables the privileges it enabled when it finishes. These privileges are only available in an elevated session of an account that holds them, such as a member of the local Administrators group. If a privilege cannot be enabled, the cmdlet continues without it and writes a debug message.
If the ACL of an item cannot be read because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. Changing the owner of an item requires the Take Ownership and Restore privileges, so this fallback only succeeds in an elevated session of an account that holds them.
If the ACL of an item cannot be read because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. Reading the owner, which that fallback needs first, requires the same Read Permissions right as reading the ACL, so the cmdlet then writes a non-terminating `ReadSecurityError` and continues with the next item. Before 5.0.0, it reported that error as an `AddAceError`.
Before 5.0.0, the cmdlet ignored `-Account` and `-SecurityDescriptor`, and after a path whose ACL could not be read, it returned the orphaned entries of the previous item again.

4
Docs/Cmdlets/Get-NTFSSimpleAccess.md

@ -27,7 +27,7 @@ Get-NTFSSimpleAccess [-IncludeRootFolder] [-SecurityDescriptor] <FileSystemSecur
## DESCRIPTION
Reads the access control entries of folders and writes them as `Security2.SimpleFileSystemAccessRule` objects whose rights are reduced to the three values `Read`, `Write`, and `Delete`. Reading rights such as `ReadAttributes` or `Traverse` become `Read`, changing rights such as `CreateFiles`, `WriteAttributes`, `ChangePermissions`, or `TakeOwnership` become `Write`, and `Delete` and `DeleteSubdirectoriesAndFiles` become `Delete`; `FullControl` becomes all three. The result answers who may read, change, or delete in a folder without the detail of the full ACL.
Reads the access control entries of folders and writes them as `Security2.SimpleFileSystemAccessRule` objects whose rights are reduced to the three values `Read`, `Write`, and `Delete`. Reading rights such as `ReadData`, which on a folder is the right to list it (`ListDirectory`), `ReadAttributes`, or `Traverse` become `Read`, changing rights such as `CreateFiles`, `WriteAttributes`, `ChangePermissions`, or `TakeOwnership` become `Write`, and `Delete` and `DeleteSubdirectoriesAndFiles` become `Delete`; `FullControl` becomes all three. The result answers who may read, change, or delete in a folder without the detail of the full ACL.
The second simplification is that repetitions are left out. The first folder the cmdlet processes is reported with all of its entries, and for every folder that follows only the entries are reported that its parent folder does not already cover. An entry is covered when the parent has an entry for the same account and access type that includes at least the same simple rights. This makes a recursive listing show where permissions actually change instead of repeating the inherited ones on every level, and it requires the parent folder to be processed before its children, which `Get-ChildItem`, `Get-ChildItem2`, and `Get-Item2` do by default.
@ -190,7 +190,7 @@ When the module setting `EnablePrivileges` is `$true` (the default in the `Priva
The simplified rights hide which exact rights an account holds. Use `Get-NTFSAccess` when you need the full access control entry, and `Get-NTFSEffectiveAccess` when you need the rights that result from all entries together.
Before 5.0.0, the cmdlet ignored `-Account` and `-SecurityDescriptor`, and its output had no table view.
Before 5.0.0, the cmdlet ignored `-Account` and `-SecurityDescriptor`, and its output had no table view. It also showed no rights for an entry that grants only `ReadData`, which other tools than .NET create, and it left out the parent folder of a relative path with a single folder name, such as `Data`.
## RELATED LINKS

8
Docs/Cmdlets/Move-Item2.md

@ -24,7 +24,7 @@ The `Move-Item2` cmdlet moves the items in `-Path` to the location in `-Destinat
How `-Destination` is interpreted depends on what is already there. If the value names an existing folder, the cmdlet keeps the name of the source item and moves it into that folder. In every other case the value is the full path of the new item, which lets you move and rename in one step, or rename an item in place. `-Destination` is resolved against the current location once, when the cmdlet starts.
Without `-Force`, the cmdlet checks whether the destination file already exists and writes a `DestinationFileAlreadyExists` error instead of overwriting it; the move itself then runs with the `CopyAllowed` option, which allows a file to move to a different volume. With `-WhatIf`, the cmdlet names an existing destination file in a verbose message instead; before 5.0.0, it wrote the error also with `-WhatIf`. With `-Force`, the move runs with the `ReplaceExisting` option and overwrites an existing destination item.
Without `-Force`, the cmdlet checks whether a file or folder already exists at the destination and writes a `DestinationFileAlreadyExists` error instead of overwriting it; the move itself then runs with the `CopyAllowed` option, which allows a file to move to a different volume. With `-WhatIf`, the cmdlet names an existing destination in a verbose message instead; before 5.0.0, it wrote the error also with `-WhatIf`. With `-Force`, the move runs with the `ReplaceExisting` option and overwrites an existing destination item. The folder that is to contain the moved item must exist; otherwise the cmdlet writes an error that names that folder, or, with `-WhatIf`, a verbose message.
The cmdlet supports `-WhatIf` and `-Confirm`, and it writes nothing to the pipeline unless you specify `-PassThru $true`.
@ -98,7 +98,7 @@ Accept wildcard characters: False
### -Force
Indicates that the cmdlet replaces an existing destination item. Without `-Force`, an existing destination file causes the error `DestinationFileAlreadyExists` and the item is not moved.
Indicates that the cmdlet replaces an existing destination item. Without `-Force`, an existing file or folder at the destination causes the error `DestinationFileAlreadyExists` and the item is not moved.
```yaml
Type: SwitchParameter
@ -190,9 +190,9 @@ With `-PassThru $true` the cmdlet returns a folder object for each folder that i
Before 5.0.0, `-PassThru` also wrote the item when `-WhatIf` or a declined confirmation skipped the operation.
The cmdlet chooses between two mutually exclusive move options. Without `-Force` it moves with `CopyAllowed`, which permits a file to cross volume boundaries because Windows then copies and deletes it. With `-Force` it moves with `ReplaceExisting`, which overwrites the destination but does not request `CopyAllowed`, so a move across volumes can fail when `-Force` is specified.
The cmdlet chooses between two mutually exclusive move options. Without `-Force` it moves with `CopyAllowed`, which permits a file to cross volume boundaries because Windows then copies and deletes it. With `-Force` it moves with `ReplaceExisting`, which overwrites the destination but does not request `CopyAllowed`, so a move across volumes can fail when `-Force` is specified. A folder can't move to another volume: the cmdlet writes a `MoveError` and leaves the folder in place, so copy it with `Copy-Item2` and remove it with `Remove-Item2` instead.
If a path in `-Path` does not exist or the destination file exists and `-Force` is missing, the cmdlet writes a non-terminating error and continues with the next path. Before 5.0.0, it skipped the remaining paths that were passed in the same call.
If a path in `-Path` does not exist, a file or folder exists at the destination and `-Force` is missing, or the folder that is to contain the moved item does not exist, the cmdlet writes a non-terminating error and continues with the next path. Before 5.0.0, it skipped the remaining paths that were passed in the same call. Before 5.0.0, it also reported an existing destination folder as a `MoveError`, and a missing destination folder as a `DirectoryNotFoundException` that named the source item ([#21](https://github.com/raandree/NTFSSecurity/issues/21)).
## RELATED LINKS

2
Docs/Cmdlets/New-NTFSHardLink.md

@ -134,6 +134,8 @@ The cmdlet never writes folder objects, because hard links are supported for fil
Windows supports hard links only for files on the same NTFS volume. A link that points to a file on another volume, or a target on a file system that does not implement hard links, cannot be created.
The cmdlet creates hard links on a network share as well, but Windows can't list the names of a file there. With `-PassThru` on a share, the cmdlet creates the link and writes a non-terminating `GetHardLinkError` with the message "The request is not supported" instead of the objects. Before 5.0.0, it stopped with a terminating error after it had created the link.
The cmdlet does not overwrite anything. If `-Path` already exists, or if `-Target` is missing or is a folder, the cmdlet reports an error and leaves the file system unchanged.
Because all names of a file share the same data, the number of hard links is a property of the file, not of an individual name. Use `Get-NTFSHardLink` to list them, and delete a link with `Remove-Item2` or `Remove-Item`, which removes only that name as long as other names remain.

6
Docs/Cmdlets/New-NTFSSymbolicLink.md

@ -25,7 +25,7 @@ The cmdlet inspects the target first and creates a file symbolic link when the t
Relative paths are resolved against the current location before the link is created, which means that the link always stores an absolute target path.
By default the cmdlet produces no output. With `-PassThru` it returns a file object for the new link, including for a link that points to a folder.
By default the cmdlet produces no output. With `-PassThru` it returns an object for the new link: a file object for a link to a file, and a folder object for a link to a folder.
## EXAMPLES
@ -65,7 +65,7 @@ This command tests a path that leads through the symbolic link. It returns `$tru
### -PassThru
Indicates that the cmdlet returns an object for the new link. By default, this cmdlet produces no output. The returned object is a file object even when the link points to a folder.
Indicates that the cmdlet returns an object for the new link. By default, this cmdlet produces no output. The returned object is a file object for a link to a file and a folder object for a link to a folder.
```yaml
Type: SwitchParameter
@ -132,7 +132,7 @@ With `-PassThru`, the cmdlet writes a folder object for a new link to a folder.
## NOTES
Creating a symbolic link on Windows requires the "Create symbolic links" user right, `SeCreateSymbolicLinkPrivilege`, which is granted to the Administrators group by default. Without that right, Windows rejects the operation with error 1314, "A required privilege is not held by the client", so run the cmdlet from an elevated session or grant the right to the account. On a computer that runs in Windows Developer Mode, Windows also allows accounts without that right to create symbolic links.
Creating a symbolic link on Windows requires the "Create symbolic links" user right, `SeCreateSymbolicLinkPrivilege`, which is granted to the Administrators group by default. Without that right, Windows rejects the operation with error 1314, "A required privilege is not held by the client", so run the cmdlet from an elevated session or grant the right to the account. Windows Developer Mode doesn't change this: it lets accounts without that right create symbolic links only in programs that request it, such as `mklink`, and the cmdlet doesn't.
Unlike a hard link, a symbolic link is a separate file system entry that stores a path, so it can point to an item on another volume and the link and its target can be managed independently. The cmdlet still requires the target to exist at the moment the link is created. If the target is removed later, the link remains and stops resolving.

2
Docs/Cmdlets/Remove-NTFSAccess.md

@ -300,7 +300,7 @@ With `-PassThru`, the cmdlet writes all access control entries, explicit and inh
When the module setting `EnablePrivileges` is `$true` (the default in the `PrivateData` section of NTFSSecurity.psd1), this cmdlet tries to enable the Backup, Restore, Take Ownership, and Security privileges while it runs and disables the privileges it enabled when it finishes. These privileges are only available in an elevated session of an account that holds them, such as a member of the local Administrators group. If a privilege cannot be enabled, the cmdlet continues without it and writes a debug message.
If the ACL of an item cannot be written because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. Changing the owner of an item requires the Take Ownership and Restore privileges, so this fallback only succeeds in an elevated session of an account that holds them.
If the ACL of an item cannot be written because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. That fallback only succeeds when the account can take ownership of the item, through the Take Ownership right on the item or the Take Ownership privilege, and can set the previous owner back, which needs the Restore privilege unless that owner is the account itself or one of its groups. When the owner changes, Windows removes the entries for OWNER RIGHTS of the item.
Removing rights from an entry that does not exist is not an error; the cmdlet leaves the ACL unchanged.

4
Docs/Cmdlets/Set-NTFSSecurityDescriptor.md

@ -23,9 +23,9 @@ The `Set-NTFSSecurityDescriptor` cmdlet writes a `Security2.FileSystemSecurity2`
Each descriptor remembers the item it was read from, and the cmdlet writes it back to exactly that item. There is no parameter that redirects the write to a different path. The cmdlet writes only the sections of the descriptor that changed since it was read or last written, such as the DACL after `Add-NTFSAccess`, and leaves the other sections of the item as they are, so a descriptor that you did not change writes nothing. With `-Verbose`, the cmdlet names the sections that it writes, or says that it writes nothing. Before 5.0.0, the cmdlet wrote every section that it had read, also an unchanged owner, which failed with error 1307, "This security ID may not be assigned as the owner of this object", when the account may not assign that owner, such as on some file servers.
The cmdlet produces no output unless you use `-PassThru`, which reads the item again after the write and returns a new `FileSystemSecurity2` object that reflects what is now stored on disk. Descriptors can be passed as an array or through the pipeline, and each one is processed on its own.
The cmdlet produces no output unless you use `-PassThru`, which reads the item again after the write and returns a new `FileSystemSecurity2` object that reflects what is now stored on disk. If that read fails, for example because the written descriptor denies the account the right to read it, the cmdlet writes a non-terminating `ReadSecurityError`; the descriptor is written all the same. Descriptors can be passed as an array or through the pipeline, and each one is processed on its own.
When the write fails because access is denied, the cmdlet takes ownership of the item with the account of the current session, writes the descriptor, and restores the previous owner, also when that write fails. A descriptor that sets a new owner keeps it; before 5.0.0, the cmdlet set the previous owner back over it. If the write fails as well, the cmdlet writes a non-terminating error and continues with the next descriptor. Windows checks each section separately: changing the access control list requires the Change Permissions right on the item, changing the owner requires the Take Ownership right or the Take Ownership privilege, assigning ownership to another account requires the Restore privilege, and writing audit entries requires the Security privilege.
When the write fails because access is denied, the cmdlet takes ownership of the item with the account of the current session, writes the descriptor, and restores the previous owner, also when that write fails. A descriptor that sets a new owner keeps it; before 5.0.0, the cmdlet set the previous owner back over it. If setting the previous owner back fails, the cmdlet writes a non-terminating `RestoreOwnerError`, and the account of the session stays the owner of the item. If the write fails as well, the cmdlet writes a non-terminating error and continues with the next descriptor. Windows checks each section separately: changing the access control list requires the Change Permissions right on the item, changing the owner requires the Take Ownership right or the Take Ownership privilege, assigning ownership to another account requires the Restore privilege, and writing audit entries requires the Security privilege. Before 5.0.0, `-PassThru` returned nothing for a descriptor that the cmdlet wrote as the owner, and a failed read for `-PassThru` started another attempt of the write and ended in a `WriteSdError`, although the write had succeeded.
## EXAMPLES

2
Docs/Cmdlets/Test-Path2.md

@ -117,7 +117,7 @@ For each path, the cmdlet writes `$true` when the item exists and matches `-Path
The cmdlet resolves paths through the AlphaFS library, which is not bound by the 260-character `MAX_PATH` limit of the Windows PowerShell file system provider. Use `Test-Path2` instead of `Test-Path` when a path can be longer than that limit.
A path that does not exist is not an error condition. The cmdlet writes `$false` and continues with the next path.
A path that does not exist is not an error condition. The cmdlet writes `$false` and continues with the next path. This also applies to a path with a character that Windows doesn't allow in names, such as `|` or `<`; Windows PowerShell rejects such a path, and the cmdlet writes the reason as a debug message. Before 5.0.0, such a path stopped the cmdlet with the terminating error "Illegal characters in path" in Windows PowerShell.
## RELATED LINKS

25
Docs/Concepts.md

@ -188,11 +188,18 @@ session runs elevated (**Run as administrator**).
The access, audit, inheritance, owner, and security descriptor cmdlets enable
these privileges automatically while they run and disable the ones they
enabled when they finish. If a privilege cannot be enabled, the cmdlet
continues without it. You can turn this behavior off with the
`EnablePrivileges` module setting. Before 5.0.0, the inheritance cmdlets were
an exception: they always tried to enable the privileges, and when
`EnablePrivileges` was `$false`, they left them enabled.
enabled when they finish, also when a later command such as
`Select-Object -First` or a terminating error stops the pipeline early. A
privilege that another command in the pipeline, such as `Disable-Privileges`,
has disabled in the meantime stays disabled, and a privilege that a cmdlet
can't disable when it finishes gives a warning. If a
privilege cannot be enabled, the cmdlet continues without it. You can turn
this behavior off with the `EnablePrivileges` module setting. Before 5.0.0,
the inheritance cmdlets were an exception: they always tried to enable the
privileges, and when `EnablePrivileges` was `$false`, they left them enabled.
And before 5.0.0, every cmdlet left the privileges enabled in the session when
the pipeline stopped early, and stopped with the error "Priviledge already
disabled" when another command in the pipeline had disabled one of them.
`Enable-Privileges` enables the four privileges for the current PowerShell
process until you run `Disable-Privileges` or close the session.
@ -202,7 +209,13 @@ state.
When reading or changing an item fails with an access-denied error, most of
these cmdlets make the current user the owner of the item, retry, and then
restore the previous owner, also when the retry fails. Before 5.0.0, a failed
retry left the current user as the owner. This requires the privileges above.
retry left the current user as the owner. Taking ownership needs the Take
Ownership right on the item or the Take Ownership privilege, and setting the
previous owner back needs the Restore privilege unless that owner is the user
or one of its groups. When the owner of an item changes, Windows removes its
entries for OWNER RIGHTS, so the retry removes such entries as well. For
reading, the retry doesn't help: the cmdlet must read the owner first, which
needs the same right as reading the permissions.
Reading or changing audit entries always requires the Security privilege.
Without it, the audit cmdlets fail, and `Get-NTFSEffectiveAccess` warns that

13
Docs/Contributing/02-Writing.md

@ -119,7 +119,8 @@ Before you open a pull request, check the following:
- The Pester tests in `Tests` pass. They test the module in
`NTFSSecurity\bin\Release`, for example that `Get-Help` shows every page,
and the conversion to the wiki. The CI workflow runs them in Windows
PowerShell 5.1 and in PowerShell 7 with Pester 5.7.1:
PowerShell 5.1 and in PowerShell 7 with Pester 5.7.1, in the elevated
session of the runner and again as a basic user:
```powershell
Install-Module -Name Pester -RequiredVersion 5.7.1 -SkipPublisherCheck
@ -127,6 +128,16 @@ Before you open a pull request, check the following:
Invoke-Pester -Path .\Tests -Output Detailed
```
A test that needs a privilege skips without it, and a test that needs a
session without the privileges of an administrator skips in an elevated
session. To run the tests as a basic user from an elevated session, as the
CI workflow does, use `Invoke-TestsAsBasicUser.ps1`:
```powershell
.\.github\scripts\Invoke-TestsAsBasicUser.ps1 `
-ResultPath TestResults\BasicUser.xml -Title 'As a basic user'
```
The [live tests](../../Tests/Lab/README.md) in `Tests\Lab` need a lab with
a file server and domain accounts. Without one, they skip all their tests,
and the CI workflow doesn't run them.

5
Docs/Contributing/05-Releasing.md

@ -77,6 +77,11 @@ Gallery compares labels as text, so `rc10` sorts before `rc2`.
## Publish a release
Before you publish a release, run the live tests in a lab against the last
prerelease from the PowerShell Gallery, as the
[acceptance of a release candidate](../../Tests/Lab/README.md#acceptance-of-a-release-candidate)
describes, with `-Version` instead of `-ModulePath`.
1. Remove the `Prerelease` value from the module manifest.
2. In `CHANGELOG.md`, rename `## [Unreleased]` to the version with the
release date, such as `## [5.0.0] - 2026-10-31`, add an empty

4
Docs/FAQ.md

@ -71,5 +71,7 @@ No. The cmdlets read and write the file system directly through the AlphaFS
library, not through the PowerShell providers, so they don't know drives
that `New-PSDrive` created, or drives of other providers such as `HKLM:`.
Use the file system path instead, such as `C:\Data` or `\\server\share`. A
relative path is resolved against the current file system location. See
relative path is resolved against the current file system location, also a
name that starts with a dot, such as `.gitignore`; before 5.0.0-rc6, the
cmdlets dropped the first two characters of such a name. See
[Long paths](Concepts.md#long-paths).

3
NTFSSecurity/AccessCmdlets/GetOrphanedAccess.cs

@ -55,7 +55,8 @@ namespace NTFSSecurity
}
catch (Exception ex2)
{
this.WriteError(new ErrorRecord(ex2, "AddAceError", ErrorCategory.WriteError, path));
// A read error; before 5.0.0-rc6, it was reported as an AddAceError.
this.WriteError(new ErrorRecord(ex2, "ReadSecurityError", ErrorCategory.ReadError, path));
continue;
}
}

101
NTFSSecurity/BaseCmdlets.cs

@ -112,14 +112,14 @@ namespace NTFSSecurity
{
path = GetCurrentLocation();
}
else if (path.StartsWith(".."))
else if (path == ".." || path.StartsWith("..\\"))
{
var currentLocation = GetCurrentLocation();
path = System.IO.Path.Combine(
string.Join("\\", currentLocation.Split('\\').Take(currentLocation.Split('\\').Count() - path.Split('\\').Count(s => s == "..")).ToArray()),
string.Join("\\", path.Split('\\').Where(e => e != "..").ToArray()));
}
else if (path.StartsWith("."))
else if (path.StartsWith(".\\") || path.StartsWith("./"))
{
//combine . and .\path\subpath
path = System.IO.Path.Combine(GetCurrentLocation(), path.Substring(2));
@ -149,6 +149,45 @@ namespace NTFSSecurity
}
#endregion
#region WriteMissingDestinationFolderError
/// <summary>
/// Returns the folder of a destination path when that folder doesn't exist.
/// </summary>
/// <param name="destinationPath">The full path of the item that the operation would create.</param>
/// <returns>The missing folder, or null when the folder exists or the path has none, such as a share root.</returns>
protected string GetMissingDestinationFolder(string destinationPath)
{
var folder = Alphaleonis.Win32.Filesystem.Path.GetDirectoryName(destinationPath.TrimEnd('\\'));
if (string.IsNullOrEmpty(folder) || Alphaleonis.Win32.Filesystem.Directory.Exists(folder))
{
return null;
}
return folder;
}
/// <summary>
/// Writes an error that names the folder of a destination path when that folder doesn't exist. Before
/// 5.0.0-rc6, AlphaFS reported such a destination as the source path that could not be found (#21), and
/// Copy-Item2 created the missing folders for a folder.
/// </summary>
/// <param name="destinationPath">The full path of the item that the operation would create.</param>
/// <param name="errorId">The error ID of the cmdlet for a failed operation.</param>
/// <returns>Whether the folder is missing and the error was written.</returns>
protected bool WriteMissingDestinationFolderError(string destinationPath, string errorId)
{
var folder = GetMissingDestinationFolder(destinationPath);
if (folder == null)
{
return false;
}
var exception = new System.IO.DirectoryNotFoundException(string.Format("The destination folder '{0}' does not exist.", folder));
WriteError(new ErrorRecord(exception, errorId, ErrorCategory.ObjectNotFound, destinationPath));
return true;
}
#endregion
#region InvokeAsOwner
/// <summary>
/// Takes ownership of the item, runs the action, and restores the previous owner on every exit path.
@ -182,13 +221,19 @@ namespace NTFSSecurity
#endregion
}
public class BaseCmdletWithPrivControl : BaseCmdlet
public class BaseCmdletWithPrivControl : BaseCmdlet, IDisposable
{
protected PrivilegeAndAttributesCollection privileges = null;
protected PrivilegeControl privControl = new PrivilegeControl();
private List<string> enabledPrivileges = new List<string>();
Hashtable privateData = null;
// A cmdlet that enables the privileges for the session, such as Enable-Privileges, keeps them enabled.
protected virtual bool KeepEnabledPrivileges
{
get { return false; }
}
protected override void BeginProcessing()
{
privateData = (Hashtable)MyInvocation.MyCommand.Module.PrivateData;
@ -208,15 +253,55 @@ namespace NTFSSecurity
//disable all privileges that have been enabled by this cmdlet
WriteVerbose(string.Format("Disabeling all {0} enabled privileges...", enabledPrivileges.Count));
foreach (var privilege in enabledPrivileges)
var failed = new Dictionary<string, Exception>();
foreach (var privilege in DisableEnabledPrivileges(failed))
{
DisablePrivilege((Privilege)Enum.Parse(typeof(Privilege), privilege));
WriteVerbose(string.Format("\t{0} disabled", privilege));
}
foreach (var failure in failed)
{
WriteDebug(string.Format("Could not disable privilege {0}. The error was: {1}", failure.Key, failure.Value.Message));
WriteWarning(string.Format("The privilege '{0}' could not be disabled.", failure.Key));
}
WriteVerbose(string.Format("...finished"));
}
}
// PowerShell calls Dispose also when a later command or a terminating error stops the pipeline, and then
// skips EndProcessing. Before 5.0.0-rc6, the privileges that the cmdlet had enabled stayed enabled in the
// session in that case. PowerShell ignores an exception from Dispose and no stream is open anymore, so a
// privilege that can't be disabled goes unreported here.
public void Dispose()
{
DisableEnabledPrivileges(new Dictionary<string, Exception>());
GC.SuppressFinalize(this);
}
// Disables the privileges that this cmdlet enabled, once, and returns their names. A privilege that can't be
// disabled goes to failed with its error and doesn't keep the others enabled.
private List<string> DisableEnabledPrivileges(Dictionary<string, Exception> failed)
{
var disabled = new List<string>();
if (!KeepEnabledPrivileges)
{
foreach (var privilege in enabledPrivileges)
{
try
{
DisablePrivilege((Privilege)Enum.Parse(typeof(Privilege), privilege));
disabled.Add(privilege);
}
catch (Exception ex)
{
failed[privilege] = ex;
}
}
}
enabledPrivileges.Clear();
return disabled;
}
protected void EnablePrivilege(Privilege privilege)
{
//throw an exception if the specified prililege is not held by the client
@ -239,8 +324,10 @@ namespace NTFSSecurity
public void DisablePrivilege(Privilege privilege)
{
//if the privilege is enabled
if (privileges.Single(p => p.Privilege == privilege).PrivilegeState == PrivilegeState.Enabled)
// The current state, not the one that the cmdlet read when it enabled the privileges: another command, also
// one in the same pipeline, can have disabled the privilege since then. Before 5.0.0-rc6, the cmdlet then
// failed with "Priviledge already disabled" and left the privileges after this one enabled.
if (privControl.GetPrivileges().Any(p => p.Privilege == privilege && p.PrivilegeState == PrivilegeState.Enabled))
privControl.DisablePrivilege(privilege);
}

16
NTFSSecurity/ItemCmdlets/CopyItem2.cs

@ -88,7 +88,8 @@ namespace NTFSSecurity
actualDestination = destination;
}
var destinationExists = !force && File.Exists(actualDestination);
// A folder at the destination counts as well; before 5.0.0-rc6, only a file did.
var destinationExists = !force && (File.Exists(actualDestination) || Directory.Exists(actualDestination));
// Report a conflict only for an operation that runs; -WhatIf names it in a verbose message (#108).
if (!ShouldProcess(resolvedPath, item is FileInfo ? "Copy File" : "Copy Directory"))
@ -97,6 +98,14 @@ namespace NTFSSecurity
{
WriteVerbose(string.Format("The destination '{0}' already exists; without -Force, the copy would fail", actualDestination));
}
else
{
var missingFolder = GetMissingDestinationFolder(actualDestination);
if (missingFolder != null)
{
WriteVerbose(string.Format("The destination folder '{0}' does not exist; the copy would fail", missingFolder));
}
}
continue;
}
@ -107,6 +116,11 @@ namespace NTFSSecurity
continue;
}
if (WriteMissingDestinationFolderError(actualDestination, "CopyError"))
{
continue;
}
try
{
FileSystemInfo copy = null;

16
NTFSSecurity/ItemCmdlets/MoveItem2.cs

@ -88,7 +88,8 @@ namespace NTFSSecurity
actualDestination = destination;
}
var destinationExists = !force && File.Exists(actualDestination);
// A folder at the destination counts as well; before 5.0.0-rc6, only a file did.
var destinationExists = !force && (File.Exists(actualDestination) || Directory.Exists(actualDestination));
// Report a conflict only for an operation that runs; -WhatIf names it in a verbose message (#108).
if (!ShouldProcess(resolvedPath, item is FileInfo ? "Move File" : "Move Directory"))
@ -97,6 +98,14 @@ namespace NTFSSecurity
{
WriteVerbose(string.Format("The destination '{0}' already exists; without -Force, the move would fail", actualDestination));
}
else
{
var missingFolder = GetMissingDestinationFolder(actualDestination);
if (missingFolder != null)
{
WriteVerbose(string.Format("The destination folder '{0}' does not exist; the move would fail", missingFolder));
}
}
continue;
}
@ -107,6 +116,11 @@ namespace NTFSSecurity
continue;
}
if (WriteMissingDestinationFolderError(actualDestination, "MoveError"))
{
continue;
}
try
{
if (item is FileInfo)

16
NTFSSecurity/LinkCmdlets/GetHardLink.cs

@ -48,8 +48,12 @@ namespace NTFSSecurity
//access the path to make sure it exists and is a file
var item = GetFileSystemInfo2(path);
// An error for this path only; before 5.0.0-rc6, a folder stopped the cmdlet for all paths.
if (item is DirectoryInfo)
throw new ArgumentException("The item must be a file");
{
WriteError(new ErrorRecord(new ArgumentException("The item must be a file"), "GetHardLinkError", ErrorCategory.InvalidArgument, path));
continue;
}
var links = File.EnumerateHardlinks(item.FullName);
@ -64,6 +68,16 @@ namespace NTFSSecurity
{
WriteError(new ErrorRecord(ex, "FileNotFound", ErrorCategory.ObjectNotFound, path));
}
// Windows can't list the names of a file on a network share: (50) The request is not supported.
// Before 5.0.0-rc6, this stopped the cmdlet for all paths.
catch (System.IO.IOException ex)
{
WriteError(new ErrorRecord(ex, "GetHardLinkError", ErrorCategory.ReadError, path));
}
catch (UnauthorizedAccessException ex)
{
WriteError(new ErrorRecord(ex, "GetHardLinkError", ErrorCategory.PermissionDenied, path));
}
}
}

15
NTFSSecurity/LinkCmdlets/NewHardLink.cs

@ -1,5 +1,7 @@
using Alphaleonis.Win32.Filesystem;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Management.Automation;
namespace NTFSSecurity
@ -74,7 +76,18 @@ namespace NTFSSecurity
if (passThru)
{
var links = File.EnumerateHardlinks(path);
IEnumerable<string> 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)
{

2
NTFSSecurity/NTFSSecurity.psd1

@ -102,7 +102,7 @@
ProjectUri = 'https://github.com/raandree/NTFSSecurity'
ReleaseNotes = 'https://github.com/raandree/NTFSSecurity/blob/master/CHANGELOG.md'
# Remove the prerelease label for the final release, see Docs/Contributing/05-Releasing.md
Prerelease = 'rc5'
Prerelease = 'rc6'
}
}
}

6
NTFSSecurity/OtherCmdlets.cs

@ -61,6 +61,12 @@ namespace NTFSSecurity
{
//nothing as we want to keep the privileges enabled
}
// Keeps the privileges enabled also when the pipeline stops early and PowerShell calls only Dispose.
protected override bool KeepEnabledPrivileges
{
get { return true; }
}
}
#endregion Enable-Privileges

49
NTFSSecurity/PathCmdlets/TestPath2.cs

@ -45,32 +45,51 @@ namespace NTFSSecurity
{
foreach (var path in paths)
{
FileSystemInfo item = null;
try
{
FileSystemInfo item;
TryGetFileSystemInfo2(path, out item);
if (item == null)
WriteObject(false);
else
{
if (PathType == TestPathType.Any)
WriteObject(true);
else if (PathType == TestPathType.Container & item is DirectoryInfo)
WriteObject(true);
else if (PathType == TestPathType.Leaf & item is FileInfo)
WriteObject(true);
else
WriteObject(false);
}
}
catch (System.IO.FileNotFoundException ex)
{
WriteError(new ErrorRecord(ex, "PathNotFound", ErrorCategory.ObjectNotFound, path));
continue;
}
// In Windows PowerShell, .NET rejects a path with a character that Windows doesn't allow in names.
// Such an item can't exist, so the cmdlet writes $false, as in PowerShell 7 and like Test-Path.
catch (System.ArgumentException ex)
{
WriteInvalidPath(path, ex);
continue;
}
catch (System.NotSupportedException ex)
{
WriteInvalidPath(path, ex);
continue;
}
if (item == null)
WriteObject(false);
else
{
if (PathType == TestPathType.Any)
WriteObject(true);
else if (PathType == TestPathType.Container & item is DirectoryInfo)
WriteObject(true);
else if (PathType == TestPathType.Leaf & item is FileInfo)
WriteObject(true);
else
WriteObject(false);
}
}
}
private void WriteInvalidPath(string path, System.Exception exception)
{
WriteDebug(string.Format("'{0}' is not a valid path: {1}", path, exception.Message));
WriteObject(false);
}
protected override void EndProcessing()
{
base.EndProcessing();

20
NTFSSecurity/SecurityDescriptorCmdlets/SetSecurityDescriptor.cs

@ -52,11 +52,6 @@ namespace NTFSSecurity
}
sd.WriteChanges();
if (passThru)
{
WriteObject(new FileSystemSecurity2(sd.Item));
}
}
catch (UnauthorizedAccessException)
{
@ -73,6 +68,21 @@ namespace NTFSSecurity
catch (Exception ex)
{
WriteError(new ErrorRecord(ex, "WriteSdError", ErrorCategory.WriteError, sd.Item));
continue;
}
// After the write and outside its retry: before 5.0.0-rc6, a write that needed ownership wrote no
// object, and a denied read started a retry of the write and ended in a WriteSdError.
if (passThru)
{
try
{
WriteObject(new FileSystemSecurity2(sd.Item));
}
catch (Exception ex)
{
WriteError(new ErrorRecord(ex, "ReadSecurityError", ErrorCategory.ReadError, sd.Item));
}
}
}
}

4
NTFSSecurity/SimpleAccessCmdlets/SimpleAccessCmdlets.cs

@ -47,7 +47,9 @@ namespace NTFSSecurity
//as this cmdlet retreives also the current working folder to show the permissions.
if (includeRootFolder & isFirstFolder)
{
string rootPath = System.IO.Path.GetDirectoryName(paths[0]);
// Resolved first, so that a relative path with a single folder name has the current location as its
// parent folder; before 5.0.0-rc6, such a path had no parent folder in the result.
string rootPath = System.IO.Path.GetDirectoryName(GetRelativePath(paths[0]));
if (!string.IsNullOrEmpty(rootPath))
{

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

@ -719,7 +719,7 @@
<maml:alertSet>
<maml:alert>
<maml:para>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.</maml:para>
<maml:para>If the ACL of an item cannot be written because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. Changing the owner of an item requires the Take Ownership and Restore privileges, so this fallback only succeeds in an elevated session of an account that holds them.</maml:para>
<maml:para>If the ACL of an item cannot be written because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. That fallback only succeeds when the account can take ownership of the item, through the Take Ownership right on the item or the Take Ownership privilege, and can set the previous owner back, which needs the Restore privilege unless that owner is the account itself or one of its groups. When the owner changes, Windows removes the entries for OWNER RIGHTS of the item.</maml:para>
<maml:para>In the `Path` parameter sets, the cmdlet reads and writes only the DACL of the item and leaves its owner, its group, and its SACL as they are. Before 5.0.0, it also wrote the owner back, which failed with error 1307, "This security ID may not be assigned as the owner of this object", when the account may not assign that owner, such as on some file servers. In an elevated session, it could also store the inherited entries of the item as explicit entries.</maml:para>
</maml:alert>
</maml:alertSet>
@ -1711,7 +1711,7 @@ PS C:\&gt; Set-NTFSSecurityDescriptor -SecurityDescriptor $sd</dev:code>
<maml:alertSet>
<maml:alert>
<maml:para>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.</maml:para>
<maml:para>If the ACL of an item cannot be written because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. Changing the owner of an item requires the Take Ownership and Restore privileges, so this fallback only succeeds in an elevated session of an account that holds them.</maml:para>
<maml:para>If the ACL of an item cannot be written because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. That fallback only succeeds when the account can take ownership of the item, through the Take Ownership right on the item or the Take Ownership privilege, and can set the previous owner back, which needs the Restore privilege unless that owner is the account itself or one of its groups. When the owner changes, Windows removes the entries for OWNER RIGHTS of the item.</maml:para>
<maml:para>In the `Path` parameter set, the cmdlet reads and writes only the DACL of the item and leaves its owner, its group, and its SACL as they are. Before 5.0.0, it also wrote the owner back, which failed with error 1307, "This security ID may not be assigned as the owner of this object", when the account may not assign that owner, such as on some file servers.</maml:para>
</maml:alert>
</maml:alertSet>
@ -1997,7 +1997,7 @@ PS C:\&gt; Set-NTFSSecurityDescriptor -SecurityDescriptor $sd</dev:code>
<maml:description>
<maml:para>The `Copy-Item2` cmdlet copies the items in `-Path` to the location in `-Destination`. It is the long-path counterpart of the built-in `Copy-Item` cmdlet: it works through the AlphaFS library (`Alphaleonis.Win32.Filesystem`), so source and destination may be longer than the 260-character `MAX_PATH` limit.</maml:para>
<maml:para>How `-Destination` is interpreted depends on what is already there. If the value names an existing folder, the cmdlet keeps the name of the source item and copies it into that folder. In every other case the value is the full path of the new item, which lets you copy and rename in one step. `-Destination` is resolved against the current location once, when the cmdlet starts.</maml:para>
<maml:para>Without `-Force`, the cmdlet checks whether the destination file already exists and writes a `DestinationFileAlreadyExists` error instead of overwriting it. With `-WhatIf`, it names an existing destination file in a verbose message instead; before 5.0.0, it wrote the error also with `-WhatIf`. With `-Force`, an existing file is replaced. Relative paths and the `.` and `..` notations in `-Path` are resolved against the current location, and wildcard characters are not supported.</maml:para>
<maml:para>Without `-Force`, the cmdlet checks whether a file or folder already exists at the destination and writes a `DestinationFileAlreadyExists` error instead of overwriting it or merging into it. With `-WhatIf`, it names an existing destination in a verbose message instead; before 5.0.0, it wrote the error also with `-WhatIf`. With `-Force`, an existing file is replaced, and a folder is copied into an existing folder of the same name, replacing the files that exist in both. The folder that is to contain the new item must exist; otherwise the cmdlet writes an error that names that folder, or, with `-WhatIf`, a verbose message. Relative paths and the `.` and `..` notations in `-Path` are resolved against the current location, and wildcard characters are not supported.</maml:para>
<maml:para>The cmdlet supports `-WhatIf` and `-Confirm`, and it writes nothing to the pipeline unless you specify `-PassThru $true`.</maml:para>
</maml:description>
<command:syntax>
@ -2041,7 +2041,7 @@ PS C:\&gt; Set-NTFSSecurityDescriptor -SecurityDescriptor $sd</dev:code>
<command:parameter required="false" variableLength="true" globbing="false" pipelineInput="False" position="named" aliases="none">
<maml:name>Force</maml:name>
<maml:description>
<maml:para>Indicates that the cmdlet overwrites an existing destination file. Without `-Force`, an existing file causes the error `DestinationFileAlreadyExists` and the item is not copied.</maml:para>
<maml:para>Indicates that the cmdlet overwrites an existing destination file, and copies a folder into an existing folder of the same name. Without `-Force`, an existing file or folder at the destination causes the error `DestinationFileAlreadyExists` and the item is not copied.</maml:para>
</maml:description>
<dev:type>
<maml:name>SwitchParameter</maml:name>
@ -2102,7 +2102,7 @@ PS C:\&gt; Set-NTFSSecurityDescriptor -SecurityDescriptor $sd</dev:code>
<command:parameter required="false" variableLength="true" globbing="false" pipelineInput="False" position="named" aliases="none">
<maml:name>Force</maml:name>
<maml:description>
<maml:para>Indicates that the cmdlet overwrites an existing destination file. Without `-Force`, an existing file causes the error `DestinationFileAlreadyExists` and the item is not copied.</maml:para>
<maml:para>Indicates that the cmdlet overwrites an existing destination file, and copies a folder into an existing folder of the same name. Without `-Force`, an existing file or folder at the destination causes the error `DestinationFileAlreadyExists` and the item is not copied.</maml:para>
</maml:description>
<command:parameterValue required="false" variableLength="false">SwitchParameter</command:parameterValue>
<dev:type>
@ -2189,7 +2189,7 @@ PS C:\&gt; Set-NTFSSecurityDescriptor -SecurityDescriptor $sd</dev:code>
<maml:para>`Copy-Item2` copies through the AlphaFS library (`Alphaleonis.Win32.Filesystem`), which is why it handles source and destination paths that exceed the 260-character `MAX_PATH` limit of the built-in `Copy-Item` cmdlet.</maml:para>
<maml:para>Before 5.0.0, `-PassThru` also wrote the item when `-WhatIf` or a declined confirmation skipped the operation.</maml:para>
<maml:para>Before 5.0.0, copying a folder that contained files failed with a `CopyError` that reported a `DirectoryNotFoundException` for the first file in the folder.</maml:para>
<maml:para>If a path in `-Path` does not exist or the destination file exists and `-Force` is missing, the cmdlet writes a non-terminating error and continues with the next path. Before 5.0.0, it skipped the remaining paths that were passed in the same call.</maml:para>
<maml:para>If a path in `-Path` does not exist, a file or folder exists at the destination and `-Force` is missing, or the folder that is to contain the copy does not exist, the cmdlet writes a non-terminating error and continues with the next path. Before 5.0.0, it skipped the remaining paths that were passed in the same call. Before 5.0.0, it also didn't detect an existing destination folder, so that the copy failed in the middle with a `CopyError` after it had copied a part of the folder; it reported a missing destination folder as a `DirectoryNotFoundException` that named the source item; and, in the prereleases of 5.0.0, it created the missing folders of the destination for a folder.</maml:para>
</maml:alert>
</maml:alertSet>
<command:examples>
@ -4585,9 +4585,10 @@ PS C:\&gt; Disable-Privileges</dev:code>
<maml:alertSet>
<maml:alert>
<maml:para>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.</maml:para>
<maml:para>If the ACL of an item cannot be read because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. Changing the owner of an item requires the Take Ownership and Restore privileges, so this fallback only succeeds in an elevated session of an account that holds them.</maml:para>
<maml:para>If the ACL of an item cannot be read because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. Reading the owner, which that fallback needs first, requires the same Read Permissions right as reading the ACL, so the cmdlet then writes a non-terminating `ReadSecurityError` and continues with the next item.</maml:para>
<maml:para>Entries whose account cannot be translated into a name are returned with their SID. Use `Get-NTFSOrphanedAccess` to list only those entries.</maml:para>
<maml:para>Before 5.0.0, after a path whose ACL could not be read, the cmdlet returned the entries of the previous item again.</maml:para>
<maml:para>Before 5.0.0-rc6, with `-ExcludeExplicit`, each inherited entry showed the `InheritedFrom` path of another entry, and for a security descriptor with audit entries, such as one that `Get-NTFSSecurityDescriptor` reads in an elevated session, the cmdlet stopped with an `ArgumentOutOfRangeException`.</maml:para>
<maml:para>For the root of a drive, such as `C:`, or of a volume, such as `\?\Volume{GUID}`, the cmdlets that read and change security use the root folder of the volume, like Explorer, `icacls`, and `Get-Acl`. Before 5.0.0, they read and changed the security descriptor of the drive itself, a device object with other entries.</maml:para>
</maml:alert>
</maml:alertSet>
@ -4871,6 +4872,7 @@ PS C:\&gt; Disable-Privileges</dev:code>
<maml:para>Reading the SACL requires the Security privilege (`SeSecurityPrivilege`, "Manage auditing and security log"), so run this cmdlet in an elevated session of an account that holds that privilege. Without it, the cmdlet writes the non-terminating error `ReadSecurityError` for each item, which reports "A required privilege is not held by the client". `Get-NTFSSecurityDescriptor` reads a security descriptor without its SACL when the privilege is missing; for such a descriptor, the cmdlet writes a `ReadSecurityError` as well.</maml:para>
<maml:para>If reading the audit entries is denied, the cmdlet writes a `ReadSecurityError` with the category `PermissionDenied`. It doesn't take ownership of the item, because ownership grants no access to the SACL.</maml:para>
<maml:para>Before 5.0.0, the cmdlet returned no entries and no error without the Security privilege, and after a path whose security descriptor could not be read, it returned the entries of the previous item again. The `InheritanceEnabled` property of the entries also reported whether the access entries were inherited instead of the audit entries.</maml:para>
<maml:para>Before 5.0.0-rc6, with `-ExcludeExplicit`, each inherited entry showed the `InheritedFrom` path of another entry.</maml:para>
</maml:alert>
</maml:alertSet>
<command:examples>
@ -4943,7 +4945,7 @@ PS C:\&gt; Get-NTFSAudit -SecurityDescriptor $sd</dev:code>
<maml:description>
<maml:para>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.</maml:para>
<maml:para>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.</maml:para>
<maml:para>When `-Account` is omitted, the account that runs the session is used. `-ServerName` selects the computer whose authorization manager resolves the group memberships of the account and defaults to `localhost`; when the remote authorization manager of the named computer cannot be reached, the cmdlet falls back to the local one and warns that the result is based on the group memberships known on this computer and may be inaccurate. Reading effective access relies on the Security privilege, and the cmdlet warns when the account does not hold it or the privilege is disabled.</maml:para>
<maml:para>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.</maml:para>
<maml:para>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.</maml:para>
</maml:description>
<command:syntax>
@ -4987,7 +4989,7 @@ PS C:\&gt; Get-NTFSAudit -SecurityDescriptor $sd</dev:code>
<command:parameter required="false" variableLength="true" globbing="false" pipelineInput="False" position="named" aliases="none">
<maml:name>ServerName</maml:name>
<maml:description>
<maml:para>Specifies the computer whose authorization manager resolves the group memberships of the account. The default is `localhost`. Name the computer that stores the item when you query a network path, because the group memberships known there determine the result; if that computer cannot be reached, the cmdlet falls back to the local authorization manager and warns that the result may be inaccurate.</maml:para>
<maml:para>Specifies the computer whose authorization manager resolves the group memberships of the account. The default is `localhost`. Name the computer that stores the item when you query a network path, because the group memberships known there determine the result; if that computer cannot be reached, the cmdlet falls back to the local authorization manager and warns that the result may be inaccurate. The authorization manager of a computer answers only its administrators and the members of its local group Access Control Assistance Operators; for any other account, the cmdlet writes a non-terminating `GetEffectiveAccessError` with the message "Access is denied" and returns no result for the item.</maml:para>
</maml:description>
<command:parameterValue required="true" variableLength="false">String</command:parameterValue>
<dev:type>
@ -5038,7 +5040,7 @@ PS C:\&gt; Get-NTFSAudit -SecurityDescriptor $sd</dev:code>
<command:parameter required="false" variableLength="true" globbing="false" pipelineInput="False" position="named" aliases="none">
<maml:name>ServerName</maml:name>
<maml:description>
<maml:para>Specifies the computer whose authorization manager resolves the group memberships of the account. The default is `localhost`. Name the computer that stores the item when you query a network path, because the group memberships known there determine the result; if that computer cannot be reached, the cmdlet falls back to the local authorization manager and warns that the result may be inaccurate.</maml:para>
<maml:para>Specifies the computer whose authorization manager resolves the group memberships of the account. The default is `localhost`. Name the computer that stores the item when you query a network path, because the group memberships known there determine the result; if that computer cannot be reached, the cmdlet falls back to the local authorization manager and warns that the result may be inaccurate. The authorization manager of a computer answers only its administrators and the members of its local group Access Control Assistance Operators; for any other account, the cmdlet writes a non-terminating `GetEffectiveAccessError` with the message "Access is denied" and returns no result for the item.</maml:para>
</maml:description>
<command:parameterValue required="true" variableLength="false">String</command:parameterValue>
<dev:type>
@ -5102,7 +5104,7 @@ PS C:\&gt; Get-NTFSAudit -SecurityDescriptor $sd</dev:code>
<command:parameter required="false" variableLength="true" globbing="false" pipelineInput="False" position="named" aliases="none">
<maml:name>ServerName</maml:name>
<maml:description>
<maml:para>Specifies the computer whose authorization manager resolves the group memberships of the account. The default is `localhost`. Name the computer that stores the item when you query a network path, because the group memberships known there determine the result; if that computer cannot be reached, the cmdlet falls back to the local authorization manager and warns that the result may be inaccurate.</maml:para>
<maml:para>Specifies the computer whose authorization manager resolves the group memberships of the account. The default is `localhost`. Name the computer that stores the item when you query a network path, because the group memberships known there determine the result; if that computer cannot be reached, the cmdlet falls back to the local authorization manager and warns that the result may be inaccurate. The authorization manager of a computer answers only its administrators and the members of its local group Access Control Assistance Operators; for any other account, the cmdlet writes a non-terminating `GetEffectiveAccessError` with the message "Access is denied" and returns no result for the item.</maml:para>
</maml:description>
<command:parameterValue required="true" variableLength="false">String</command:parameterValue>
<dev:type>
@ -5224,7 +5226,7 @@ PS C:\&gt; Get-NTFSAudit -SecurityDescriptor $sd</dev:code>
<maml:description>
<maml:para>On an NTFS volume, a file is a block of data that one or more directory entries, called hard links, refer to. The `Get-NTFSHardLink` cmdlet asks the file system for every hard link of the file that `-Path` points to and writes a file object for each of them, including the name that you passed in.</maml:para>
<maml:para>A file that has only one name returns a single object. A file that has additional hard links returns one object per name, which lets you find all the places on the volume from which the same data is reachable. The file system reports the links relative to the root of the volume, and the cmdlet combines them with the root of the path you specify, so the result contains full paths. All hard links of a file are always on the same volume as the file.</maml:para>
<maml:para>`-Path` must point to a file. A folder causes an error, because NTFS does not support hard links to folders. If you omit `-Path`, the cmdlet falls back to the current location, which is a folder and therefore produces the same error, so always pass the path of a file.</maml:para>
<maml:para>`-Path` must point to a file. A folder causes a non-terminating `GetHardLinkError`, because NTFS does not support hard links to folders, and the cmdlet continues with the next path. If you omit `-Path`, the cmdlet falls back to the current location, which is a folder and therefore produces the same error, so always pass the path of a file.</maml:para>
<maml:para>The parameter accepts an array of paths and takes pipeline input by value and by property name through its `FullName` alias. `Get-ChildItem2` adds a `HardLinkCount` property to each file as long as the `IdentifyHardLinks` entry in the `PrivateData` section of the module manifest is `$true`, which lets you select the files that have more than one name before you resolve them.</maml:para>
</maml:description>
<command:syntax>
@ -5289,6 +5291,7 @@ PS C:\&gt; Get-NTFSAudit -SecurityDescriptor $sd</dev:code>
<maml:alertSet>
<maml:alert>
<maml:para>Hard links exist only within a single NTFS volume. Every object that this cmdlet returns therefore refers to a path on the volume of the file that you passed in.</maml:para>
<maml:para>Windows can't list the names of a file on a network share, also when the share lies on an NTFS volume of the file server. For such a file the cmdlet writes a non-terminating `GetHardLinkError` with the message "The request is not supported" and continues with the next path; run the cmdlet on the file server itself instead. Before 5.0.0, a file on a network share and a folder stopped the cmdlet with a terminating error, so that it skipped the remaining paths.</maml:para>
<maml:para>Because all hard links of a file share the same data, they also share the file content, the file size, and the time stamps. The security descriptor is stored with the file as well, so changing permissions through one name changes them for every name.</maml:para>
<maml:para>The cmdlet resolves paths through the AlphaFS library and therefore also works with paths that exceed the 260-character `MAX_PATH` limit.</maml:para>
</maml:alert>
@ -5740,7 +5743,7 @@ PS C:\&gt; Get-NTFSAudit -SecurityDescriptor $sd</dev:code>
<maml:alertSet>
<maml:alert>
<maml:para>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.</maml:para>
<maml:para>If the ACL of an item cannot be read because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. Changing the owner of an item requires the Take Ownership and Restore privileges, so this fallback only succeeds in an elevated session of an account that holds them.</maml:para>
<maml:para>If the ACL of an item cannot be read because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. Reading the owner, which that fallback needs first, requires the same Read Permissions right as reading the ACL, so the cmdlet then writes a non-terminating `ReadSecurityError` and continues with the next item. Before 5.0.0, it reported that error as an `AddAceError`.</maml:para>
<maml:para>Before 5.0.0, the cmdlet ignored `-Account` and `-SecurityDescriptor`, and after a path whose ACL could not be read, it returned the orphaned entries of the previous item again.</maml:para>
</maml:alert>
</maml:alertSet>
@ -6381,7 +6384,7 @@ PS C:\Data&gt; Get-NTFSSecurityDescriptor</dev:code>
</maml:description>
</command:details>
<maml:description>
<maml:para>Reads the access control entries of folders and writes them as `Security2.SimpleFileSystemAccessRule` objects whose rights are reduced to the three values `Read`, `Write`, and `Delete`. Reading rights such as `ReadAttributes` or `Traverse` become `Read`, changing rights such as `CreateFiles`, `WriteAttributes`, `ChangePermissions`, or `TakeOwnership` become `Write`, and `Delete` and `DeleteSubdirectoriesAndFiles` become `Delete`; `FullControl` becomes all three. The result answers who may read, change, or delete in a folder without the detail of the full ACL.</maml:para>
<maml:para>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.</maml:para>
<maml:para>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.</maml:para>
<maml:para>`-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.</maml:para>
<maml:para>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.</maml:para>
@ -6624,7 +6627,7 @@ PS C:\Data&gt; Get-NTFSSecurityDescriptor</dev:code>
<maml:alert>
<maml:para>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.</maml:para>
<maml:para>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.</maml:para>
<maml:para>Before 5.0.0, the cmdlet ignored `-Account` and `-SecurityDescriptor`, and its output had no table view.</maml:para>
<maml:para>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`.</maml:para>
</maml:alert>
</maml:alertSet>
<command:examples>
@ -6784,7 +6787,7 @@ PS C:\Data&gt; Get-NTFSSecurityDescriptor</dev:code>
<maml:description>
<maml:para>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.</maml:para>
<maml:para>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.</maml:para>
<maml:para>Without `-Force`, the cmdlet checks whether the destination file already exists and writes a `DestinationFileAlreadyExists` error instead of overwriting it; the move itself then runs with the `CopyAllowed` option, which allows a file to move to a different volume. With `-WhatIf`, the cmdlet names an existing destination file in a verbose message instead; before 5.0.0, it wrote the error also with `-WhatIf`. With `-Force`, the move runs with the `ReplaceExisting` option and overwrites an existing destination item.</maml:para>
<maml:para>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.</maml:para>
<maml:para>The cmdlet supports `-WhatIf` and `-Confirm`, and it writes nothing to the pipeline unless you specify `-PassThru $true`.</maml:para>
</maml:description>
<command:syntax>
@ -6828,7 +6831,7 @@ PS C:\Data&gt; Get-NTFSSecurityDescriptor</dev:code>
<command:parameter required="false" variableLength="true" globbing="false" pipelineInput="False" position="named" aliases="none">
<maml:name>Force</maml:name>
<maml:description>
<maml:para>Indicates that the cmdlet replaces an existing destination item. Without `-Force`, an existing destination file causes the error `DestinationFileAlreadyExists` and the item is not moved.</maml:para>
<maml:para>Indicates that the cmdlet replaces an existing destination item. Without `-Force`, an existing file or folder at the destination causes the error `DestinationFileAlreadyExists` and the item is not moved.</maml:para>
</maml:description>
<dev:type>
<maml:name>SwitchParameter</maml:name>
@ -6889,7 +6892,7 @@ PS C:\Data&gt; Get-NTFSSecurityDescriptor</dev:code>
<command:parameter required="false" variableLength="true" globbing="false" pipelineInput="False" position="named" aliases="none">
<maml:name>Force</maml:name>
<maml:description>
<maml:para>Indicates that the cmdlet replaces an existing destination item. Without `-Force`, an existing destination file causes the error `DestinationFileAlreadyExists` and the item is not moved.</maml:para>
<maml:para>Indicates that the cmdlet replaces an existing destination item. Without `-Force`, an existing file or folder at the destination causes the error `DestinationFileAlreadyExists` and the item is not moved.</maml:para>
</maml:description>
<command:parameterValue required="false" variableLength="false">SwitchParameter</command:parameterValue>
<dev:type>
@ -6975,8 +6978,8 @@ PS C:\Data&gt; Get-NTFSSecurityDescriptor</dev:code>
<maml:alert>
<maml:para>`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.</maml:para>
<maml:para>Before 5.0.0, `-PassThru` also wrote the item when `-WhatIf` or a declined confirmation skipped the operation.</maml:para>
<maml:para>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.</maml:para>
<maml:para>If a path in `-Path` does not exist or the destination file exists and `-Force` is missing, the cmdlet writes a non-terminating error and continues with the next path. Before 5.0.0, it skipped the remaining paths that were passed in the same call.</maml:para>
<maml:para>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.</maml:para>
<maml:para>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)).</maml:para>
</maml:alert>
</maml:alertSet>
<command:examples>
@ -7160,6 +7163,7 @@ PS C:\Data&gt; Get-NTFSSecurityDescriptor</dev:code>
<maml:alertSet>
<maml:alert>
<maml:para>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.</maml:para>
<maml:para>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.</maml:para>
<maml:para>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.</maml:para>
<maml:para>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.</maml:para>
<maml:para>Before 5.0.0, the error for a missing `-Target` said "The target path exist", the opposite of the cause.</maml:para>
@ -7231,7 +7235,7 @@ PS C:\Data&gt; Get-NTFSSecurityDescriptor</dev:code>
<maml:para>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 ".</maml:para>
<maml:para>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.</maml:para>
<maml:para>Relative paths are resolved against the current location before the link is created, which means that the link always stores an absolute target path.</maml:para>
<maml:para>By default the cmdlet produces no output. With `-PassThru` it returns a file object for the new link, including for a link that points to a folder.</maml:para>
<maml:para>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.</maml:para>
</maml:description>
<command:syntax>
<command:syntaxItem>
@ -7263,7 +7267,7 @@ PS C:\Data&gt; Get-NTFSSecurityDescriptor</dev:code>
<command:parameter required="false" variableLength="true" globbing="false" pipelineInput="False" position="named" aliases="none">
<maml:name>PassThru</maml:name>
<maml:description>
<maml:para>Indicates that the cmdlet returns an object for the new link. By default, this cmdlet produces no output. The returned object is a file object even when the link points to a folder.</maml:para>
<maml:para>Indicates that the cmdlet returns an object for the new link. By default, this cmdlet produces no output. The returned object is a file object for a link to a file and a folder object for a link to a folder.</maml:para>
</maml:description>
<dev:type>
<maml:name>SwitchParameter</maml:name>
@ -7277,7 +7281,7 @@ PS C:\Data&gt; Get-NTFSSecurityDescriptor</dev:code>
<command:parameter required="false" variableLength="true" globbing="false" pipelineInput="False" position="named" aliases="none">
<maml:name>PassThru</maml:name>
<maml:description>
<maml:para>Indicates that the cmdlet returns an object for the new link. By default, this cmdlet produces no output. The returned object is a file object even when the link points to a folder.</maml:para>
<maml:para>Indicates that the cmdlet returns an object for the new link. By default, this cmdlet produces no output. The returned object is a file object for a link to a file and a folder object for a link to a folder.</maml:para>
</maml:description>
<command:parameterValue required="false" variableLength="false">SwitchParameter</command:parameterValue>
<dev:type>
@ -7341,7 +7345,7 @@ PS C:\Data&gt; Get-NTFSSecurityDescriptor</dev:code>
</command:returnValues>
<maml:alertSet>
<maml:alert>
<maml:para>Creating a symbolic link on Windows requires the "Create symbolic links" user right, `SeCreateSymbolicLinkPrivilege`, which is granted to the Administrators group by default. Without that right, Windows rejects the operation with error 1314, "A required privilege is not held by the client", so run the cmdlet from an elevated session or grant the right to the account. On a computer that runs in Windows Developer Mode, Windows also allows accounts without that right to create symbolic links.</maml:para>
<maml:para>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.</maml:para>
<maml:para>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.</maml:para>
<maml:para>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.</maml:para>
</maml:alert>
@ -8423,7 +8427,7 @@ PS C:\Data&gt; Get-NTFSSecurityDescriptor</dev:code>
<maml:alertSet>
<maml:alert>
<maml:para>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.</maml:para>
<maml:para>If the ACL of an item cannot be written because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. Changing the owner of an item requires the Take Ownership and Restore privileges, so this fallback only succeeds in an elevated session of an account that holds them.</maml:para>
<maml:para>If the ACL of an item cannot be written because access is denied, the cmdlet tries once more after making the current account the owner of the item, and restores the previous owner afterwards. That fallback only succeeds when the account can take ownership of the item, through the Take Ownership right on the item or the Take Ownership privilege, and can set the previous owner back, which needs the Restore privilege unless that owner is the account itself or one of its groups. When the owner changes, Windows removes the entries for OWNER RIGHTS of the item.</maml:para>
<maml:para>Removing rights from an entry that does not exist is not an error; the cmdlet leaves the ACL unchanged.</maml:para>
<maml:para>In the `Path` parameter sets, the cmdlet writes only the DACL of the item and leaves its owner, its group, and its SACL as they are. Before 5.0.0, it could also write the owner back, which failed with error 1307, "This security ID may not be assigned as the owner of this object", when the account may not assign that owner, such as on some file servers.</maml:para>
<maml:para>An entry with a generic right, such as `GenericAll`, can be removed, for example by piping it from `Get-NTFSAccess`. Windows keeps generic rights in the inherit-only entries of folders. Before 5.0.0, the cmdlet failed for such an entry with the error "The value '269484032' is not valid for this usage of the type FileSystemRights".</maml:para>
@ -9884,8 +9888,8 @@ PS C:\&gt; Set-NTFSSecurityDescriptor -SecurityDescriptor $sd</dev:code>
<maml:description>
<maml:para>The `Set-NTFSSecurityDescriptor` cmdlet writes a `Security2.FileSystemSecurity2` object to the file system. It is the final step of the security descriptor workflow: `Get-NTFSSecurityDescriptor` reads a descriptor into memory, cmdlets such as `Add-NTFSAccess`, `Remove-NTFSAccess`, `Set-NTFSOwner`, and `Disable-NTFSAccessInheritance` change that copy through their `-SecurityDescriptor` parameter, and this cmdlet applies all of those changes in a single write.</maml:para>
<maml:para>Each descriptor remembers the item it was read from, and the cmdlet writes it back to exactly that item. There is no parameter that redirects the write to a different path. The cmdlet writes only the sections of the descriptor that changed since it was read or last written, such as the DACL after `Add-NTFSAccess`, and leaves the other sections of the item as they are, so a descriptor that you did not change writes nothing. With `-Verbose`, the cmdlet names the sections that it writes, or says that it writes nothing. Before 5.0.0, the cmdlet wrote every section that it had read, also an unchanged owner, which failed with error 1307, "This security ID may not be assigned as the owner of this object", when the account may not assign that owner, such as on some file servers.</maml:para>
<maml:para>The cmdlet produces no output unless you use `-PassThru`, which reads the item again after the write and returns a new `FileSystemSecurity2` object that reflects what is now stored on disk. Descriptors can be passed as an array or through the pipeline, and each one is processed on its own.</maml:para>
<maml:para>When the write fails because access is denied, the cmdlet takes ownership of the item with the account of the current session, writes the descriptor, and restores the previous owner, also when that write fails. A descriptor that sets a new owner keeps it; before 5.0.0, the cmdlet set the previous owner back over it. If the write fails as well, the cmdlet writes a non-terminating error and continues with the next descriptor. Windows checks each section separately: changing the access control list requires the Change Permissions right on the item, changing the owner requires the Take Ownership right or the Take Ownership privilege, assigning ownership to another account requires the Restore privilege, and writing audit entries requires the Security privilege.</maml:para>
<maml:para>The cmdlet produces no output unless you use `-PassThru`, which reads the item again after the write and returns a new `FileSystemSecurity2` object that reflects what is now stored on disk. If that read fails, for example because the written descriptor denies the account the right to read it, the cmdlet writes a non-terminating `ReadSecurityError`; the descriptor is written all the same. Descriptors can be passed as an array or through the pipeline, and each one is processed on its own.</maml:para>
<maml:para>When the write fails because access is denied, the cmdlet takes ownership of the item with the account of the current session, writes the descriptor, and restores the previous owner, also when that write fails. A descriptor that sets a new owner keeps it; before 5.0.0, the cmdlet set the previous owner back over it. If setting the previous owner back fails, the cmdlet writes a non-terminating `RestoreOwnerError`, and the account of the session stays the owner of the item. If the write fails as well, the cmdlet writes a non-terminating error and continues with the next descriptor. Windows checks each section separately: changing the access control list requires the Change Permissions right on the item, changing the owner requires the Take Ownership right or the Take Ownership privilege, assigning ownership to another account requires the Restore privilege, and writing audit entries requires the Security privilege. Before 5.0.0, `-PassThru` returned nothing for a descriptor that the cmdlet wrote as the owner, and a failed read for `-PassThru` started another attempt of the write and ended in a `WriteSdError`, although the write had succeeded.</maml:para>
</maml:description>
<command:syntax>
<command:syntaxItem>
@ -10136,7 +10140,7 @@ PS C:\&gt; Set-NTFSSecurityDescriptor -SecurityDescriptor $sd</dev:code>
<maml:alertSet>
<maml:alert>
<maml:para>The cmdlet resolves paths through the AlphaFS library, which is not bound by the 260-character `MAX_PATH` limit of the Windows PowerShell file system provider. Use `Test-Path2` instead of `Test-Path` when a path can be longer than that limit.</maml:para>
<maml:para>A path that does not exist is not an error condition. The cmdlet writes `$false` and continues with the next path.</maml:para>
<maml:para>A path that does not exist is not an error condition. The cmdlet writes `$false` and continues with the next path. This also applies to a path with a character that Windows doesn't allow in names, such as `|` or `&lt;`; Windows PowerShell rejects such a path, and the cmdlet writes the reason as a debug message. Before 5.0.0, such a path stopped the cmdlet with the terminating error "Illegal characters in path" in Windows PowerShell.</maml:para>
</maml:alert>
</maml:alertSet>
<command:examples>

18
Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.GetFileSystemAccessRules.cs

@ -21,21 +21,29 @@ namespace Security2
if (getInheritedFrom)
{
inheritedFrom = Win32.GetInheritedFrom(sd.Item, sd.SecurityDescriptor);
inheritedFrom = Win32.GetInheritedFrom(sd.Item, sd.SecurityDescriptor, false);
}
// All entries, so that each entry gets the source at its index in the DACL; before 5.0.0-rc6, the entries of
// -ExcludeExplicit got the sources of other entries. The filter follows.
var aceCounter = 0;
var acl = !sd.IsFile ?
((DirectorySecurity)sd.SecurityDescriptor).GetAccessRules(includeExplicit, includeInherited, typeof(SecurityIdentifier)) :
((FileSecurity)sd.SecurityDescriptor).GetAccessRules(includeExplicit, includeInherited, typeof(SecurityIdentifier));
((DirectorySecurity)sd.SecurityDescriptor).GetAccessRules(true, true, typeof(SecurityIdentifier)) :
((FileSecurity)sd.SecurityDescriptor).GetAccessRules(true, true, typeof(SecurityIdentifier));
foreach (FileSystemAccessRule ace in acl)
{
var source = getInheritedFrom && aceCounter < inheritedFrom.Count ? inheritedFrom[aceCounter] : null;
aceCounter++;
if (ace.IsInherited ? !includeInherited : !includeExplicit)
{
continue;
}
var ace2 = new FileSystemAccessRule2(ace) { FullName = sd.Item.FullName, InheritanceEnabled = !sd.SecurityDescriptor.AreAccessRulesProtected };
if (getInheritedFrom && inheritedFrom.Count > 0)
{
ace2.inheritedFrom = string.IsNullOrEmpty(inheritedFrom[aceCounter]) ? "" : inheritedFrom[aceCounter].Substring(0, inheritedFrom[aceCounter].Length - 1);
aceCounter++;
ace2.inheritedFrom = string.IsNullOrEmpty(source) ? "" : source.Substring(0, source.Length - 1);
}
aceList.Add(ace2);

6
Security2/FileSystem/FileSystemAccessRule2 Class/FileSystemAccessRule2.cs

@ -48,10 +48,12 @@ namespace Security2
return new FileSystemAccessRule2(ace);
}
//REQUIRED BECAUSE OF CONVERSION OPERATORS
// Like the entries of .NET, two objects are equal when they wrap the same entry; an entry of .NET is never equal,
// so that equality is the same in both directions. A cast instead of "as" threw an InvalidCastException.
public override bool Equals(object obj)
{
return fileSystemAccessRule == (FileSystemAccessRule)obj;
var other = obj as FileSystemAccessRule2;
return other != null && ReferenceEquals(fileSystemAccessRule, other.fileSystemAccessRule);
}
public override int GetHashCode()
{

20
Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.GetFileSystemAuditRules.cs

@ -21,21 +21,29 @@ namespace Security2
if (getInheritedFrom)
{
inheritedFrom = Win32.GetInheritedFrom(sd.Item, sd.SecurityDescriptor);
inheritedFrom = Win32.GetInheritedFrom(sd.Item, sd.SecurityDescriptor, true);
}
// All entries, so that each entry gets the source at its index in the SACL; before 5.0.0-rc6, the entries of
// -ExcludeExplicit got the sources of other entries. The filter follows.
var aceCounter = 0;
var acl = !sd.IsFile ?
((DirectorySecurity)sd.SecurityDescriptor).GetAuditRules(includeExplicit, includeInherited, typeof(SecurityIdentifier)) :
((FileSecurity)sd.SecurityDescriptor).GetAuditRules(includeExplicit, includeInherited, typeof(SecurityIdentifier));
((DirectorySecurity)sd.SecurityDescriptor).GetAuditRules(true, true, typeof(SecurityIdentifier)) :
((FileSecurity)sd.SecurityDescriptor).GetAuditRules(true, true, typeof(SecurityIdentifier));
foreach (FileSystemAuditRule ace in acl)
{
var source = getInheritedFrom && aceCounter < inheritedFrom.Count ? inheritedFrom[aceCounter] : null;
aceCounter++;
if (ace.IsInherited ? !includeInherited : !includeExplicit)
{
continue;
}
var ace2 = new FileSystemAuditRule2(ace) { FullName = sd.Item.FullName, InheritanceEnabled = !sd.SecurityDescriptor.AreAuditRulesProtected };
if (getInheritedFrom)
if (getInheritedFrom && inheritedFrom.Count > 0)
{
ace2.inheritedFrom = string.IsNullOrEmpty(inheritedFrom[aceCounter]) ? "" : inheritedFrom[aceCounter].Substring(0, inheritedFrom[aceCounter].Length - 1);
aceCounter++;
ace2.inheritedFrom = string.IsNullOrEmpty(source) ? "" : source.Substring(0, source.Length - 1);
}
aceList.Add(ace2);

6
Security2/FileSystem/FileSystemAuditRule2 Class/FileSystemAuditRule2.cs

@ -48,10 +48,12 @@ namespace Security2
{
return new FileSystemAuditRule2(ace);
}
//REQUIRED BECAUSE OF CONVERSION OPERATORS
// Like the entries of .NET, two objects are equal when they wrap the same entry; an entry of .NET is never equal,
// so that equality is the same in both directions. A cast instead of "as" threw an InvalidCastException.
public override bool Equals(object obj)
{
return this.fileSystemAuditRule == (FileSystemAuditRule)obj;
var other = obj as FileSystemAuditRule2;
return other != null && ReferenceEquals(fileSystemAuditRule, other.fileSystemAuditRule);
}
public override int GetHashCode()
{

17
Security2/FileSystem/FileSystemSecurity2.cs

@ -7,8 +7,6 @@ namespace Security2
{
public class FileSystemSecurity2
{
protected FileSecurity fileSecurityDescriptor;
protected DirectorySecurity directorySecurityDescriptor;
protected FileSystemInfo item;
protected FileSystemSecurity sd;
protected AccessControlSections sections;
@ -236,9 +234,11 @@ namespace Security2
}
#region Conversion
// The descriptor is in sd. Before 5.0.0-rc6, these conversions returned fields that were never set, so they
// gave null, and the hash code threw a NullReferenceException.
public static implicit operator FileSecurity(FileSystemSecurity2 fs2)
{
return fs2.fileSecurityDescriptor;
return fs2.sd as FileSecurity;
}
public static implicit operator FileSystemSecurity2(FileSecurity fs)
{
@ -247,21 +247,24 @@ namespace Security2
public static implicit operator DirectorySecurity(FileSystemSecurity2 fs2)
{
return fs2.directorySecurityDescriptor;
return fs2.sd as DirectorySecurity;
}
public static implicit operator FileSystemSecurity2(DirectorySecurity fs)
{
return new FileSystemSecurity2(new DirectoryInfo(""));
}
//REQUIRED BECAUSE OF CONVERSION OPERATORS
// Like the descriptors of .NET, two objects are equal when they wrap the same descriptor; a descriptor of .NET
// is never equal, so that equality is the same in both directions. A cast instead of "as" threw an
// InvalidCastException.
public override bool Equals(object obj)
{
return this.fileSecurityDescriptor == (FileSecurity)obj;
var other = obj as FileSystemSecurity2;
return other != null && ReferenceEquals(sd, other.sd);
}
public override int GetHashCode()
{
return fileSecurityDescriptor.GetHashCode();
return sd != null ? sd.GetHashCode() : 0;
}
#endregion

4
Security2/FileSystem/SimpleFileSystemAccessRule.cs

@ -42,6 +42,10 @@ namespace Security2
if ((accessRights & FileSystemRights2.Read) == FileSystemRights2.Read)
{ result |= SimpleFileSystemAccessRights.Read; }
// An entry with ReadData alone, which other tools than .NET create, was None before 5.0.0-rc6.
if ((accessRights & FileSystemRights2.ReadData) == FileSystemRights2.ReadData)
{ result |= SimpleFileSystemAccessRights.Read; }
if ((accessRights & FileSystemRights2.CreateFiles) == FileSystemRights2.CreateFiles)
{ result |= SimpleFileSystemAccessRights.Write; }

41
Security2/Win32/Lib.cs

@ -19,44 +19,21 @@ namespace Security2
IntPtr pErrorSecObj = IntPtr.Zero;
#region GetInheritedFrom
public static List<string> GetInheritedFrom(FileSystemInfo item, ObjectSecurity sd)
// Returns the source of each entry of the DACL, or of the SACL for audit entries, in the order of the ACL. Before
// 5.0.0-rc6, a descriptor with a SACL returned the sources of the audit entries also for the access entries.
public static List<string> GetInheritedFrom(FileSystemInfo item, ObjectSecurity sd, bool auditEntries)
{
var inheritedFrom = new List<string>();
var sdBytes = sd.GetSecurityDescriptorBinaryForm();
byte[] aclBytes = null;
var rawSd = new RawSecurityDescriptor(sdBytes, 0);
var acl = auditEntries ? rawSd.SystemAcl : rawSd.DiscretionaryAcl;
var aceCount = 0;
if (rawSd.SystemAcl != null)
{
aceCount = rawSd.SystemAcl.Count;
aclBytes = new byte[rawSd.SystemAcl.BinaryLength];
rawSd.SystemAcl.GetBinaryForm(aclBytes, 0);
try
{
inheritedFrom = GetInheritedFrom(item.FullName,
aclBytes,
aceCount,
item is DirectoryInfo ? true : false,
SECURITY_INFORMATION.SACL_SECURITY_INFORMATION);
}
catch
{
inheritedFrom = new List<string>();
for (int i = 0; i < aceCount; i++)
{
inheritedFrom.Add("unknown parent");
}
}
}
else if (rawSd.DiscretionaryAcl != null)
if (acl != null)
{
aceCount = rawSd.DiscretionaryAcl.Count;
aclBytes = new byte[rawSd.DiscretionaryAcl.BinaryLength];
rawSd.DiscretionaryAcl.GetBinaryForm(aclBytes, 0);
var aceCount = acl.Count;
var aclBytes = new byte[acl.BinaryLength];
acl.GetBinaryForm(aclBytes, 0);
try
{
@ -64,7 +41,7 @@ namespace Security2
aclBytes,
aceCount,
item is DirectoryInfo ? true : false,
SECURITY_INFORMATION.DACL_SECURITY_INFORMATION);
auditEntries ? SECURITY_INFORMATION.SACL_SECURITY_INFORMATION : SECURITY_INFORMATION.DACL_SECURITY_INFORMATION);
}
catch
{

345
Tests/Access.Tests.ps1

@ -152,13 +152,15 @@ Describe 'Get-NTFSEffectiveAccess' {
}
Describe 'Get-NTFSOrphanedAccess' {
# Before 5.0.0, a path with braces stopped the cmdlet with a FormatException (#3).
# Before 5.0.0, a path with braces stopped the cmdlet with a FormatException (#3), which the verbose message raised.
It 'Should read a folder whose name contains braces' {
$braces = Join-Path -Path $sandbox -ChildPath ('{{Braces}}-{0}' -f [guid]::NewGuid().ToString('N').Substring(0, 8))
Assert-TestSandboxPath -Sandbox $sandbox -Path $braces
[IO.Directory]::CreateDirectory($braces) | Out-Null
{ Get-NTFSOrphanedAccess -Path $braces -ErrorAction Stop } | Should -Not -Throw
$messages = @(Get-NTFSOrphanedAccess -Path $braces -Verbose -ErrorAction Stop 4>&1)
$messages.Message | Should -Contain "Item $braces knows about 0 orphaned SIDs in its ACL"
}
BeforeAll {
@ -228,6 +230,167 @@ Describe 'Get-NTFSSimpleAccess' {
$text | Should -Match 'Account\s+Access Rights\s+Type'
}
Context 'When it reduces the rights of an entry' {
BeforeAll {
# One explicit entry per case, for a SID of its own and with exactly these rights. .NET would add
# Synchronize to an allow entry, so the DACL is set in SDDL.
$rightsFolder = New-TestSandboxItem -Sandbox $sandbox -Name 'SimpleRights' -Directory
$rightsCases = @(
@{ Name = 'ReadData'; Mask = 0x1; Expected = 'Read' }
@{ Name = 'ReadAttributes'; Mask = 0x80; Expected = 'Read' }
@{ Name = 'Traverse'; Mask = 0x20; Expected = 'Read' }
@{ Name = 'ReadPermissions'; Mask = 0x20000; Expected = 'Read' }
@{ Name = 'CreateFiles'; Mask = 0x2; Expected = 'Write' }
@{ Name = 'AppendData'; Mask = 0x4; Expected = 'Write' }
@{ Name = 'WriteAttributes'; Mask = 0x100; Expected = 'Write' }
@{ Name = 'ChangePermissions'; Mask = 0x40000; Expected = 'Write' }
@{ Name = 'TakeOwnership'; Mask = 0x80000; Expected = 'Write' }
@{ Name = 'Delete'; Mask = 0x10000; Expected = 'Delete' }
@{ Name = 'DeleteSubdirectoriesAndFiles'; Mask = 0x40; Expected = 'Delete' }
@{ Name = 'Modify'; Mask = 0x1301BF; Expected = 'Read, Write, Delete' }
@{ Name = 'FullControl'; Mask = 0x1F01FF; Expected = 'Read, Write, Delete' }
)
$rightsSid = @{}
$entries = for ($i = 0; $i -lt $rightsCases.Count; $i++) {
$sid = 'S-1-5-21-1-2-3-{0}' -f (3001 + $i)
$rightsSid[$rightsCases[$i].Name] = $sid
'(A;;0x{0:X};;;{1})' -f $rightsCases[$i].Mask, $sid
}
$acl = Get-Acl -LiteralPath $rightsFolder
$acl.SetSecurityDescriptorSddlForm(('D:{0}' -f ($entries -join '')), 'Access')
Set-Acl -LiteralPath $rightsFolder -AclObject $acl
$rightsResult = @(Get-NTFSSimpleAccess -Path $rightsFolder -ExcludeInherited -IncludeRootFolder:$false -ErrorAction Stop)
}
# Before 5.0.0-rc6, an entry with ReadData alone, which .NET never creates but other tools do, became None.
It 'Should reduce <Name> to <Expected>' -ForEach @(
@{ Name = 'ReadData'; Expected = 'Read' }
@{ Name = 'ReadAttributes'; Expected = 'Read' }
@{ Name = 'Traverse'; Expected = 'Read' }
@{ Name = 'ReadPermissions'; Expected = 'Read' }
@{ Name = 'CreateFiles'; Expected = 'Write' }
@{ Name = 'AppendData'; Expected = 'Write' }
@{ Name = 'WriteAttributes'; Expected = 'Write' }
@{ Name = 'ChangePermissions'; Expected = 'Write' }
@{ Name = 'TakeOwnership'; Expected = 'Write' }
@{ Name = 'Delete'; Expected = 'Delete' }
@{ Name = 'DeleteSubdirectoriesAndFiles'; Expected = 'Delete' }
@{ Name = 'Modify'; Expected = 'Read, Write, Delete' }
@{ Name = 'FullControl'; Expected = 'Read, Write, Delete' }
) {
$entry = @($rightsResult | Where-Object -FilterScript { $_.Identity.Sid -eq $rightsSid[$Name] })
$entry | Should -HaveCount 1
$entry[0].AccessRights.ToString() | Should -Be $Expected
}
}
Context 'When it compares folders with their parent folder' {
BeforeAll {
# The parent grants Everyone ReadData to its subfolders and the user Full Control, so that a basic user
# can create the items; the child adds an entry of its own. The child is created after the DACL of the
# parent, so that it inherits only these entries.
$parent = New-TestSandboxItem -Sandbox $sandbox -Name 'SimpleParent' -Directory
$user = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
$acl = Get-Acl -LiteralPath $parent
$acl.SetSecurityDescriptorSddlForm("D:P(A;OICI;0x120089;;;WD)(A;OICI;FA;;;BA)(A;OICI;FA;;;SY)(A;OICI;FA;;;$user)", 'Access')
Set-Acl -LiteralPath $parent -AclObject $acl
$child = Join-Path -Path $parent -ChildPath 'Child'
Assert-TestSandboxPath -Sandbox $sandbox -Path $child
New-Item -ItemType Directory -Path $child | Out-Null
$acl = Get-Acl -LiteralPath $child
$acl.AddAccessRule((New-Object -TypeName 'System.Security.AccessControl.FileSystemAccessRule' -ArgumentList (
(New-Object -TypeName 'System.Security.Principal.SecurityIdentifier' -ArgumentList 'S-1-5-21-1-2-3-3101'),
[System.Security.AccessControl.FileSystemRights]::Modify, [System.Security.AccessControl.AccessControlType]::Allow
)))
Set-Acl -LiteralPath $child -AclObject $acl
$childFile = Join-Path -Path $child -ChildPath 'File.txt'
Assert-TestSandboxPath -Sandbox $sandbox -Path $childFile
Set-Content -LiteralPath $childFile -Value 'File' -ErrorAction Stop
}
It 'Should report all entries of the first folder and of the following folder only what the parent does not cover' {
$result = @(Get-NTFSSimpleAccess -Path $parent, $child -IncludeRootFolder:$false -ErrorAction Stop)
@($result | Where-Object -Property FullName -EQ -Value $parent) | Should -HaveCount 4
$childEntries = @($result | Where-Object -Property FullName -EQ -Value $child)
$childEntries | Should -HaveCount 1
$childEntries[0].Identity.Sid | Should -Be 'S-1-5-21-1-2-3-3101'
}
It 'Should compare the folders from the pipeline in the same way' {
$result = @(Get-Item2 -Path $parent, $child | Get-NTFSSimpleAccess -IncludeRootFolder:$false -ErrorAction Stop)
@($result | Where-Object -Property FullName -EQ -Value $child).Identity.Sid | Should -Be 'S-1-5-21-1-2-3-3101'
}
It 'Should report the parent folder of the first path first by default' {
$result = @(Get-NTFSSimpleAccess -Path $child -ErrorAction Stop)
$result[0].FullName | Should -Be $parent
@($result | Where-Object -Property FullName -EQ -Value $child).Identity.Sid | Should -Be 'S-1-5-21-1-2-3-3101'
}
# Before 5.0.0-rc6, a relative path with a single folder name had no parent folder in the result.
It 'Should report the parent folder of a relative path first by default' {
Push-Location -LiteralPath $parent
try {
$result = @(Get-NTFSSimpleAccess -Path 'Child' -ErrorAction Stop)
}
finally {
Pop-Location
}
$result[0].FullName | Should -Be $parent
@($result | Where-Object -Property FullName -EQ -Value $child).Identity.Sid | Should -Be 'S-1-5-21-1-2-3-3101'
}
It 'Should use the current location without -Path' {
Push-Location -LiteralPath $child
try {
$result = @(Get-NTFSSimpleAccess -IncludeRootFolder:$false -ErrorAction Stop)
}
finally {
Pop-Location
}
$result | ForEach-Object -Process { $_.FullName | Should -Be $child }
}
It 'Should return only the inherited entries with -ExcludeExplicit' {
$result = @(Get-NTFSSimpleAccess -Path $child -ExcludeExplicit -IncludeRootFolder:$false -ErrorAction Stop)
$result | Should -HaveCount 4
$result.Identity.Sid | Should -Not -Contain 'S-1-5-21-1-2-3-3101'
}
It 'Should skip a file without an error' {
$result = @(Get-NTFSSimpleAccess -Path $childFile -IncludeRootFolder:$false -ErrorVariable simpleErrors -ErrorAction SilentlyContinue)
$simpleErrors | Should -BeNullOrEmpty
$result | Should -BeNullOrEmpty
}
It 'Should report the security descriptor of a file' {
$result = @(Get-NTFSSecurityDescriptor -Path $childFile | Get-NTFSSimpleAccess -ErrorAction Stop)
$result | Should -Not -BeNullOrEmpty
$result | ForEach-Object -Process { $_.FullName | Should -Be $childFile }
}
It 'Should write an error for a path that does not exist and continue with the next path' {
$missing = Join-Path -Path $parent -ChildPath 'Missing'
$result = @(Get-NTFSSimpleAccess -Path $missing, $child -IncludeRootFolder:$false -ErrorVariable simpleErrors -ErrorAction SilentlyContinue)
$simpleErrors | Should -HaveCount 1
$simpleErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadError,*'
$result | Should -Not -BeNullOrEmpty
$result | ForEach-Object -Process { $_.FullName | Should -Be $child }
}
}
}
Describe 'Remove-NTFSAccess' {
@ -388,6 +551,26 @@ Describe 'Remove-NTFSAccess' {
$rule | Should -Not -BeNullOrEmpty
$rule.FileSystemRights.HasFlag([System.Security.AccessControl.FileSystemRights]::ReadData) | Should -BeFalse
}
It 'Should keep an entry that does not match exactly, given the path' {
Add-NTFSAccess -Path $removeFolder -Account 'Everyone' -AccessRights Modify
Remove-NTFSAccess -Path $removeFolder -Account 'Everyone' -AccessRights ReadData -RemoveSpecific -ErrorAction Stop
$rules = @((Get-Acl -LiteralPath $removeFolder).GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) |
Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-1-0' })
$rules | Should -HaveCount 1
$rules[0].FileSystemRights.HasFlag([System.Security.AccessControl.FileSystemRights]::Modify) | Should -BeTrue
}
It 'Should remove an entry that matches exactly, given the path' {
Add-NTFSAccess -Path $removeFolder -Account 'Everyone' -AccessRights Modify
Remove-NTFSAccess -Path $removeFolder -Account 'Everyone' -AccessRights Modify -RemoveSpecific -ErrorAction Stop
(Get-Acl -LiteralPath $removeFolder).GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) |
Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-1-0' } | Should -BeNullOrEmpty
}
}
}
@ -452,6 +635,65 @@ Describe 'Add-NTFSAccess' {
@($acl.GetAccessRules($false, $true, $sidType)) | Should -HaveCount $inheritedCount
}
}
# The page: -PassThru writes all entries, explicit and inherited, of every item that the cmdlet changed.
Context 'With -PassThru' {
It 'Should write all entries of the item after the change' {
$file = New-TestSandboxItem -Sandbox $sandbox -Name 'AddPassThru'
Assert-TestSandboxPath -Sandbox $sandbox -Path $file
$result = @(Add-NTFSAccess -Path $file -Account 'S-1-1-0' -AccessRights ReadData -PassThru)
$result | ForEach-Object -Process { $_ | Should -BeOfType [Security2.FileSystemAccessRule2] }
$result | Should -HaveCount @(Get-NTFSAccess -Path $file).Count
@($result | Where-Object -FilterScript { $_.IsInherited }) | Should -Not -BeNullOrEmpty
$added = @($result | Where-Object -FilterScript { $_.Account.Sid -eq 'S-1-1-0' -and -not $_.IsInherited })
$added | Should -HaveCount 1
$added[0].AccessRights.HasFlag([Security2.FileSystemRights2]::ReadData) | Should -BeTrue
}
It 'Should write all entries of a security descriptor after the change and leave the item unchanged' {
$file = New-TestSandboxItem -Sandbox $sandbox -Name 'AddPassThruDescriptor'
Assert-TestSandboxPath -Sandbox $sandbox -Path $file
$sd = Get-NTFSSecurityDescriptor -Path $file
$result = @(Add-NTFSAccess -SecurityDescriptor $sd -Account 'S-1-1-0' -AccessRights ReadData -PassThru)
$result | Should -HaveCount @($sd.SecurityDescriptor.GetAccessRules($true, $true, $sidType)).Count
@($result | Where-Object -FilterScript { $_.Account.Sid -eq 'S-1-1-0' -and -not $_.IsInherited }) | Should -HaveCount 1
@((Get-Acl -LiteralPath $file).GetAccessRules($true, $false, $sidType) |
Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-1-0' }) | Should -BeNullOrEmpty
}
}
Context 'With -InheritanceFlags and -PropagationFlags' {
It 'Should add an entry with the flags to a folder' {
$folder = New-TestSandboxItem -Sandbox $sandbox -Name 'AddFlags' -Directory
Assert-TestSandboxPath -Sandbox $sandbox -Path $folder
Add-NTFSAccess -Path $folder -Account 'S-1-5-32-546' -AccessRights ReadData -InheritanceFlags ContainerInherit -PropagationFlags InheritOnly
$rules = @((Get-Acl -LiteralPath $folder).GetAccessRules($true, $false, $sidType) |
Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-5-32-546' })
$rules | Should -HaveCount 1
$rules[0].InheritanceFlags | Should -Be ([System.Security.AccessControl.InheritanceFlags]::ContainerInherit)
$rules[0].PropagationFlags | Should -Be ([System.Security.AccessControl.PropagationFlags]::InheritOnly)
}
# The page: inheritance and propagation flags are ignored on files.
It 'Should add an entry without flags to a file' {
$file = New-TestSandboxItem -Sandbox $sandbox -Name 'AddFlagsFile'
Assert-TestSandboxPath -Sandbox $sandbox -Path $file
Add-NTFSAccess -Path $file -Account 'S-1-5-32-546' -AccessRights ReadData -InheritanceFlags 'ContainerInherit, ObjectInherit' -PropagationFlags InheritOnly
$rules = @((Get-Acl -LiteralPath $file).GetAccessRules($true, $false, $sidType) |
Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-5-32-546' })
$rules | Should -HaveCount 1
$rules[0].InheritanceFlags | Should -Be ([System.Security.AccessControl.InheritanceFlags]::None)
$rules[0].PropagationFlags | Should -Be ([System.Security.AccessControl.PropagationFlags]::None)
}
}
}
Describe 'Security descriptor parameter sets' {
@ -542,3 +784,102 @@ Describe 'Clear-NTFSAccess' {
}
}
}
# Before 5.0.0-rc6, comparing an entry with anything threw an InvalidCastException, so -eq, -contains, and in PowerShell 7
# also Select-Object -Unique and Compare-Object failed for the output of Get-NTFSAccess. Like the entries of .NET, two
# objects are equal when they wrap the same entry.
Describe 'Comparing access entries' {
BeforeAll {
$compareFile = New-TestSandboxItem -Sandbox $sandbox -Name 'Compare'
Add-NTFSAccess -Path $compareFile -Account 'S-1-1-0' -AccessRights ReadData
$entries = @(Get-NTFSAccess -Path $compareFile)
}
It 'Should find an entry equal to itself and not to another entry' {
$entries.Count | Should -BeGreaterThan 1
$entries[0] -eq $entries[0] | Should -BeTrue
$entries[0] -eq $entries[1] | Should -BeFalse
$entries -contains $entries[1] | Should -BeTrue
$entries[0].Equals('S-1-1-0') | Should -BeFalse
$entries[0].Equals($null) | Should -BeFalse
}
It 'Should work with Select-Object -Unique and Compare-Object' {
@(@($entries) + $entries[0] | Select-Object -Unique) | Should -HaveCount $entries.Count
Compare-Object -ReferenceObject $entries -DifferenceObject $entries | Should -BeNullOrEmpty
}
# The entry of .NET doesn't know the object of the module, so equality in one direction only would make a hashtable
# lookup depend on which of the two is the key.
It 'Should be equal only to an entry of the module, in both directions' {
$raw = [System.Security.AccessControl.FileSystemAccessRule] $entries[0]
$entries[0].Equals($raw) | Should -BeFalse
$raw.Equals($entries[0]) | Should -BeFalse
}
}
# Before 5.0.0-rc6, -ExcludeExplicit gave each inherited entry the source of another entry, and Get-NTFSAccess stopped
# with an ArgumentOutOfRangeException for a security descriptor with audit entries, because it took the sources of the
# audit entries for the access entries.
Describe 'InheritedFrom of access entries' {
BeforeAll {
$inheritedFromFile = New-TestSandboxItem -Sandbox $sandbox -Name 'InheritedFrom'
Add-NTFSAccess -Path $inheritedFromFile -Account 'S-1-1-0' -AccessRights ReadData
$expectedSource = @{}
foreach ($entry in Get-NTFSAccess -Path $inheritedFromFile) {
if ($entry.IsInherited) {
$expectedSource["$($entry.Account.Sid)"] = $entry.InheritedFrom
}
}
}
It 'Should name the folder that each inherited entry comes from' {
$expectedSource.Count | Should -BeGreaterThan 0
$expectedSource.Values | ForEach-Object -Process { $_ | Should -Not -BeNullOrEmpty }
}
It 'Should name the same folders with -ExcludeExplicit' {
$result = @(Get-NTFSAccess -Path $inheritedFromFile -ExcludeExplicit)
$result | Should -HaveCount $expectedSource.Count
foreach ($entry in $result) {
$entry.InheritedFrom | Should -Be $expectedSource["$($entry.Account.Sid)"]
}
}
# Two explicit entries before the inherited ones; before 5.0.0-rc6, -ExcludeExplicit shifted the sources by two.
It 'Should name the folder of an inheritable entry, also with -ExcludeExplicit' {
$parent = New-TestSandboxItem -Sandbox $sandbox -Name 'InheritedFromParent' -Directory
Add-NTFSAccess -Path $parent -Account 'S-1-5-32-546' -AccessRights ReadData -AppliesTo ThisFolderSubfoldersAndFiles
$child = Join-Path -Path $parent -ChildPath 'Child.txt'
Assert-TestSandboxPath -Sandbox $sandbox -Path $child
Set-Content -LiteralPath $child -Value 'Child'
Add-NTFSAccess -Path $child -Account 'S-1-1-0' -AccessRights ReadData
Add-NTFSAccess -Path $child -Account 'S-1-5-32-545' -AccessRights ReadData
$all = @(Get-NTFSAccess -Path $child | Where-Object -FilterScript { $_.IsInherited -and "$($_.Account.Sid)" -eq 'S-1-5-32-546' })
$inherited = @(Get-NTFSAccess -Path $child -ExcludeExplicit | Where-Object -FilterScript { "$($_.Account.Sid)" -eq 'S-1-5-32-546' })
$all | Should -HaveCount 1
$all[0].InheritedFrom | Should -Be $parent
$inherited | Should -HaveCount 1
$inherited[0].InheritedFrom | Should -Be $parent
}
It 'Should read a security descriptor with audit entries and name the same folders' -Skip:(-not $holdsSecurityPrivilege) {
$file = New-TestSandboxItem -Sandbox $sandbox -Name 'InheritedFromAudit'
Add-NTFSAccess -Path $file -Account 'S-1-1-0' -AccessRights ReadData
Add-NTFSAudit -Path $file -Account 'S-1-1-0' -AccessRights ReadData -InheritanceFlags None -PropagationFlags None
$sd = Get-NTFSSecurityDescriptor -Path $file
@($sd.SecurityDescriptor.GetAuditRules($true, $true, $sidType)) | Should -Not -BeNullOrEmpty
$result = @(Get-NTFSAccess -SecurityDescriptor $sd -ErrorAction Stop)
$result | Should -HaveCount @(Get-NTFSAccess -Path $file).Count
foreach ($entry in @($result | Where-Object -FilterScript { $_.IsInherited })) {
$entry.InheritedFrom | Should -Be $expectedSource["$($entry.Account.Sid)"]
}
}
}

143
Tests/Audit.Tests.ps1

@ -183,24 +183,92 @@ Describe 'Add-NTFSAudit' {
Describe 'Get-NTFSOrphanedAudit' {
BeforeAll {
$orphanedFile = New-TestSandboxItem -Sandbox $sandbox -Name 'OrphanedAudit'
$missing = Join-Path -Path $sandbox -ChildPath 'MissingOrphanedAudit.txt'
}
# Before 5.0.0, the cmdlet wrote the entries of an item as one collection and ignored -Account.
It 'Should return one object per entry whose account cannot be resolved' -Skip:(-not $canReadAudit) {
foreach ($sid in 'S-1-5-21-1-2-3-1001', 'S-1-5-21-1-2-3-1002') {
Add-NTFSAudit -Path $orphanedFile -Account $sid -AccessRights ReadData -InheritanceFlags None -PropagationFlags None
Context 'With the Security privilege' {
BeforeAll {
# Two entries of accounts that don't exist and one of Everyone, which resolves. Each test reads them, so
# none depends on another one.
foreach ($sid in 'S-1-5-21-1-2-3-1001', 'S-1-5-21-1-2-3-1002', 'S-1-1-0') {
Add-NTFSAudit -Path $orphanedFile -Account $sid -AccessRights ReadData -InheritanceFlags None -PropagationFlags None -ErrorAction Stop
}
$orphanedFolder = New-TestSandboxItem -Sandbox $sandbox -Name 'OrphanedAuditFolder' -Directory
Add-NTFSAudit -Path $orphanedFolder -Account 'S-1-5-21-1-2-3-1003' -AccessRights Delete -ErrorAction Stop
$inheritingFile = Join-Path -Path $orphanedFolder -ChildPath 'File.txt'
Assert-TestSandboxPath -Sandbox $sandbox -Path $inheritingFile
Set-Content -LiteralPath $inheritingFile -Value 'File'
}
# Before 5.0.0, the cmdlet wrote the entries of an item as one collection and ignored -Account.
It 'Should return one object per entry whose account cannot be resolved' -Skip:(-not $canReadAudit) {
$result = @(Get-NTFSOrphanedAudit -Path $orphanedFile)
$result | Should -HaveCount 2
$result | ForEach-Object -Process { $_ | Should -BeOfType [Security2.FileSystemAuditRule2] }
$result.Account.Sid | Should -Not -Contain 'S-1-1-0'
}
It 'Should return only the entries of -Account' -Skip:(-not $canReadAudit) {
$result = @(Get-NTFSOrphanedAudit -Path $orphanedFile -Account 'S-1-5-21-1-2-3-1002')
$result | Should -HaveCount 1
$result[0].Account.Sid | Should -Be 'S-1-5-21-1-2-3-1002'
}
It 'Should read the entries of a security descriptor' -Skip:(-not $canReadAudit) {
$result = @(Get-NTFSSecurityDescriptor -Path $orphanedFile | Get-NTFSOrphanedAudit -ErrorAction Stop)
$result | Should -HaveCount 2
$result | ForEach-Object -Process { $_.FullName | Should -Be $orphanedFile }
}
It 'Should return an inherited entry, and nothing with -ExcludeInherited' -Skip:(-not $canReadAudit) {
$result = @(Get-NTFSOrphanedAudit -Path $inheritingFile -ErrorAction Stop)
$explicitResult = @(Get-NTFSOrphanedAudit -Path $inheritingFile -ExcludeInherited -ErrorAction Stop)
$result | Should -HaveCount 1
$result[0].Account.Sid | Should -Be 'S-1-5-21-1-2-3-1003'
$result[0].IsInherited | Should -BeTrue
$explicitResult | Should -BeNullOrEmpty
}
$result = @(Get-NTFSOrphanedAudit -Path $orphanedFile)
It 'Should report the number of orphaned entries of each item in a verbose message' -Skip:(-not $canReadAudit) {
$messages = @(Get-NTFSOrphanedAudit -Path $orphanedFile -Verbose 4>&1 | Where-Object -FilterScript {
$_ -is [System.Management.Automation.VerboseRecord] })
$result | Should -HaveCount 2
$result | ForEach-Object -Process { $_ | Should -BeOfType [Security2.FileSystemAuditRule2] }
$messages.Message | Should -Contain "Item $orphanedFile knows about 2 orphaned SIDs in its ACL"
}
}
It 'Should return only the entries of -Account' -Skip:(-not $canReadAudit) {
$result = @(Get-NTFSOrphanedAudit -Path $orphanedFile -Account 'S-1-5-21-1-2-3-1002')
It 'Should write an error for a path that does not exist and continue with the next path' {
$result = @(Get-NTFSOrphanedAudit -Path $missing, $orphanedFile -ErrorVariable orphanedErrors -ErrorAction SilentlyContinue)
$result | Should -HaveCount 1
$orphanedErrors | Should -HaveCount 1
$orphanedErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadError,*'
$orphanedErrors[0].TargetObject | Should -Be $missing
$result | ForEach-Object -Process { $_.FullName | Should -Be $orphanedFile }
}
It 'Should write an error for a security descriptor that was read without the audit entries' {
$sd = New-Object -TypeName 'Security2.FileSystemSecurity2' -ArgumentList (
(Get-Item2 -Path $orphanedFile), [System.Security.AccessControl.AccessControlSections]::Access
)
$result = @($sd | Get-NTFSOrphanedAudit -ErrorVariable orphanedErrors -ErrorAction SilentlyContinue)
$result | Should -BeNullOrEmpty
$orphanedErrors | Should -HaveCount 1
$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 {
$result = @(Get-NTFSOrphanedAudit -Path $orphanedFile -ErrorVariable orphanedErrors -ErrorAction SilentlyContinue)
$orphanedErrors | Should -BeNullOrEmpty
$result | Should -BeNullOrEmpty
}
}
@ -248,6 +316,25 @@ Describe 'Remove-NTFSAudit' {
Get-EveryoneAuditRule | Should -BeNullOrEmpty
}
It 'Should keep an audit entry that does not match exactly, given the path' {
Add-NTFSAudit -Path $removeFolder -Account 'Everyone' -AccessRights Modify
Remove-NTFSAudit -Path $removeFolder -Account 'Everyone' -AccessRights ReadData -RemoveSpecific -ErrorAction Stop
$entries = @(Get-NTFSAudit -Path $removeFolder -ExcludeInherited | Where-Object -FilterScript { $_.Account.Sid -eq 'S-1-1-0' })
$entries | Should -HaveCount 1
$entries[0].AccessRights.ToString() | Should -BeLike '*Modify*'
}
It 'Should remove an audit entry that matches exactly, given the path' {
Add-NTFSAudit -Path $removeFolder -Account 'Everyone' -AccessRights Modify
Remove-NTFSAudit -Path $removeFolder -Account 'Everyone' -AccessRights Modify -RemoveSpecific -ErrorAction Stop
Get-NTFSAudit -Path $removeFolder -ExcludeInherited | Where-Object -FilterScript { $_.Account.Sid -eq 'S-1-1-0' } |
Should -BeNullOrEmpty
}
}
Context 'With -PassThru' {
@ -347,6 +434,8 @@ Describe 'Clear-NTFSAudit' {
)
$audit.SecurityDescriptor.GetSecurityDescriptorSddlForm('Audit') | Should -BeNullOrEmpty
$inheritedCount = @((Get-Acl -LiteralPath $file).GetAccessRules($false, $true, $sidType)).Count
# Without inherited entries, the test couldn't see them copied as explicit ones (#110).
$inheritedCount | Should -BeGreaterThan 0
Clear-NTFSAudit -Path $file -ErrorVariable clearErrors -ErrorAction SilentlyContinue
@ -373,3 +462,37 @@ Describe 'Clear-NTFSAudit' {
}
}
}
# Before 5.0.0-rc6, comparing an audit entry with anything threw an InvalidCastException.
Describe 'Comparing audit entries' {
It 'Should find an entry equal to itself and not to a string' -Skip:(-not $canReadAudit) {
$file = New-TestSandboxItem -Sandbox $sandbox -Name 'Compare'
Add-NTFSAudit -Path $file -Account 'S-1-1-0' -AccessRights ReadData -InheritanceFlags None -PropagationFlags None
$entries = @(Get-NTFSAudit -Path $file -ExcludeInherited)
$entries | Should -HaveCount 1
$entries[0] -eq $entries[0] | Should -BeTrue
$entries -contains $entries[0] | Should -BeTrue
$entries[0].Equals('S-1-1-0') | Should -BeFalse
}
}
# Before 5.0.0-rc6, -ExcludeExplicit gave each inherited audit entry the source of another entry.
Describe 'InheritedFrom of audit entries' {
It 'Should name the folder that an inherited entry comes from, also with -ExcludeExplicit' -Skip:(-not $canReadAudit) {
$folder = New-TestSandboxItem -Sandbox $sandbox -Name 'InheritedFrom' -Directory
Add-NTFSAudit -Path $folder -Account 'S-1-1-0' -AccessRights ReadData -InheritanceFlags 'ContainerInherit, ObjectInherit' -PropagationFlags None
$file = Join-Path -Path $folder -ChildPath 'File.txt'
Assert-TestSandboxPath -Sandbox $sandbox -Path $file
Set-Content -LiteralPath $file -Value 'File'
Add-NTFSAudit -Path $file -Account 'S-1-5-32-546' -AccessRights Delete -InheritanceFlags None -PropagationFlags None
$all = @(Get-NTFSAudit -Path $file)
$inherited = @(Get-NTFSAudit -Path $file -ExcludeExplicit)
@($all | Where-Object -FilterScript { $_.IsInherited }) | Should -HaveCount 1
@($all | Where-Object -FilterScript { $_.IsInherited })[0].InheritedFrom | Should -Be $folder
$inherited | Should -HaveCount 1
$inherited[0].InheritedFrom | Should -Be $folder
}
}

2
Tests/Inheritance.Tests.ps1

@ -127,6 +127,8 @@ Describe 'Set-NTFSInheritance' {
$file = New-TestSandboxItem -Sandbox $sandbox -Name 'KeepAccess'
Assert-TestSandboxPath -Sandbox $sandbox -Path $file
$inheritedCount = @((Get-Acl -LiteralPath $file).Access | Where-Object -Property IsInherited).Count
# Without inherited entries, the test couldn't see them kept as explicit ones (#110).
$inheritedCount | Should -BeGreaterThan 0
Set-NTFSInheritance -Path $file -AccessInheritanceEnabled $false

266
Tests/ItemCmdlets.Tests.ps1

@ -160,6 +160,20 @@ Describe 'Copy-Item2, Move-Item2, and Remove-Item2 with several paths' {
$messages.Message | Should -Contain ("File '{0}' {1} to '{2}'" -f $first, $Verb, $target)
}
It '<Command> should name the destination of a folder in the verbose message' -ForEach @(
@{ Command = 'Copy-Item2'; Verb = 'copied' }
@{ Command = 'Move-Item2'; Verb = 'moved' }
) {
$sourceFolder = Join-Path -Path $folder -ChildPath 'VerboseFolder'
Assert-TestSandboxPath -Sandbox $sandbox -Path $sourceFolder
New-Item -ItemType Directory -Path $sourceFolder | Out-Null
$target = Join-Path -Path $destination -ChildPath 'VerboseFolder'
$messages = & $Command -Path $sourceFolder -Destination $destination -Verbose 4>&1
$messages.Message | Should -Contain ("Directory '{0}' {1} to '{2}'" -f $sourceFolder, $Verb, $target)
}
# With -PassThru, both cmdlets return the item at the destination, as their pages say.
It 'Copy-Item2 -PassThru should return the copy' {
$result = Copy-Item2 -Path $first -Destination $destination -PassThru $true
@ -185,6 +199,89 @@ Describe 'Copy-Item2, Move-Item2, and Remove-Item2 with several paths' {
$result.FullName | Should -Be (Join-Path -Path $destination -ChildPath 'First.txt')
}
It 'Move-Item2 -PassThru should return a folder at its new location' {
$sourceFolder = Join-Path -Path $folder -ChildPath 'MovedFolder'
Assert-TestSandboxPath -Sandbox $sandbox -Path $sourceFolder
New-Item -ItemType Directory -Path $sourceFolder | Out-Null
$result = Move-Item2 -Path $sourceFolder -Destination $destination -PassThru $true
$result | Should -BeOfType [Alphaleonis.Win32.Filesystem.DirectoryInfo]
$result.FullName | Should -Be (Join-Path -Path $destination -ChildPath 'MovedFolder')
$sourceFolder | Should -Not -Exist
}
# Before 5.0.0-rc6, the check for an existing destination looked for a file only. For a folder whose name existed
# in the destination, the cmdlets failed in the middle with a CopyError or a MoveError, and Copy-Item2 could copy
# a part of the folder.
It '<_> should write DestinationFileAlreadyExists for a folder that exists at the destination and change nothing' -ForEach @('Copy-Item2', 'Move-Item2') {
$sourceFolder = Join-Path -Path $folder -ChildPath 'Conflict'
$existingFolder = Join-Path -Path $destination -ChildPath 'Conflict'
Assert-TestSandboxPath -Sandbox $sandbox -Path $sourceFolder, $existingFolder
New-Item -ItemType Directory -Path $sourceFolder, $existingFolder | Out-Null
Set-Content -LiteralPath (Join-Path -Path $sourceFolder -ChildPath 'A.txt') -Value 'New'
Set-Content -LiteralPath (Join-Path -Path $sourceFolder -ChildPath 'B.txt') -Value 'New'
Set-Content -LiteralPath (Join-Path -Path $existingFolder -ChildPath 'A.txt') -Value 'Existing'
& $_ -Path $sourceFolder -Destination $destination -ErrorVariable itemErrors -ErrorAction SilentlyContinue
$itemErrors | Should -HaveCount 1
$itemErrors[0].FullyQualifiedErrorId | Should -BeLike 'DestinationFileAlreadyExists,*'
$itemErrors[0].TargetObject | Should -Be $existingFolder
Get-Content -LiteralPath (Join-Path -Path $existingFolder -ChildPath 'A.txt') | Should -Be 'Existing'
Join-Path -Path $existingFolder -ChildPath 'B.txt' | Should -Not -Exist
Join-Path -Path $sourceFolder -ChildPath 'A.txt' | Should -Exist
}
It 'Copy-Item2 -Force should copy a folder into an existing folder of the same name and replace the files in both' {
$sourceFolder = Join-Path -Path $folder -ChildPath 'Merge'
$existingFolder = Join-Path -Path $destination -ChildPath 'Merge'
Assert-TestSandboxPath -Sandbox $sandbox -Path $sourceFolder, $existingFolder
New-Item -ItemType Directory -Path $sourceFolder, $existingFolder | Out-Null
Set-Content -LiteralPath (Join-Path -Path $sourceFolder -ChildPath 'A.txt') -Value 'New'
Set-Content -LiteralPath (Join-Path -Path $sourceFolder -ChildPath 'B.txt') -Value 'New'
Set-Content -LiteralPath (Join-Path -Path $existingFolder -ChildPath 'A.txt') -Value 'Existing'
Set-Content -LiteralPath (Join-Path -Path $existingFolder -ChildPath 'C.txt') -Value 'Existing'
Copy-Item2 -Path $sourceFolder -Destination $destination -Force -ErrorVariable itemErrors -ErrorAction SilentlyContinue
$itemErrors | Should -BeNullOrEmpty
Get-Content -LiteralPath (Join-Path -Path $existingFolder -ChildPath 'A.txt') | Should -Be 'New'
Get-Content -LiteralPath (Join-Path -Path $existingFolder -ChildPath 'B.txt') | Should -Be 'New'
Get-Content -LiteralPath (Join-Path -Path $existingFolder -ChildPath 'C.txt') | Should -Be 'Existing'
}
# Before 5.0.0-rc6, the error named the source item as the path that wasn't found, also when the folder of the
# destination was missing (#21).
It '<Command> should name the missing folder of the destination for a <Kind> and change nothing' -ForEach @(
@{ Command = 'Copy-Item2'; Kind = 'file'; ErrorId = 'CopyError' }
@{ Command = 'Copy-Item2'; Kind = 'folder'; ErrorId = 'CopyError' }
@{ Command = 'Move-Item2'; Kind = 'file'; ErrorId = 'MoveError' }
@{ Command = 'Move-Item2'; Kind = 'folder'; ErrorId = 'MoveError' }
) {
$source = $first
if ($Kind -eq 'folder') {
$source = Join-Path -Path $folder -ChildPath 'SourceFolder'
Assert-TestSandboxPath -Sandbox $sandbox -Path $source
New-Item -ItemType Directory -Path $source | Out-Null
Set-Content -LiteralPath (Join-Path -Path $source -ChildPath 'Inner.txt') -Value 'Inner'
}
$missingFolder = Join-Path -Path $folder -ChildPath 'MissingFolder'
$target = Join-Path -Path $missingFolder -ChildPath 'Item'
Assert-TestSandboxPath -Sandbox $sandbox -Path $missingFolder, $target
& $Command -Path $source -Destination $target -ErrorVariable itemErrors -ErrorAction SilentlyContinue
$itemErrors | Should -HaveCount 1
$itemErrors[0].FullyQualifiedErrorId | Should -BeLike "$ErrorId,*"
$itemErrors[0].Exception.Message | Should -BeLike "*'$missingFolder'*"
$itemErrors[0].TargetObject | Should -Be $target
$source | Should -Exist
$missingFolder | Should -Not -Exist
}
# Before 5.0.0, -PassThru wrote the item also when -WhatIf skipped the operation.
It '<_> should write nothing with -PassThru and -WhatIf' -ForEach @('Copy-Item2', 'Move-Item2', 'Remove-Item2') {
$parameters = @{ Path = $first; PassThru = $true; WhatIf = $true }
@ -219,6 +316,37 @@ Describe 'Copy-Item2, Move-Item2, and Remove-Item2 with several paths' {
@($messages | Where-Object -FilterScript { "$_" -like "*'$existing' already exists*" }) | Should -HaveCount 1
}
# Before 5.0.0-rc6, -WhatIf didn't tell that the operation would fail because the folder of the destination is
# missing.
It '<_> should name the missing folder of the destination in a verbose message with -WhatIf, and write no error' -ForEach @('Copy-Item2', 'Move-Item2') {
$missingFolder = Join-Path -Path $folder -ChildPath 'MissingFolder'
$target = Join-Path -Path $missingFolder -ChildPath 'Item'
Assert-TestSandboxPath -Sandbox $sandbox -Path $missingFolder, $target
$messages = & $_ -Path $first -Destination $target -WhatIf -Verbose -ErrorVariable itemErrors -ErrorAction SilentlyContinue 4>&1
$itemErrors | Should -BeNullOrEmpty
@($messages | Where-Object -FilterScript { "$_" -like "*'$missingFolder' does not exist*" }) | Should -HaveCount 1
$first | Should -Exist
$missingFolder | Should -Not -Exist
}
# The folder of a destination on a share that doesn't exist is the share itself, which the error names.
It '<Command> should name the missing share of a UNC destination' -ForEach @(
@{ Command = 'Copy-Item2'; ErrorId = 'CopyError' }
@{ Command = 'Move-Item2'; ErrorId = 'MoveError' }
) {
$missingShare = '\\localhost\NTFSSecurityMissing-{0}' -f [guid]::NewGuid().ToString('N').Substring(0, 8)
$target = Join-Path -Path $missingShare -ChildPath 'Item.txt'
& $Command -Path $first -Destination $target -ErrorVariable itemErrors -ErrorAction SilentlyContinue
$itemErrors | Should -HaveCount 1
$itemErrors[0].FullyQualifiedErrorId | Should -BeLike "$ErrorId,*"
$itemErrors[0].Exception.Message | Should -BeLike "*'$missingShare'*"
$first | Should -Exist
}
}
Describe 'Copy-Item2' {
@ -245,3 +373,141 @@ Describe 'Copy-Item2' {
}
}
}
Describe 'Test-Path2' {
BeforeAll {
$folder = New-TestSandboxItem -Sandbox $sandbox -Name 'TestPath' -Directory
$file = Join-Path -Path $folder -ChildPath 'File.txt'
Assert-TestSandboxPath -Sandbox $sandbox -Path $file
Set-Content -LiteralPath $file -Value 'File'
$missing = Join-Path -Path $folder -ChildPath 'Missing.txt'
$paths = @{ 'file' = $file; 'folder' = $folder; 'missing item' = $missing }
}
It 'Should return <Expected> for a <Kind> with -PathType <PathType>' -ForEach @(
@{ Kind = 'file'; PathType = 'Any'; Expected = $true }
@{ Kind = 'folder'; PathType = 'Any'; Expected = $true }
@{ Kind = 'missing item'; PathType = 'Any'; Expected = $false }
@{ Kind = 'file'; PathType = 'Leaf'; Expected = $true }
@{ Kind = 'folder'; PathType = 'Leaf'; Expected = $false }
@{ Kind = 'missing item'; PathType = 'Leaf'; Expected = $false }
@{ Kind = 'file'; PathType = 'Container'; Expected = $false }
@{ Kind = 'folder'; PathType = 'Container'; Expected = $true }
@{ Kind = 'missing item'; PathType = 'Container'; Expected = $false }
) {
$result = @(Test-Path2 -Path $paths[$Kind] -PathType $PathType -ErrorAction Stop)
$result | Should -HaveCount 1
$result[0] | Should -BeOfType [bool]
$result[0] | Should -Be $Expected
}
It 'Should write one value per path in the order of the paths' {
$result = @(Test-Path2 -Path $file, $missing, $folder -ErrorAction Stop)
$result -join ',' | Should -Be 'True,False,True'
}
It 'Should take the items from the pipeline' {
$result = @(Get-ChildItem -LiteralPath $folder | Test-Path2 -PathType Leaf -ErrorAction Stop)
$result -join ',' | Should -Be 'True'
}
It 'Should resolve a relative path against the current location' {
$relative = Join-Path -Path (Split-Path -Path $folder -Leaf) -ChildPath 'File.txt'
Test-Path2 -Path $relative -ErrorAction Stop | Should -BeTrue
}
It 'Should find a folder whose path is longer than 260 characters' {
$longRoot = Join-Path -Path $folder -ChildPath 'Long'
Assert-TestSandboxPath -Sandbox $sandbox -Path $longRoot
$long = Join-Path -Path $longRoot -ChildPath (('A' * 100), ('B' * 100), ('C' * 100) -join '\')
Add-Type -Path (Join-Path -Path (Get-Module -Name NTFSSecurity).ModuleBase -ChildPath 'AlphaFS.dll')
[Alphaleonis.Win32.Filesystem.Directory]::CreateDirectory($long) | Out-Null
$long.Length | Should -BeGreaterThan 260
Test-Path2 -Path $long -PathType Container -ErrorAction Stop | Should -BeTrue
}
# Before 5.0.0-rc6, a path with a character that Windows doesn't allow in file names stopped the cmdlet with a
# terminating "Illegal characters in path" error in Windows PowerShell. Such an item can't exist, so the cmdlet
# writes $false like for any other missing item, as in PowerShell 7 and like Test-Path.
It 'Should return $false for a path with the character <_> and continue with the next path' -ForEach @('|', '<', '>', '"', '*', '?') {
$invalid = Join-Path -Path $folder -ChildPath ('a{0}b' -f $_)
$result = @(Test-Path2 -Path $invalid, $file -ErrorVariable testErrors -ErrorAction SilentlyContinue)
$testErrors | Should -BeNullOrEmpty
$result -join ',' | Should -Be 'False,True'
}
# PowerShell 7 accepts these characters and finds no item, so only Windows PowerShell rejects the path. Before
# 5.0.0-rc6, the cmdlet wrote $false for a rejected path without saying why. -Debug would prompt in Windows
# PowerShell, so the test sets the preference.
It 'Should say in a debug message why it writes $false for a path that Windows PowerShell rejects' -Skip:($PSVersionTable.PSEdition -ne 'Desktop') {
$invalid = Join-Path -Path $folder -ChildPath 'a|b'
$DebugPreference = 'Continue'
$output = @(Test-Path2 -Path $invalid -ErrorAction Stop 5>&1)
$messages = @($output | Where-Object -FilterScript { $_ -is [Management.Automation.DebugRecord] } |
Where-Object -Property Message -Like -Value '*is not a valid path*')
$messages | Should -HaveCount 1
$messages[0].Message.Contains("'$invalid'") | Should -BeTrue
$output | Where-Object -FilterScript { $_ -is [bool] } | Should -BeFalse
}
}
Describe 'Get-DiskSpace' {
BeforeAll {
$systemDrive = New-Object -TypeName 'System.IO.DriveInfo' -ArgumentList $env:SystemDrive
}
It 'Should return the size of the system drive' {
$result = @(Get-DiskSpace -DriveLetter $env:SystemDrive -ErrorAction Stop)
$result | Should -HaveCount 1
$result[0] | Should -BeOfType [Alphaleonis.Win32.Filesystem.DiskSpaceInfo]
$result[0].DriveName | Should -Be ('{0}\' -f $env:SystemDrive)
$result[0].TotalNumberOfBytes | Should -Be $systemDrive.TotalSize
}
It 'Should report free space and clusters that fit the size' {
$result = Get-DiskSpace -DriveLetter $env:SystemDrive -ErrorAction Stop
$result.TotalNumberOfFreeBytes | Should -BeLessOrEqual $result.TotalNumberOfBytes
$result.FreeBytesAvailable | Should -BeLessOrEqual $result.TotalNumberOfFreeBytes
$result.ClusterSize | Should -Be ($result.BytesPerSector * $result.SectorsPerCluster)
$result.NumberOfFreeClusters | Should -BeLessOrEqual $result.TotalNumberOfClusters
}
It 'Should return the volumes with a size greater than zero without -DriveLetter' {
$result = @(Get-DiskSpace -WarningAction SilentlyContinue -ErrorAction Stop)
$result | Should -Not -BeNullOrEmpty
$result | ForEach-Object -Process { $_.TotalNumberOfBytes | Should -BeGreaterThan 0 }
$result.TotalNumberOfBytes | Should -Contain $systemDrive.TotalSize
}
It 'Should warn and return nothing for a drive letter without a volume' {
$used = @((Get-PSDrive -PSProvider FileSystem).Name) + @([System.IO.DriveInfo]::GetDrives() | ForEach-Object -Process { $_.Name.Substring(0, 1) })
$letter = [char[]](68..90) | Where-Object -FilterScript { [string] $_ -notin $used } | Select-Object -Last 1
if (-not $letter) {
Set-ItResult -Skipped -Because 'every drive letter is in use'
return
}
$result = @(Get-DiskSpace -DriveLetter "${letter}:" -WarningVariable spaceWarnings -WarningAction SilentlyContinue -ErrorVariable spaceErrors -ErrorAction SilentlyContinue)
$result | Should -BeNullOrEmpty
$spaceErrors | Should -BeNullOrEmpty
$spaceWarnings.Message | Should -Be "Could not get drive details for '${letter}:'"
}
It 'Should reject a drive letter without a colon' {
{ Get-DiskSpace -DriveLetter 'C' -ErrorAction Stop } |
Should -Throw -ErrorId 'ParameterArgumentValidationError,NTFSSecurity.GetDiskSpace'
}
}

149
Tests/Lab/Acceptance-2026-10-08-5.0.0-rc6.md

@ -0,0 +1,149 @@
# Lab acceptance of 5.0.0-rc6
Acceptance of the release candidate 5.0.0-rc6 in the lab, on 2026-10-08,
before the pull request. It follows the procedure in the
[README](README.md#acceptance-of-a-release-candidate).
The candidate changed twice during the acceptance. The run of `acfe3af`
passed; the coverage report of the candidate then found three defects,
which `0df2482` and `1b9edbb` fix, and the run of `1b9edbb` passed as
well; a second review led to `7b0781f`. This record describes the run of
`7b0781f`, the last commit of the pull request that changes the module,
and names the results of the earlier runs.
## Candidate
- Branch `ai/release-5.0.0-rc6`, commit
`7b0781ff8bb2c1ee4087a16411acd4c7070ba1fa`, 31 commits on `fcb370e`
(5.0.0-rc5).
- Release build of that commit, packaged with
`.github\scripts\New-ModulePackage.ps1`. The live tests imported the
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 |
| --- | --- |
| `E99B5123F45E4F56AC005C629C2241DC616B2AAC152C17EA57A67C0823FFCA10` | `NTFSSecurity.5.0.0-rc6.nupkg` |
| `53C020EAD59467A407ED755F3D9296E9C70AFFAB184CF9CDF27AF92117FBBEE0` | `NTFSSecurity.zip` |
| `F438D7FDB3F5A1D75F5CA48D7B610EED31215FF1BF9C185C6856AA365D195C8D` | `NTFSSecurity\NTFSSecurity.dll` |
| `EE1B0DF9619C998A4482F3F79DCB2191BBEAD859660D6D924D1F333DBE7CBBCE` | `NTFSSecurity\Security2.dll` |
| `902157ABBD2E0B76DA744A918BDD174D5226C3494908ABA75F9E5DE28AE6A008` | `NTFSSecurity\ProcessPrivileges.dll` |
| `E2077AFEB38703345AE7857C1266F8B26E167ED887BFFAC8C8169A8F267BE6E9` | `NTFSSecurity\PrivilegeControl.dll` |
| `A8DA47194AB0F71232C69D01955AD93BA73C7ECEB58D0DE800CA085D4A2E18D8` | `NTFSSecurity\AlphaFS.dll` |
| `75DA9F7A54DF7011968BACB3FDF5E30B33F4C078861D1DF87BC06F5E64A962D8` | `NTFSSecurity\NTFSSecurity.psd1` |
| `3F777E9D141EE0046119DA9D5ECF88A3BC7E023726098FE2FB150528E2FB59B8` | `NTFSSecurity\NTFSSecurity.psm1` |
| `59583423241951EBE0FC2D8237D0C28C3ECC8C7CD2C115D2660F8579888632FC` | `NTFSSecurity\NTFSSecurity.Init.ps1` |
| `FB0920CC37ED858F55AFD54998DC854E27FBBE6A0177CB059CB03CFE91361197` | `NTFSSecurity\NTFSSecurity.format.ps1xml` |
| `CB6882FF91E6716605D5599E7B464C3346E461216ACED07E847621738F04FB9B` | `NTFSSecurity\NTFSSecurity.types.ps1xml` |
| `5115D0D76CA2A06795CD754539AC7EC19A70591E8C5616E7BEB5AE66E6971E6D` | `NTFSSecurity\en-US\NTFSSecurity.dll-Help.xml` |
## Tests without a lab
The Pester suite of the commit, 684 tests, against the same build. No test
failed, and every test ran in at least one configuration.
| Configuration | Passed | Failed | Skipped |
| --- | ---: | ---: | ---: |
| Windows PowerShell 5.1, elevated | 662 | 0 | 22 |
| PowerShell 7, elevated | 632 | 0 | 52 |
| Windows PowerShell 5.1, basic user | 590 | 0 | 94 |
| PowerShell 7, basic user | 560 | 0 | 124 |
The C# coverage of the four configurations, measured with AltCover on
`1b9edbb`: 68.1% of the lines and 44.3% of the branches.
## Lab
`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 each run: WinRM answered on all six machines.
The four domain controllers answered LDAP (RootDSE, synchronized) and
issued a Kerberos ticket for `krbtgt`. The client and the file server
found a domain controller, had a working secure channel, and got a service
ticket for each other. The clocks were 4.3 to 5.0 seconds ahead of the
host.
Checkpoints (Production) of the six machines, taken before each run:
`ntfs-rc6-acfe3af-before-acceptance` (11:07 UTC),
`ntfs-rc6-1b9edbb-before-acceptance` (12:21 UTC), and
`ntfs-rc6-7b0781f-before-acceptance` (12:50 UTC).
## Results
`Invoke-NTFSSecurityLabTest.ps1 -ModulePath <extracted package>` in both
editions, 12:51 to 13:07 UTC. The module reported version 5.0.0-rc6 in
every role that loads it.
| Edition | Role | Passed | Failed | Skipped |
| --- | --- | ---: | ---: | ---: |
| 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: `acfe3af` from 11:08
to 11:25 UTC, and `1b9edbb` from 12:23 to 12:39 UTC.
## Baseline
The published 5.0.0-rc5 from the PowerShell Gallery, whose hash the script
checks against the one the Gallery publishes, in Windows PowerShell 5.1,
11:26 to 11:34 UTC: Delegate 38 passed, ServerAdmin 13, Admin 38 passed and
2 failed, Server 72 passed and 1 skipped. The two failures are the defect
that 5.0.0-rc6 fixes: on the share, `Get-NTFSHardLink` and
`New-NTFSHardLink -PassThru` stopped with the terminating error "(50) The
request is not supported" instead of writing a `GetHardLinkError`. The new
cases find no other difference between the two versions; the local tests
cover the other fixes of 5.0.0-rc6.
## Cleanup
`Invoke-NTFSSecurityLabTest.ps1 -RemoveFixture` after each run: at 11:38
UTC after the first run and the baseline, at 12:48 UTC after the second,
and at 13:09 UTC after the third. Each check compared the lab with the 10
SIDs of the fixture's accounts and groups, read before the removal; the
same check had found the fixture before the first removal. After each
removal:
- No domain has the organizational unit `NTFSSecurityLive` or an account
whose name starts with `NtfsLive`.
- 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 three checkpoints stay on the six machines until the maintainer
deletes them.
## Not covered
- Other operating systems than Windows Server 2025, such as a Windows 11
client and Server 2019 or 2022 file servers: Phase 3 of the quality
gate.
- File servers that aren't Windows, such as the IBM ESS system of #34:
only the feedback of the reporter covers them.
- The package that CI publishes for the tag: the live tests run against it
after the release, with `-Version 5.0.0-rc6`.

188
Tests/Lab/Invoke-NTFSSecurityLabTest.ps1

@ -25,6 +25,11 @@
.PARAMETER Client
The computer that runs the tests. Its domain must be the domain of the domain controller.
.PARAMETER ForeignDomainController
Domain controllers of other domains or forests, one per domain. The script creates the account NtfsLiveForeign in
each of their domains, and the tests grant it access to share folders by SID and by name. The domains need a trust
with the domain of the file server. Pass an empty array to leave these tests out.
.PARAMETER Version
The versions of NTFSSecurity on the PowerShell Gallery to test, such as 5.0.0-rc4.
@ -75,6 +80,11 @@ param (
[string]
$Client = 'F1AFile1',
[Parameter()]
[AllowEmptyCollection()]
[string[]]
$ForeignDomainController = @('F1BDC1', 'F2DC1', 'F3DC1'),
[Parameter(ParameterSetName = 'Test')]
[ValidatePattern('^\d+\.\d+\.\d+(-[0-9A-Za-z]+)?$')]
[string[]]
@ -120,6 +130,9 @@ $roleAccounts = [ordered]@{
}
$subjectAccount = 'NtfsLiveSubject'
$orphanAccount = 'NtfsLiveOrphan'
$foreignAccount = 'NtfsLiveForeign'
# The rights that the entries of the foreign accounts grant on the folder of case 9, by position
$foreignRights = 'ReadAndExecute', 'Modify', 'Write'
$localGroupName = 'NtfsLiveLocal'
$groupMembers = @{
NtfsLiveDelegates = @('NtfsLiveDelegate')
@ -410,6 +423,40 @@ $removeOrphanScript = {
}
}
# Runs on a domain controller of another domain or forest: the account that the tests of case 9 grant access to.
$foreignAccountScript = {
param ($OrganizationalUnitName, $Name, [securestring] $Password)
$ErrorActionPreference = 'Stop'
Import-Module -Name ActiveDirectory
$domain = Get-ADDomain
$server = $domain.PDCEmulator
$path = 'OU={0},{1}' -f $OrganizationalUnitName, $domain.DistinguishedName
if (-not (Get-ADOrganizationalUnit -LDAPFilter "(ou=$OrganizationalUnitName)" -SearchBase $domain.DistinguishedName -SearchScope OneLevel -Server $server)) {
New-ADOrganizationalUnit -Name $OrganizationalUnitName -Path $domain.DistinguishedName -ProtectedFromAccidentalDeletion $false -Server $server
}
$user = Get-ADUser -LDAPFilter "(sAMAccountName=$Name)" -Server $server
if ($user -and $user.DistinguishedName -notlike "*,$path") {
throw "The account '$Name' exists outside '$path'."
}
if ($user) {
Set-ADAccountPassword -Identity $user -Reset -NewPassword $Password -Server $server
Enable-ADAccount -Identity $user -Server $server
}
else {
$user = New-ADUser -Name $Name -SamAccountName $Name -UserPrincipalName "$Name@$($domain.DNSRoot)" -Path $path -AccountPassword $Password -Enabled $true -PasswordNeverExpires $true -Server $server -PassThru
}
[pscustomobject]@{
DomainName = $domain.DNSRoot
Name = '{0}\{1}' -f $domain.NetBIOSName, $Name
UserPrincipalName = '{0}@{1}' -f $Name, $domain.DNSRoot
Sid = $user.SID.Value
}
}
# Runs on the file server once: the local group, the members of Administrators, the share, and the tools folder.
$fileServerSetupScript = {
param ($ShareName, $ShareLocalPath, $PayloadPath, $LocalGroupName, $SubjectSid, $AdministratorSid, $DelegatesSid)
@ -495,7 +542,7 @@ $clientSetupScript = {
# Runs on the file server for each run: creates the folders of the cases below the share and returns what the
# configuration needs. Each case and operation gets its own folder, so that the tests don't depend on each other.
$fixtureScript = {
param ($HelperScript, $ShareLocalPath, $RunId, $Sid, $SubjectPrincipalName, $LongPathSegment)
param ($HelperScript, $ShareLocalPath, $RunId, $Sid, $SubjectPrincipalName, $LongPathSegment, $ForeignAccount)
$ErrorActionPreference = 'Stop'
. ([scriptblock]::Create($HelperScript))
@ -521,7 +568,7 @@ $fixtureScript = {
'PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Runs in the fixture that the script confirmed.'
)]
param ([string] $RelativePath, [object[]] $AccessRule = @(), [switch] $Protected, [switch] $RemoveInherited,
[switch] $Audit, [switch] $LegacyDacl)
[switch] $Audit, [switch] $LegacyDacl, [switch] $InheritableAudit, [switch] $ProtectedAudit)
$path = Join-Path -Path $root -ChildPath $RelativePath
$null = New-Item -ItemType Directory -Path $path -Force
@ -535,12 +582,26 @@ $fixtureScript = {
$acl.AddAccessRule($rule)
}
Set-Acl -LiteralPath $path -AclObject $acl
if ($Audit) {
# Not Set-Acl: in Windows PowerShell, it also writes an empty, protected SACL, which drops the audit entries
# that the folder inherits. SetAccessControl writes only the sections that changed.
[System.IO.Directory]::SetAccessControl($path, $acl)
if ($Audit -or $InheritableAudit -or $ProtectedAudit) {
$auditAcl = Get-Acl -LiteralPath $path -Audit
$everyone = New-Object -TypeName 'System.Security.Principal.SecurityIdentifier' -ArgumentList 'S-1-1-0'
$auditAcl.AddAuditRule((New-Object -TypeName 'System.Security.AccessControl.FileSystemAuditRule' -ArgumentList $everyone, 'Delete', 'None', 'None', 'Success'))
Set-Acl -LiteralPath $path -AclObject $auditAcl
if ($Audit) {
$auditAcl.AddAuditRule((New-Object -TypeName 'System.Security.AccessControl.FileSystemAuditRule' -ArgumentList $everyone, 'Delete', 'None', 'None', 'Success'))
}
# An entry that the subfolders created afterwards inherit
if ($InheritableAudit) {
$auditAcl.AddAuditRule((New-Object -TypeName 'System.Security.AccessControl.FileSystemAuditRule' -ArgumentList $everyone, 'Delete', 'ContainerInherit, ObjectInherit', 'None', 'Failure'))
}
if ($ProtectedAudit) {
$auditAcl.SetAuditRuleProtection($true, $false)
}
[System.IO.Directory]::SetAccessControl($path, $auditAcl)
}
# Set-Acl adds the auto-inherit flag, so the DACL is stored again without it at the end.
@ -595,6 +656,11 @@ $fixtureScript = {
# Case 4: an entry for the account that the domain controller deletes next, and a file that inherits it.
$orphanPath = New-FixtureFolder -RelativePath 'Case4\OrphanedAccess' -AccessRule (New-AccessRule -Sid $Sid.Orphan -Rights 'Modify')
Set-Content -LiteralPath (Join-Path -Path $orphanPath -ChildPath 'File.txt') -Value 'Orphan'
$orphanAuditPath = New-FixtureFolder -RelativePath 'Case4\OrphanedAudit' -AccessRule $delegatesFullControl
$orphanAuditAcl = Get-Acl -LiteralPath $orphanAuditPath -Audit
$orphanIdentity = New-Object -TypeName 'System.Security.Principal.SecurityIdentifier' -ArgumentList $Sid.Orphan
$orphanAuditAcl.AddAuditRule((New-Object -TypeName 'System.Security.AccessControl.FileSystemAuditRule' -ArgumentList $orphanIdentity, 'Delete', 'None', 'None', 'Success'))
[System.IO.Directory]::SetAccessControl($orphanAuditPath, $orphanAuditAcl)
# A file whose path on the share is longer than 260 characters, created by PowerShell 7, which handles such paths.
$longPath = New-FixtureFolder -RelativePath 'LongPath'
@ -609,11 +675,68 @@ $fixtureScript = {
Set-Content -LiteralPath (Join-Path -Path $whatIfPath -ChildPath 'Source.txt') -Value 'Source' -NoNewline
Set-Content -LiteralPath (Join-Path -Path $whatIfPath -ChildPath 'Destination.txt') -Value 'Destination' -NoNewline
# Case 5: the owner cmdlets, and case 6: the audit inheritance cmdlets and Clear-NTFSAudit below a folder whose
# audit entry the subfolders inherit, on folders that Administrators own and the delegated group fully controls.
foreach ($role in 'Admin', 'ServerAdmin', 'Delegate') {
foreach ($operation in 'GetOwner', 'TakeOwnership', 'AssignOwner') {
$null = New-FixtureFolder -RelativePath "Case5\$role\$operation" -AccessRule $delegatesFullControl
}
$null = New-FixtureFolder -RelativePath "Case6\$role" -AccessRule $delegatesFullControl -InheritableAudit
$null = New-FixtureFolder -RelativePath "Case6\$role\DisableAuditInheritance"
$null = New-FixtureFolder -RelativePath "Case6\$role\EnableAuditInheritance" -ProtectedAudit
$null = New-FixtureFolder -RelativePath "Case6\$role\ClearAudit" -Audit
$null = New-FixtureFolder -RelativePath "Case6\$role\GetInheritance" -Protected
}
# Case 7: the item cmdlets in a folder that the delegated group fully controls.
$itemsPath = New-FixtureFolder -RelativePath 'Case7\Items' -AccessRule $delegatesFullControl
foreach ($name in 'Source', 'Move', 'Remove') {
Set-Content -LiteralPath (Join-Path -Path $itemsPath -ChildPath "$name.txt") -Value $name -NoNewline
}
$null = New-Item -ItemType Directory -Path (Join-Path -Path $itemsPath -ChildPath 'Folder')
Set-Content -LiteralPath (Join-Path -Path $itemsPath -ChildPath 'Folder\File.txt') -Value 'File' -NoNewline
# Case 8: the link cmdlets.
$linksPath = New-FixtureFolder -RelativePath 'Case8\Links' -AccessRule $delegatesFullControl
Set-Content -LiteralPath (Join-Path -Path $linksPath -ChildPath 'Target.txt') -Value 'Target' -NoNewline
$null = New-Item -ItemType Directory -Path (Join-Path -Path $linksPath -ChildPath 'TargetFolder')
# Case 9: a subfolder with an entry of its own for Get-NTFSSimpleAccess, and the accounts of other domains.
$null = New-FixtureFolder -RelativePath 'Case9\Simple' -AccessRule $delegatesFullControl
$null = New-FixtureFolder -RelativePath 'Case9\Simple\Child' -AccessRule (New-AccessRule -Sid $Sid.Subject -Rights 'Modify')
$foreignPath = New-FixtureFolder -RelativePath 'Case9\Foreign' -AccessRule @(
foreach ($account in $ForeignAccount) {
New-AccessRule -Sid $account.Sid -Rights $account.Rights
}
)
$null = New-FixtureFolder -RelativePath 'Case9\ForeignAdd' -AccessRule $delegatesFullControl
$null = New-FixtureFolder -RelativePath 'Case9\ForeignRemove' -AccessRule @(
foreach ($account in $ForeignAccount) {
New-AccessRule -Sid $account.Sid -Rights 'ReadAndExecute'
}
)
# The rights that the file server's own token of each foreign account gets on the folder, like case 3. A token
# that the file server can't create is reported as -1, which fails only the effective-access test of the account.
$foreignDescriptor = Get-LabSecurityDescriptor -Path $foreignPath
$foreignEffectiveRights = @{}
foreach ($account in $ForeignAccount) {
try {
$foreignEffectiveRights[$account.Sid] = Get-LabGrantedRight -Descriptor $foreignDescriptor -Sid @(Get-LabTokenSid -UserPrincipalName $account.UserPrincipalName)
}
catch {
$foreignEffectiveRights[$account.Sid] = -1L
}
}
[pscustomobject]@{
ServerPath = $root
FileServerRights = $fileServerRights
EffectiveAccessSddl = $effectiveDescriptor.GetSddlForm('All')
LongPath = $longRelativePath
ServerPath = $root
FileServerRights = $fileServerRights
EffectiveAccessSddl = $effectiveDescriptor.GetSddlForm('All')
LongPath = $longRelativePath
ForeignEffectiveRights = $foreignEffectiveRights
}
}
@ -778,6 +901,21 @@ if (@($machines | ForEach-Object -Process { $_.DomainName } | Select-Object -Uni
throw 'The domain controller, the file server, and the client must belong to one domain.'
}
foreach ($name in $ForeignDomainController) {
$machine = Get-LabVM -ComputerName $name
if (-not $machine) {
throw "The lab '$LabName' has no machine '$name'."
}
if ($machine.DomainName -eq $machines[0].DomainName) {
throw "The foreign domain controller '$name' must belong to another domain than the file server."
}
}
if (@($ForeignDomainController | ForEach-Object -Process { (Get-LabVM -ComputerName $_).DomainName } | Select-Object -Unique).Count -ne @($ForeignDomainController).Count) {
throw 'Each foreign domain controller must belong to a domain of its own.'
}
$helperScript = Get-Content -LiteralPath (Join-Path -Path $PSScriptRoot -ChildPath 'NTFSSecurity.LabHelpers.ps1') -Raw
$labCommand = @{
NoDisplay = $true
@ -794,6 +932,10 @@ if ($RemoveFixture) {
$null = Invoke-LabCommand -ComputerName $FileServer -ActivityName 'Remove the share and the folders' -ScriptBlock $removeFileServerScript -ArgumentList $shareName, $shareLocalPath, $payloadPath, $localGroupName, $accountSids @labCommand
$null = Invoke-LabCommand -ComputerName $Client -ActivityName 'Remove the members and the folder' -ScriptBlock $removeClientScript -ArgumentList $payloadPath, $accountSids @labCommand
$null = Invoke-LabCommand -ComputerName $DomainController -ActivityName 'Remove the accounts' -ScriptBlock $removeAccountScript -ArgumentList $organizationalUnitName @labCommand
foreach ($computer in $ForeignDomainController) {
$null = Invoke-LabCommand -ComputerName $computer -ActivityName 'Remove the account of another domain' -ScriptBlock $removeAccountScript -ArgumentList $organizationalUnitName @labCommand
}
Write-LabProgress "Removed the live tests from the lab '$LabName'."
return
}
@ -837,6 +979,18 @@ foreach ($name in @($roleAccounts.Values) + $subjectAccount) {
$directory = Invoke-LabCommand -ComputerName $DomainController -ActivityName 'Prepare the accounts' -ScriptBlock $accountScript -ArgumentList $organizationalUnitName, $passwords, $groupMembers @labCommand
$sids = $directory.Sids
$foreignAccounts = @(
for ($index = 0; $index -lt @($ForeignDomainController).Count; $index++) {
$account = Invoke-LabCommand -ComputerName $ForeignDomainController[$index] -ActivityName 'Prepare the account of another domain' -ScriptBlock $foreignAccountScript -ArgumentList $organizationalUnitName, $foreignAccount, (New-LabPassword) @labCommand
[pscustomobject]@{
DomainName = $account.DomainName
Name = $account.Name
UserPrincipalName = $account.UserPrincipalName
Sid = $account.Sid
Rights = $foreignRights[$index % $foreignRights.Count]
}
}
)
$localGroupSid = Invoke-LabCommand -ComputerName $FileServer -ActivityName 'Prepare the file server' -ScriptBlock $fileServerSetupScript -ArgumentList $shareName, $shareLocalPath, $payloadPath, $localGroupName, $sids[$subjectAccount], @($sids['NtfsLiveServerAdmin'], $sids['NtfsLiveAdmin']), $sids['NtfsLiveDelegates'] @labCommand
$null = Invoke-LabCommand -ComputerName $Client -ActivityName 'Prepare the client' -ScriptBlock $clientSetupScript -ArgumentList $payloadPath, @($sids['NtfsLiveDelegate'], $sids['NtfsLiveAdmin']), @($sids['NtfsLiveServerAdmin']) @labCommand
foreach ($computer in $Client, $FileServer) {
@ -880,8 +1034,9 @@ foreach ($module in $modules) {
NtfsLiveOuter = $sids['NtfsLiveOuter']
LocalGroup = [string]$localGroupSid
Orphan = $orphan.Sid
Subject = $sids[$subjectAccount]
}
$fixture = Invoke-LabCommand -ComputerName $FileServer -ActivityName 'Create the folders of the run' -ScriptBlock $fixtureScript -ArgumentList $helperScript, $shareLocalPath, $runId, $fixtureSids, $subjectPrincipalName, $longPathSegments @labCommand
$fixture = Invoke-LabCommand -ComputerName $FileServer -ActivityName 'Create the folders of the run' -ScriptBlock $fixtureScript -ArgumentList $helperScript, $shareLocalPath, $runId, $fixtureSids, $subjectPrincipalName, $longPathSegments, $foreignAccounts @labCommand
$null = Invoke-LabCommand -ComputerName $DomainController -ActivityName 'Delete the orphan account' -ScriptBlock $removeOrphanScript -ArgumentList $orphan.Guid @labCommand
$oracle = Invoke-LabCommand -ComputerName $Client -ActivityName 'Calculate the rights on the client' -ScriptBlock $clientOracleScript -ArgumentList $helperScript, $fixture.EffectiveAccessSddl, $subjectPrincipalName, $orphan.Sid @labCommand
if ($oracle.OrphanResolved) {
@ -911,6 +1066,17 @@ foreach ($module in $modules) {
FileServerRights = [long]$fixture.FileServerRights
ClientRights = [long]$oracle.ClientRights
}
ForeignAccounts = @(
foreach ($account in $foreignAccounts) {
[ordered]@{
Name = $account.Name
DomainName = $account.DomainName
Sid = $account.Sid
Rights = $account.Rights
EffectiveRights = [long]$fixture.ForeignEffectiveRights[$account.Sid]
}
}
)
Accounts = [ordered]@{
Delegate = [ordered]@{ Name = $credentials['Delegate'].UserName; Sid = $sids['NtfsLiveDelegate']; ClientAdministrator = $true; FileServerAdministrator = $false }
ServerAdmin = [ordered]@{ Name = $credentials['ServerAdmin'].UserName; Sid = $sids['NtfsLiveServerAdmin']; ClientAdministrator = $false; FileServerAdministrator = $true }

417
Tests/Lab/NTFSSecurity.Live.Tests.ps1

@ -61,6 +61,49 @@ BeforeDiscovery {
@{ Folder = "Case2\$auditRole\RemoveAudit"; Count = [int](-not $mayWrite) }
}
)
# Case 5: only the administrators of the file server hold the Restore privilege there, which assigning an owner
# other than the account itself needs.
$ownerCases = @(
@{ OwnerRole = 'Admin'; Description = 'an administrator of the file server and the client'; MayAssign = $true }
@{ OwnerRole = 'ServerAdmin'; Description = 'an administrator of the file server only'; MayAssign = $true }
@{ OwnerRole = 'Delegate'; Description = 'the delegated account, an administrator of the client only'; MayAssign = $false }
)
$ownerExpectations = @(
foreach ($ownerCase in $ownerCases) {
@{ Folder = "Case5\$($ownerCase.OwnerRole)\GetOwner"; Owner = 'Administrators' }
@{ Folder = "Case5\$($ownerCase.OwnerRole)\TakeOwnership"; Owner = $ownerCase.OwnerRole }
@{ Folder = "Case5\$($ownerCase.OwnerRole)\AssignOwner"; Owner = if ($ownerCase.MayAssign) { 'Subject' } else { 'Administrators' } }
}
)
# Case 6: the state of the SACL that each folder has after the runs. The folders inherit one audit entry; the
# administrators of the file server change them, the delegated account changes nothing.
$auditInheritanceExpectations = @(
foreach ($auditRole in 'Admin', 'ServerAdmin', 'Delegate') {
$mayWrite = $auditRole -ne 'Delegate'
@{ Folder = "Case6\$auditRole\DisableAuditInheritance"; Protected = $mayWrite; Explicit = [int]$mayWrite; Inherited = [int](-not $mayWrite) }
@{ Folder = "Case6\$auditRole\EnableAuditInheritance"; Protected = -not $mayWrite; Explicit = 0; Inherited = [int]$mayWrite }
@{ Folder = "Case6\$auditRole\ClearAudit"; Protected = $false; Explicit = [int](-not $mayWrite); Inherited = 1 }
@{ Folder = "Case6\$auditRole\GetInheritance"; Protected = $false; Explicit = 0; Inherited = 1 }
}
)
$ownedFolders += @(
foreach ($auditRole in 'Admin', 'ServerAdmin', 'Delegate') {
foreach ($operation in 'DisableAuditInheritance', 'EnableAuditInheritance', 'ClearAudit', 'GetInheritance') {
@{ Folder = "Case6\$auditRole\$operation" }
}
}
)
# Case 9: the accounts of other domains and forests that the script created, if any.
$foreignAccounts = @(
if ($configured) {
foreach ($account in (Get-Content -LiteralPath $ConfigurationPath -Raw | ConvertFrom-Json).ForeignAccounts) {
@{ Name = $account.Name; Sid = $account.Sid; Rights = $account.Rights; EffectiveRights = [long]$account.EffectiveRights }
}
}
)
}
BeforeAll {
@ -377,6 +420,23 @@ Describe 'Get-NTFSEffectiveAccess for a domain account on a share folder' -Tag '
}
}
Describe 'Get-NTFSEffectiveAccess as an account that is not an administrator of the file server' -Tag 'Delegate' -Skip:(-not $configured) {
# The cmdlet page: the authorization manager of a computer answers only its administrators and the members of its
# group Access Control Assistance Operators. The error must name the denial; no access instead of an error would be
# a wrong result.
It 'Should write a GetEffectiveAccessError that names the denial with -ServerName, and no result' {
$path = Get-LabPath -RelativePath 'Case5\Delegate\GetOwner'
$result = @(Get-NTFSEffectiveAccess -Path $path -Account $configuration.Accounts.Delegate.Sid -ServerName $configuration.FileServerFqdn -WarningVariable operationWarnings -WarningAction SilentlyContinue -ErrorVariable operationErrors -ErrorAction SilentlyContinue)
$result | Should -BeNullOrEmpty
$operationWarnings | Should -BeNullOrEmpty
@(Format-LabError -ErrorRecord $operationErrors) | Should -HaveCount 1
$operationErrors[0].FullyQualifiedErrorId | Should -BeLike 'GetEffectiveAccessError,*'
$operationErrors[0].Exception.InnerException.NativeErrorCode | Should -Be 5
}
}
Describe 'Get-NTFSOrphanedAccess with the entry of a deleted domain account on a share folder' -Tag 'Admin' -Skip:(-not $configured) {
BeforeAll {
$folder = Get-LabPath -RelativePath 'Case4\OrphanedAccess'
@ -455,6 +515,324 @@ Describe 'Copy-Item2 and Move-Item2 with -WhatIf onto an existing file on a shar
}
}
Describe 'Owner cmdlets on share folders' -Skip:(-not $configured) {
# The file server decides: taking ownership needs the Take Ownership right, which Full Control includes, and
# assigning another account needs the Restore privilege there. The folders start owned by Administrators.
foreach ($ownerCase in $ownerCases) {
Context 'As <Description>' -Tag $ownerCase.OwnerRole -ForEach @($ownerCase) {
BeforeAll {
$folder = Get-LabPath -RelativePath "Case5\$OwnerRole"
$accountSid = $configuration.Accounts.$OwnerRole.Sid
}
It 'Get-NTFSOwner should return Administrators' {
$owners = @(Get-NTFSOwner -Path (Join-Path -Path $folder -ChildPath 'GetOwner') -ErrorVariable operationErrors -ErrorAction SilentlyContinue)
Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty
$owners | Should -HaveCount 1
$owners[0].Owner.Sid | Should -Be $administrators
}
It 'Set-NTFSOwner should make the account itself the owner' {
$path = Join-Path -Path $folder -ChildPath 'TakeOwnership'
Set-NTFSOwner -Path $path -Account $accountSid -ErrorVariable operationErrors -ErrorAction SilentlyContinue
Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty
Get-LabOwner -Path $path | Should -Be $accountSid
}
It 'Set-NTFSOwner should assign another account only with the Restore privilege of the file server' {
$path = Join-Path -Path $folder -ChildPath 'AssignOwner'
Set-NTFSOwner -Path $path -Account $configuration.Accounts.Subject.Sid -ErrorVariable operationErrors -ErrorAction SilentlyContinue
$written = (Format-LabError -ErrorRecord $operationErrors) -join ' | '
if ($MayAssign) {
$written | Should -BeNullOrEmpty
Get-LabOwner -Path $path | Should -Be $configuration.Accounts.Subject.Sid
}
else {
@(Format-LabError -ErrorRecord $operationErrors) | Should -HaveCount 1 -Because "the cmdlet wrote: $written"
$operationErrors[0].FullyQualifiedErrorId | Should -BeLike 'SetOwnerError,*'
Get-LabOwner -Path $path | Should -Be $administrators
}
}
}
}
}
Describe 'Audit inheritance cmdlets and Clear-NTFSAudit on share folders' -Skip:(-not $configured) {
# The subfolders of Case6\<role> inherit one audit entry; the role Server checks the SACLs that the runs left.
foreach ($auditCase in $auditSuccessCases) {
Context 'As <Description>' -Tag $auditCase.AuditRole -ForEach @($auditCase) {
BeforeAll {
$folder = Get-LabPath -RelativePath "Case6\$AuditRole"
}
It 'Disable-NTFSAuditInheritance should protect the audit entries and keep the owner' {
$path = Join-Path -Path $folder -ChildPath 'DisableAuditInheritance'
Disable-NTFSAuditInheritance -Path $path -ErrorVariable operationErrors -ErrorAction SilentlyContinue
Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty
Get-LabOwner -Path $path | Should -Be $administrators
(Get-NTFSInheritance -Path $path).AuditInheritanceEnabled | Should -BeFalse
}
It 'Enable-NTFSAuditInheritance should let the folder inherit the audit entries and keep the owner' {
$path = Join-Path -Path $folder -ChildPath 'EnableAuditInheritance'
Enable-NTFSAuditInheritance -Path $path -ErrorVariable operationErrors -ErrorAction SilentlyContinue
Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty
Get-LabOwner -Path $path | Should -Be $administrators
(Get-NTFSInheritance -Path $path).AuditInheritanceEnabled | Should -BeTrue
}
It 'Clear-NTFSAudit should remove the explicit audit entry, keep the inherited one, and keep the owner' {
$path = Join-Path -Path $folder -ChildPath 'ClearAudit'
Clear-NTFSAudit -Path $path -ErrorVariable operationErrors -ErrorAction SilentlyContinue
Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty
Get-LabOwner -Path $path | Should -Be $administrators
@(Get-NTFSAudit -Path $path -ExcludeInherited) | Should -BeNullOrEmpty
@(Get-NTFSAudit -Path $path -ExcludeExplicit) | Should -HaveCount 1
}
It 'Get-NTFSInheritance should report the protected DACL and the inherited audit entries' {
$states = @(Get-NTFSInheritance -Path (Join-Path -Path $folder -ChildPath 'GetInheritance') -ErrorVariable operationErrors -ErrorAction SilentlyContinue)
Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty
$states | Should -HaveCount 1
$states[0].AccessInheritanceEnabled | Should -BeFalse
$states[0].AuditInheritanceEnabled | Should -BeTrue
}
}
}
Context 'As the delegated account, an administrator of the client only' -Tag 'Delegate' {
BeforeAll {
$folder = Get-LabPath -RelativePath 'Case6\Delegate'
}
It '<Command> should write a <ErrorId> that names the missing privilege and leave the folder unchanged' -ForEach @(
@{ Command = 'Disable-NTFSAuditInheritance'; SubFolder = 'DisableAuditInheritance'; ErrorId = 'ModifySdError' }
@{ Command = 'Enable-NTFSAuditInheritance'; SubFolder = 'EnableAuditInheritance'; ErrorId = 'ModifySdError' }
@{ Command = 'Clear-NTFSAudit'; SubFolder = 'ClearAudit'; ErrorId = 'ClearAclError' }
) {
$path = Join-Path -Path $folder -ChildPath $SubFolder
$before = (Get-LabSecurityDescriptor -Path $path).GetSddlForm('All')
& $Command -Path $path -ErrorVariable operationErrors -ErrorAction SilentlyContinue
$written = (Format-LabError -ErrorRecord $operationErrors) -join ' | '
(Get-LabSecurityDescriptor -Path $path).GetSddlForm('All') | Should -Be $before -Because "the cmdlet wrote: $written"
@(Format-LabError -ErrorRecord $operationErrors) | Should -HaveCount 1 -Because "the cmdlet wrote: $written"
$operationErrors[0].FullyQualifiedErrorId | Should -BeLike "$ErrorId,*"
$operationErrors[0].Exception.Message | Should -Match 'privilege'
}
# The cmdlet page: without the Security privilege, AuditInheritanceEnabled is $null and no error is written.
It 'Get-NTFSInheritance should report the protected DACL, no audit state, and no error' {
$states = @(Get-NTFSInheritance -Path (Join-Path -Path $folder -ChildPath 'GetInheritance') -ErrorVariable operationErrors -ErrorAction SilentlyContinue)
Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty
$states | Should -HaveCount 1
$states[0].AccessInheritanceEnabled | Should -BeFalse
$states[0].AuditInheritanceEnabled | Should -BeNullOrEmpty
}
}
}
Describe 'Item cmdlets on a share folder' -Tag 'Delegate' -Skip:(-not $configured) {
# The delegated account fully controls the folder through its domain group.
BeforeAll {
$folder = Get-LabPath -RelativePath 'Case7\Items'
$source = Join-Path -Path $folder -ChildPath 'Source.txt'
}
It 'Get-Item2 should return the file with its path on the share' {
$item = @(Get-Item2 -Path $source -ErrorVariable operationErrors -ErrorAction SilentlyContinue)
Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty
$item | Should -HaveCount 1
$item[0].FullName | Should -Be $source
$item[0].Length | Should -Be 6
}
It 'Test-Path2 should find the file and the folder, and not a missing item' {
Test-Path2 -Path $source -PathType Leaf | Should -BeTrue
Test-Path2 -Path (Join-Path -Path $folder -ChildPath 'Folder') -PathType Container | Should -BeTrue
Test-Path2 -Path (Join-Path -Path $folder -ChildPath 'Missing.txt') | Should -BeFalse
}
It 'Get-FileHash2 should return the hash that Get-FileHash returns' {
$hash = @(Get-FileHash2 -Path $source -Algorithm SHA256 -ErrorVariable operationErrors -ErrorAction SilentlyContinue)
Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty
$hash | Should -HaveCount 1
$hash[0].Hash | Should -Be (Get-FileHash -LiteralPath $source -Algorithm SHA256).Hash
}
It 'Copy-Item2 should copy a file, and a folder with its file' {
Copy-Item2 -Path $source -Destination (Join-Path -Path $folder -ChildPath 'Copy.txt') -ErrorVariable operationErrors -ErrorAction SilentlyContinue
Copy-Item2 -Path (Join-Path -Path $folder -ChildPath 'Folder') -Destination (Join-Path -Path $folder -ChildPath 'FolderCopy') -ErrorVariable +operationErrors -ErrorAction SilentlyContinue
Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty
Get-Content -LiteralPath (Join-Path -Path $folder -ChildPath 'Copy.txt') -Raw | Should -Be 'Source'
Get-Content -LiteralPath (Join-Path -Path $folder -ChildPath 'FolderCopy\File.txt') -Raw | Should -Be 'File'
Get-Content -LiteralPath $source -Raw | Should -Be 'Source'
}
It 'Move-Item2 should move a file' {
$moving = Join-Path -Path $folder -ChildPath 'Move.txt'
Move-Item2 -Path $moving -Destination (Join-Path -Path $folder -ChildPath 'Moved.txt') -ErrorVariable operationErrors -ErrorAction SilentlyContinue
Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty
Test-Path -LiteralPath $moving | Should -BeFalse
Get-Content -LiteralPath (Join-Path -Path $folder -ChildPath 'Moved.txt') -Raw | Should -Be 'Move'
}
It 'Remove-Item2 should remove a file' {
$removing = Join-Path -Path $folder -ChildPath 'Remove.txt'
Remove-Item2 -Path $removing -ErrorVariable operationErrors -ErrorAction SilentlyContinue
Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty
Test-Path -LiteralPath $removing | Should -BeFalse
}
It 'Get-ChildItem2 should list the file and the folder that the tests leave in place' {
$names = @(Get-ChildItem2 -Path $folder -ErrorVariable operationErrors -ErrorAction SilentlyContinue).Name
Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty
$names | Should -Contain 'Source.txt'
$names | Should -Contain 'Folder'
}
}
Describe 'Link cmdlets on a share folder' -Tag 'Admin' -Skip:(-not $configured) {
BeforeAll {
$folder = Get-LabPath -RelativePath 'Case8\Links'
$target = Join-Path -Path $folder -ChildPath 'Target.txt'
$hardLink = Join-Path -Path $folder -ChildPath 'HardLink.txt'
}
It 'New-NTFSHardLink should give the file a second name' {
New-NTFSHardLink -Path $hardLink -Target $target -ErrorVariable operationErrors -ErrorAction SilentlyContinue
Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty
Get-Content -LiteralPath $hardLink -Raw | Should -Be 'Target'
}
# The cmdlet pages: Windows can't list the names of a file on a share, so the cmdlets write a GetHardLinkError.
It 'Get-NTFSHardLink should write a GetHardLinkError, because Windows cannot list the names on a share' {
$links = @(Get-NTFSHardLink -Path $target -ErrorVariable operationErrors -ErrorAction SilentlyContinue)
$links | Should -BeNullOrEmpty
@(Format-LabError -ErrorRecord $operationErrors) | Should -HaveCount 1
$operationErrors[0].FullyQualifiedErrorId | Should -BeLike 'GetHardLinkError,*'
}
It 'New-NTFSHardLink -PassThru should create the link and write a GetHardLinkError instead of the names' {
$passThruLink = Join-Path -Path $folder -ChildPath 'PassThruLink.txt'
$result = @(New-NTFSHardLink -Path $passThruLink -Target $target -PassThru -ErrorVariable operationErrors -ErrorAction SilentlyContinue)
$result | Should -BeNullOrEmpty
@(Format-LabError -ErrorRecord $operationErrors) | Should -HaveCount 1
$operationErrors[0].FullyQualifiedErrorId | Should -BeLike 'GetHardLinkError,*'
Get-Content -LiteralPath $passThruLink -Raw | Should -Be 'Target'
}
It 'New-NTFSSymbolicLink should create a link to a file and a link to a folder' {
New-NTFSSymbolicLink -Path (Join-Path -Path $folder -ChildPath 'FileLink.txt') -Target $target -ErrorVariable operationErrors -ErrorAction SilentlyContinue
New-NTFSSymbolicLink -Path (Join-Path -Path $folder -ChildPath 'FolderLink') -Target (Join-Path -Path $folder -ChildPath 'TargetFolder') -ErrorVariable +operationErrors -ErrorAction SilentlyContinue
Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty
}
}
Describe 'Get-NTFSSimpleAccess on share folders' -Tag 'Delegate' -Skip:(-not $configured) {
It 'Should report the parent folder first and for the subfolder only the entry of its own' {
$parent = Get-LabPath -RelativePath 'Case9\Simple'
$child = Join-Path -Path $parent -ChildPath 'Child'
$entries = @(Get-NTFSSimpleAccess -Path $child -ErrorVariable operationErrors -ErrorAction SilentlyContinue)
Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty
$entries | Should -Not -BeNullOrEmpty
$entries[0].FullName | Should -Be $parent
@($entries | Where-Object -Property FullName -EQ -Value $child).Identity.Sid | Should -Be $configuration.Accounts.Subject.Sid
}
}
Describe 'Get-NTFSOrphanedAudit with the audit entry of a deleted domain account on a share folder' -Tag 'Admin' -Skip:(-not $configured) {
It 'Should return the audit entry of the deleted account with its SID' {
$entries = @(Get-NTFSOrphanedAudit -Path (Get-LabPath -RelativePath 'Case4\OrphanedAudit') -WarningVariable operationWarnings -WarningAction SilentlyContinue -ErrorVariable operationErrors -ErrorAction SilentlyContinue)
Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty
$operationWarnings | Should -BeNullOrEmpty
$entries | Should -HaveCount 1
$entries[0].Account.Sid | Should -Be $configuration.Accounts.Orphan.Sid
}
}
Describe 'Accounts of another domain and of other forests on share folders' -Tag 'Admin' -Skip:(-not $configured -or $foreignAccounts.Count -eq 0) {
# Through the trusts, the client resolves the names of the accounts, and the file server creates their tokens.
BeforeAll {
$folder = Get-LabPath -RelativePath 'Case9\Foreign'
}
It 'Get-NTFSAccess should return the entry of <Name> with its name' -ForEach $foreignAccounts {
$entries = @(Get-NTFSAccess -Path $folder -ExcludeInherited -ErrorVariable operationErrors -ErrorAction SilentlyContinue |
Where-Object -FilterScript { $_.Account.Sid -eq $Sid })
Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty
$entries | Should -HaveCount 1
$entries[0].Account.AccountName | Should -Be $Name
}
It 'Get-NTFSOrphanedAccess should not report the entries of the accounts' {
$entries = @(Get-NTFSOrphanedAccess -Path $folder -ErrorVariable operationErrors -ErrorAction SilentlyContinue)
Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty
$entries | Should -BeNullOrEmpty
}
It 'Add-NTFSAccess should add an entry for <Name> by its name' -ForEach $foreignAccounts {
$path = Get-LabPath -RelativePath 'Case9\ForeignAdd'
Add-NTFSAccess -Path $path -Account $Name -AccessRights ReadAndExecute -ErrorVariable operationErrors -ErrorAction SilentlyContinue
Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty
@(Get-LabExplicitAccessRule -Path $path -Sid $Sid) | Should -HaveCount 1
}
It 'Remove-NTFSAccess should remove the entry of <Name> by its name' -ForEach $foreignAccounts {
$path = Get-LabPath -RelativePath 'Case9\ForeignRemove'
Remove-NTFSAccess -Path $path -Account $Name -AccessRights ReadAndExecute -InheritanceFlags 'ContainerInherit, ObjectInherit' -PropagationFlags None -ErrorVariable operationErrors -ErrorAction SilentlyContinue
Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty
Get-LabExplicitAccessRule -Path $path -Sid $Sid | Should -BeNullOrEmpty
}
It 'Get-NTFSEffectiveAccess should return the rights of <Name> on the file server with -ServerName, without a warning' -ForEach $foreignAccounts {
$EffectiveRights | Should -BeGreaterThan 0 -Because 'the file server must create a token for the account to calculate the expected rights'
$result = @(Get-NTFSEffectiveAccess -Path $folder -Account $Name -ServerName $configuration.FileServerFqdn -WarningVariable operationWarnings -WarningAction SilentlyContinue -ErrorVariable operationErrors -ErrorAction SilentlyContinue)
Format-LabError -ErrorRecord $operationErrors | Should -BeNullOrEmpty
$operationWarnings | Should -BeNullOrEmpty
$result | Should -HaveCount 1
Format-LabRight -Right $result[0].AccessRights | Should -Be (Format-LabRight -Right $EffectiveRights)
}
}
Describe 'Security descriptors on the file server after the runs on the client' -Tag 'Server' -Skip:(-not $configured) {
It 'Should keep Administrators as the owner of <Folder>' -ForEach $ownedFolders {
Get-LabOwner -Path (Get-LabPath -RelativePath $Folder) | Should -Be $administrators
@ -465,4 +843,43 @@ Describe 'Security descriptors on the file server after the runs on the client'
@($acl.GetAuditRules($true, $false, $sidType)).Count | Should -Be $Count
}
It 'Should have the owner <Owner> on <Folder>' -ForEach $ownerExpectations {
$expected = if ($Owner -eq 'Administrators') { $administrators } else { $configuration.Accounts.$Owner.Sid }
Get-LabOwner -Path (Get-LabPath -RelativePath $Folder) | Should -Be $expected
}
It 'Should have <Explicit> explicit and <Inherited> inherited audit entries on <Folder>, protected: <Protected>' -ForEach $auditInheritanceExpectations {
$acl = Get-Acl -LiteralPath (Get-LabPath -RelativePath $Folder) -Audit
$acl.AreAuditRulesProtected | Should -Be $Protected
@($acl.GetAuditRules($true, $false, $sidType)).Count | Should -Be $Explicit
@($acl.GetAuditRules($false, $true, $sidType)).Count | Should -Be $Inherited
}
It 'Should have the items that the item cmdlets left' {
$folder = Get-LabPath -RelativePath 'Case7\Items'
foreach ($name in 'Source.txt', 'Copy.txt', 'Moved.txt', 'FolderCopy\File.txt') {
Test-Path -LiteralPath (Join-Path -Path $folder -ChildPath $name) -PathType Leaf | Should -BeTrue -Because $name
}
foreach ($name in 'Move.txt', 'Remove.txt') {
Test-Path -LiteralPath (Join-Path -Path $folder -ChildPath $name) | Should -BeFalse -Because $name
}
}
It 'Should have the links that the link cmdlets created' {
$folder = Get-LabPath -RelativePath 'Case8\Links'
@(& fsutil.exe hardlink list (Join-Path -Path $folder -ChildPath 'Target.txt') | Where-Object -FilterScript { $_ }) | Should -HaveCount 3
(Get-Item -LiteralPath (Join-Path -Path $folder -ChildPath 'FileLink.txt') -Force).LinkType | Should -Be 'SymbolicLink'
(Get-Item -LiteralPath (Join-Path -Path $folder -ChildPath 'FolderLink') -Force).LinkType | Should -Be 'SymbolicLink'
}
It 'Should have the entry of <Name> that Add-NTFSAccess added, and not the one that Remove-NTFSAccess removed' -ForEach $foreignAccounts {
@(Get-LabExplicitAccessRule -Path (Get-LabPath -RelativePath 'Case9\ForeignAdd') -Sid $Sid) | Should -HaveCount 1
Get-LabExplicitAccessRule -Path (Get-LabPath -RelativePath 'Case9\ForeignRemove') -Sid $Sid | Should -BeNullOrEmpty
}
}

50
Tests/Lab/README.md

@ -13,11 +13,16 @@ without a lab they skip every test.
| --- | --- | --- |
| 1, [#34][issue-34] | Delegate | `Add-NTFSAccess`, `Remove-NTFSAccess`, `Clear-NTFSAccess`, `Disable-NTFSAccessInheritance`, `Enable-NTFSAccessInheritance`, `Set-NTFSInheritance`, and `Set-NTFSSecurityDescriptor` on share folders that Administrators own and on which a domain group has Full Control, run by a member of that group who isn't an administrator of the file server. They succeed and keep the owner. |
| 2 | Admin, ServerAdmin, Delegate | `Get-NTFSAudit`, `Add-NTFSAudit`, and `Remove-NTFSAudit` on share folders. Over SMB, the file server checks the Security privilege of the account. The administrators of the file server read and change the audit entries; the delegated account gets the errors that the cmdlet pages describe, and the folders stay unchanged. |
| 3 | Admin | `Get-NTFSEffectiveAccess` for a domain account with rights through two nested domain groups and through a local group of the file server. With `-ServerName`, the result includes the local group, without a warning; without it, the client doesn't know that group. With an unreachable server, the cmdlet falls back to the client and warns. |
| 4 | Admin | `Get-NTFSOrphanedAccess` returns the entry of a deleted domain account with its SID, on the folder and as inherited entry on a file in it. |
| 3 | Admin, Delegate | `Get-NTFSEffectiveAccess` for a domain account with rights through two nested domain groups and through a local group of the file server. With `-ServerName`, the result includes the local group, without a warning; without it, the client doesn't know that group. With an unreachable server, the cmdlet falls back to the client and warns. The authorization manager of the file server refuses the delegated account, which isn't an administrator there, and the cmdlet reports that as an error, not as no access. |
| 4 | Admin | `Get-NTFSOrphanedAccess` returns the entry of a deleted domain account with its SID, on the folder and as inherited entry on a file in it; `Get-NTFSOrphanedAudit` returns the audit entry of that account. |
| 5 | Admin, ServerAdmin, Delegate | `Get-NTFSOwner` and `Set-NTFSOwner` on share folders that Administrators own. Every role makes itself the owner; only the administrators of the file server, which hold the Restore privilege there, assign another account. The delegated account gets a `SetOwnerError`, and the owner stays. |
| 6 | Admin, ServerAdmin, Delegate | `Disable-NTFSAuditInheritance`, `Enable-NTFSAuditInheritance`, `Clear-NTFSAudit`, and `Get-NTFSInheritance` on share folders that inherit an audit entry. The administrators of the file server change the audit entries; the delegated account gets the errors that the cmdlet pages describe, and `Get-NTFSInheritance` reports no audit state for it. |
| 7 | Delegate | `Get-Item2`, `Test-Path2`, `Get-FileHash2`, `Copy-Item2`, `Move-Item2`, `Remove-Item2`, and `Get-ChildItem2` in a share folder. |
| 8 | Admin | `New-NTFSHardLink`, `Get-NTFSHardLink`, and `New-NTFSSymbolicLink` in a share folder. Windows can't list the names of a file on a share, so `Get-NTFSHardLink` and `New-NTFSHardLink -PassThru` write the `GetHardLinkError` that their pages describe. |
| 9 | Delegate, Admin | `Get-NTFSSimpleAccess` compares a share folder with its parent. For the accounts of another domain and of other forests, `Get-NTFSAccess` returns their names, `Get-NTFSOrphanedAccess` doesn't report them, `Add-NTFSAccess` and `Remove-NTFSAccess` find them by name, and `Get-NTFSEffectiveAccess -ServerName` returns the rights that the file server's own token of each account gets. |
| Long paths | Admin | `Get-ChildItem2` and `Get-NTFSAccess` with a share path longer than 260 characters. |
| [#108][issue-108] | Admin | `Copy-Item2` and `Move-Item2` with `-WhatIf` onto an existing file on the share write no error. |
| State | Server | After the runs on the client, the file server checks the owners and the audit entries of the folders itself, without the module. |
| State | Server | After the runs on the client, the file server checks the owners, the audit entries, the items, the links, and the entries of the foreign accounts itself, without the module. |
Case 1 uses two kinds of folders. Before 5.0.0-rc3, the cmdlets wrote back the
owner that Windows returns with a DACL without the auto-inherit flag, and the
@ -29,7 +34,8 @@ Windows 2000. `Set-NTFSSecurityDescriptor` wrote the owner on both kinds.
The expected rights of case 3 come from the tokens that the file server and the
client create for the account with a Kerberos S4U logon, the way the Effective
Access tab of the advanced security settings does.
Access tab of the advanced security settings does. Case 9 calculates the rights
of the foreign accounts the same way, on the file server.
## Roles
@ -43,7 +49,8 @@ Access tab of the advanced security settings does.
The script also creates `NtfsLiveSubject`, the account of case 3, which is a
member of `NtfsLiveInner`, a member of `NtfsLiveOuter`, and of the local group
`NtfsLiveLocal` of the file server, and `NtfsLiveOrphan`, which it deletes in
every run.
every run. For case 9, it creates `NtfsLiveForeign` in the organizational unit
`NTFSSecurityLive` of each domain of `-ForeignDomainController`.
## Lab
@ -55,11 +62,15 @@ use the lab of
`tests/Lab/Deploy-WindowsAccessControlLab.ps1` in that repository deploys:
`F1ADC1` as domain controller, `F1AFile2` as file server, and `F1AFile1` as
client, all in `a.forest1.net`. `-DomainController`, `-FileServer`, and
`-Client` select other machines.
`-Client` select other machines. Case 9 uses `F1BDC1` of `b.forest1.net`, a
domain of the same forest, and `F2DC1` and `F3DC1` of the forests
`forest2.net` and `forest3.net`, which have forest trusts with `forest1.net`;
`-ForeignDomainController @()` leaves it out.
The script adds to the lab:
- the organizational unit `NTFSSecurityLive` with the accounts and groups
- the organizational unit `NTFSSecurityLive` with the accounts and groups, and
with `NtfsLiveForeign` in the domains of the foreign domain controllers
- the local group `NtfsLiveLocal` and members of Administrators on the file
server, and members of Administrators and Remote Management Users on the
client
@ -107,9 +118,30 @@ Each call writes to a new folder in `$env:TEMP\NTFSSecurityLab\Results`:
folder after the run
A version before 5.0.0-rc3 fails case 1 with error 1307, a version before
5.0.0-rc4 fails the tests of #108, and a version before 5.0.0-rc5 fails the
5.0.0-rc4 fails the tests of #108, a version before 5.0.0-rc5 fails the
test of case 3 with a computer that can't be reached: it returned no access
instead of the result of the client.
instead of the result of the client. A version before 5.0.0-rc6 fails two
tests of case 8: `Get-NTFSHardLink` and `New-NTFSHardLink -PassThru` stopped
on the share with the terminating error (50).
## Acceptance of a release candidate
Before a release, run the live tests once more under controlled conditions
and record the evidence in this folder:
1. Build the candidate once, package it with
`.github\scripts\New-ModulePackage.ps1`, and record the SHA-256 of the
packages and of the module files.
2. Check that WinRM, LDAP, Kerberos, the secure channel, and the clocks of
the lab machines work.
3. Take a checkpoint of the machines, named after the candidate and its
commit.
4. Run the tests with `-ModulePath` of the extracted `NTFSSecurity.zip` in
both editions.
5. Remove the fixture with `-RemoveFixture` and check that its accounts,
share, folders, group memberships, and profiles are gone.
Records: [5.0.0-rc6](Acceptance-2026-10-08-5.0.0-rc6.md).
## Files

281
Tests/Links.Tests.ps1

@ -6,12 +6,28 @@
)]
param ()
BeforeDiscovery {
Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force
$canCreateSymbolicLinks = Test-PrivilegeHeld -Name 'SeCreateSymbolicLinkPrivilege'
# The administrative share of the drive of the sandboxes reaches them over SMB, like a share of a file server.
$tempPath = [IO.Path]::GetTempPath()
$canUseAdminShare = (Test-IsElevated) -and
(Test-Path -LiteralPath ('\\localhost\{0}$\' -f $tempPath.Substring(0, 1)) -ErrorAction SilentlyContinue)
}
BeforeAll {
Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force
$modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\NTFSSecurity\bin\Release\NTFSSecurity.psd1'
Import-Module -Name $modulePath -Force -ErrorAction Stop
$sandbox = New-TestSandbox -Name '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 {
@ -41,4 +57,269 @@ Describe 'New-NTFSHardLink' {
{ New-NTFSHardLink -Path $link -Target $missing -ErrorAction Stop } | Should -Throw -ExpectedMessage '*does not exist*'
$link | Should -Not -Exist
}
It 'Should write nothing without -PassThru' {
$target = New-TestSandboxItem -Sandbox $sandbox -Name 'Quiet'
$link = Join-Path -Path $sandbox -ChildPath 'QuietLink.txt'
Assert-TestSandboxPath -Sandbox $sandbox -Path $link
$result = @(New-NTFSHardLink -Path $link -Target $target -ErrorAction Stop)
$result | Should -BeNullOrEmpty
}
It 'Should return every name of the file with -PassThru' {
$target = New-TestSandboxItem -Sandbox $sandbox -Name 'Names'
$link = Join-Path -Path $sandbox -ChildPath 'NamesLink.txt'
Assert-TestSandboxPath -Sandbox $sandbox -Path $link
$result = @(New-NTFSHardLink -Path $link -Target $target -PassThru -ErrorAction Stop)
$result | Should -HaveCount 2
$result | ForEach-Object -Process { $_ | Should -BeOfType [Alphaleonis.Win32.Filesystem.FileInfo] }
($result.FullName | Sort-Object) -join '|' | Should -Be ((@($target, $link) | Sort-Object) -join '|')
$result[0].Mode | Should -Not -BeNullOrEmpty
}
It 'Should give both names the same data' {
$target = New-TestSandboxItem -Sandbox $sandbox -Name 'Shared'
$link = Join-Path -Path $sandbox -ChildPath 'SharedLink.txt'
Assert-TestSandboxPath -Sandbox $sandbox -Path $link
New-NTFSHardLink -Path $link -Target $target -ErrorAction Stop
Set-Content -LiteralPath $link -Value 'Changed through the link'
Get-Content -LiteralPath $target | Should -Be 'Changed through the link'
}
It 'Should resolve relative paths against the current location' {
$target = New-TestSandboxItem -Sandbox $sandbox -Name 'Relative'
$link = Join-Path -Path $sandbox -ChildPath 'RelativeLink.txt'
Assert-TestSandboxPath -Sandbox $sandbox -Path $link
New-NTFSHardLink -Path 'RelativeLink.txt' -Target (Split-Path -Path $target -Leaf) -ErrorAction Stop
$link | Should -Exist
}
It 'Should refuse an existing -Path and leave it unchanged' {
$target = New-TestSandboxItem -Sandbox $sandbox -Name 'ExistingTarget'
$existing = New-TestSandboxItem -Sandbox $sandbox -Name 'Existing'
Set-Content -LiteralPath $existing -Value 'Existing'
{ New-NTFSHardLink -Path $existing -Target $target -ErrorAction Stop } | Should -Throw -ExpectedMessage '*already exist*'
Get-Content -LiteralPath $existing | Should -Be 'Existing'
}
It 'Should refuse a folder as -Target and create no link' {
$folder = New-TestSandboxItem -Sandbox $sandbox -Name 'FolderTarget' -Directory
$link = Join-Path -Path $sandbox -ChildPath 'FolderLink.txt'
Assert-TestSandboxPath -Sandbox $sandbox -Path $link
{ New-NTFSHardLink -Path $link -Target $folder -ErrorAction Stop } | Should -Throw -ExpectedMessage '*not a file*'
$link | Should -Not -Exist
}
# Windows can't list the names of a file on a network share. Before 5.0.0-rc6, the cmdlet stopped with a
# terminating error after it had created the link.
It 'Should create the link on a network share and write an error for -PassThru, which cannot list the names there' -Skip:(-not $canUseAdminShare) {
$target = New-TestSandboxItem -Sandbox $sandbox -Name 'ShareTarget'
$link = Join-Path -Path $sandbox -ChildPath 'ShareLink.txt'
Assert-TestSandboxPath -Sandbox $sandbox -Path $link
$result = @(New-NTFSHardLink -Path (ConvertTo-AdminSharePath -Path $link) -Target (ConvertTo-AdminSharePath -Path $target) -PassThru -ErrorVariable linkErrors -ErrorAction SilentlyContinue)
$link | Should -Exist
$linkErrors | Should -HaveCount 1
$linkErrors[0].FullyQualifiedErrorId | Should -BeLike 'GetHardLinkError,*'
$result | Should -BeNullOrEmpty
}
}
Describe 'Get-NTFSHardLink' {
It 'Should return the file itself for a file with one name' {
$file = New-TestSandboxItem -Sandbox $sandbox -Name 'Single'
$result = @(Get-NTFSHardLink -Path $file -ErrorAction Stop)
$result | Should -HaveCount 1
$result[0].FullName | Should -Be $file
$result[0].Mode | Should -Not -BeNullOrEmpty
}
It 'Should return every name of a file with hard links' {
$file = New-TestSandboxItem -Sandbox $sandbox -Name 'Linked'
$link = Join-Path -Path $sandbox -ChildPath 'LinkedLink.txt'
Assert-TestSandboxPath -Sandbox $sandbox -Path $link
New-NTFSHardLink -Path $link -Target $file -ErrorAction Stop
$result = @(Get-NTFSHardLink -Path $link -ErrorAction Stop)
($result.FullName | Sort-Object) -join '|' | Should -Be ((@($file, $link) | Sort-Object) -join '|')
}
It 'Should take the files with more than one name from Get-ChildItem2' {
$folder = New-TestSandboxItem -Sandbox $sandbox -Name 'Counted' -Directory
$file = Join-Path -Path $folder -ChildPath 'File.txt'
$link = Join-Path -Path $folder -ChildPath 'Link.txt'
$other = Join-Path -Path $folder -ChildPath 'Other.txt'
Assert-TestSandboxPath -Sandbox $sandbox -Path $file, $link, $other
Set-Content -LiteralPath $file, $other -Value 'File'
New-NTFSHardLink -Path $link -Target $file -ErrorAction Stop
$result = @(Get-ChildItem2 -Path $folder -File | Where-Object -Property HardLinkCount -GT -Value 1 | Get-NTFSHardLink -ErrorAction Stop)
$result.FullName | Should -Not -Contain $other
@($result.FullName | Sort-Object -Unique) -join '|' | Should -Be ((@($file, $link) | Sort-Object) -join '|')
}
It 'Should write an error for a path that does not exist and continue with the next path' {
$missing = Join-Path -Path $sandbox -ChildPath 'MissingHardLink.txt'
$file = New-TestSandboxItem -Sandbox $sandbox -Name 'AfterMissing'
$result = @(Get-NTFSHardLink -Path $missing, $file -ErrorVariable linkErrors -ErrorAction SilentlyContinue)
$linkErrors | Should -HaveCount 1
$linkErrors[0].FullyQualifiedErrorId | Should -BeLike 'FileNotFound,*'
$result.FullName | Should -Be $file
}
# Before 5.0.0-rc6, a folder stopped the cmdlet with a terminating error, so that it skipped the remaining paths.
It 'Should write an error for a folder and continue with the next path' {
$folder = New-TestSandboxItem -Sandbox $sandbox -Name 'HardLinkFolder' -Directory
$file = New-TestSandboxItem -Sandbox $sandbox -Name 'AfterFolder'
$result = @(Get-NTFSHardLink -Path $folder, $file -ErrorVariable linkErrors -ErrorAction SilentlyContinue)
$linkErrors | Should -HaveCount 1
$linkErrors[0].FullyQualifiedErrorId | Should -BeLike 'GetHardLinkError,*'
$linkErrors[0].TargetObject | Should -Be $folder
$linkErrors[0].Exception.Message | Should -Be 'The item must be a file'
$result.FullName | Should -Be $file
}
# Windows can't list the names of a file on a network share. Before 5.0.0-rc6, the cmdlet stopped with a
# terminating error, so that it skipped the remaining paths.
It 'Should write an error for a file on a network share and continue with the next path' -Skip:(-not $canUseAdminShare) {
$file = New-TestSandboxItem -Sandbox $sandbox -Name 'ShareFile'
$other = New-TestSandboxItem -Sandbox $sandbox -Name 'AfterShare'
$sharePath = ConvertTo-AdminSharePath -Path $file
$result = @(Get-NTFSHardLink -Path $sharePath, $other -ErrorVariable linkErrors -ErrorAction SilentlyContinue)
$linkErrors | Should -HaveCount 1
$linkErrors[0].FullyQualifiedErrorId | Should -BeLike 'GetHardLinkError,*'
$linkErrors[0].TargetObject | Should -Be $sharePath
$result.FullName | Should -Be $other
}
}
Describe 'New-NTFSSymbolicLink' {
BeforeAll {
function Get-LinkTarget {
param ([string] $Path)
# Windows PowerShell returns the target as an array, PowerShell 7 as a string.
@((Get-Item -LiteralPath $Path -Force).Target)[0]
}
}
It 'Should create a link to a file that reads the data of the file' -Skip:(-not $canCreateSymbolicLinks) {
$target = New-TestSandboxItem -Sandbox $sandbox -Name 'SymbolicTarget'
Set-Content -LiteralPath $target -Value 'Target'
$link = Join-Path -Path $sandbox -ChildPath 'SymbolicFile.txt'
Assert-TestSandboxPath -Sandbox $sandbox -Path $link
$result = @(New-NTFSSymbolicLink -Path $link -Target $target -ErrorAction Stop)
$result | Should -BeNullOrEmpty
(Get-Item -LiteralPath $link -Force).LinkType | Should -Be 'SymbolicLink'
Get-LinkTarget -Path $link | Should -Be $target
Get-Content -LiteralPath $link | Should -Be 'Target'
}
It 'Should create a link to a folder through which the files of the folder are reachable' -Skip:(-not $canCreateSymbolicLinks) {
$folder = New-TestSandboxItem -Sandbox $sandbox -Name 'SymbolicFolder' -Directory
$file = Join-Path -Path $folder -ChildPath 'File.txt'
$link = Join-Path -Path $sandbox -ChildPath 'SymbolicFolderLink'
Assert-TestSandboxPath -Sandbox $sandbox -Path $file, $link
Set-Content -LiteralPath $file -Value 'File'
New-NTFSSymbolicLink -Path $link -Target $folder -ErrorAction Stop
(Get-Item -LiteralPath $link -Force).Attributes.HasFlag([IO.FileAttributes]::Directory) | Should -BeTrue
Test-Path2 -Path (Join-Path -Path $link -ChildPath 'File.txt') -PathType Leaf | Should -BeTrue
}
It 'Should return a file object for a link to a file with -PassThru' -Skip:(-not $canCreateSymbolicLinks) {
$target = New-TestSandboxItem -Sandbox $sandbox -Name 'SymbolicPassThru'
$link = Join-Path -Path $sandbox -ChildPath 'SymbolicPassThru.txt'
Assert-TestSandboxPath -Sandbox $sandbox -Path $link
$result = New-NTFSSymbolicLink -Path $link -Target $target -PassThru -ErrorAction Stop
$result | Should -BeOfType [Alphaleonis.Win32.Filesystem.FileInfo]
$result.FullName | Should -Be $link
}
# Before 5.0.0, the cmdlet returned a file object for a link to a folder.
It 'Should return a folder object for a link to a folder with -PassThru' -Skip:(-not $canCreateSymbolicLinks) {
$folder = New-TestSandboxItem -Sandbox $sandbox -Name 'SymbolicPassThruFolder' -Directory
$link = Join-Path -Path $sandbox -ChildPath 'SymbolicPassThruFolderLink'
Assert-TestSandboxPath -Sandbox $sandbox -Path $link
$result = New-NTFSSymbolicLink -Path $link -Target $folder -PassThru -ErrorAction Stop
$result | Should -BeOfType [Alphaleonis.Win32.Filesystem.DirectoryInfo]
$result.FullName | Should -Be $link
}
It 'Should store the absolute path of a relative target' -Skip:(-not $canCreateSymbolicLinks) {
$target = New-TestSandboxItem -Sandbox $sandbox -Name 'SymbolicRelative'
$link = Join-Path -Path $sandbox -ChildPath 'SymbolicRelative.txt'
Assert-TestSandboxPath -Sandbox $sandbox -Path $link
New-NTFSSymbolicLink -Path 'SymbolicRelative.txt' -Target (Split-Path -Path $target -Leaf) -ErrorAction Stop
Get-LinkTarget -Path $link | Should -Be $target
}
It 'Should refuse an existing -Path and leave it unchanged' -Skip:(-not $canCreateSymbolicLinks) {
$target = New-TestSandboxItem -Sandbox $sandbox -Name 'SymbolicExistingTarget'
$existing = New-TestSandboxItem -Sandbox $sandbox -Name 'SymbolicExisting'
Set-Content -LiteralPath $existing -Value 'Existing'
{ New-NTFSSymbolicLink -Path $existing -Target $target -ErrorAction Stop } | Should -Throw -ExpectedMessage '*already exist*'
(Get-Item -LiteralPath $existing -Force).LinkType | Should -BeNullOrEmpty
Get-Content -LiteralPath $existing | Should -Be 'Existing'
}
It 'Should write an error for a target that does not exist and create no link' {
$missing = Join-Path -Path $sandbox -ChildPath 'SymbolicMissing.txt'
$link = Join-Path -Path $sandbox -ChildPath 'SymbolicMissingLink.txt'
Assert-TestSandboxPath -Sandbox $sandbox -Path $missing, $link
New-NTFSSymbolicLink -Path $link -Target $missing -ErrorVariable linkErrors -ErrorAction SilentlyContinue
$linkErrors | Should -HaveCount 1
$linkErrors[0].FullyQualifiedErrorId | Should -BeLike 'CreateSymbolicLinkError,*'
Test-Path2 -Path $link | Should -BeFalse
}
# Windows rejects the link with error 1314 without the "Create symbolic links" user right. Its message is
# localized, so the test compares the HRESULT of that error.
It 'Should fail and create no link without the right to create symbolic links' -Skip:$canCreateSymbolicLinks {
$target = New-TestSandboxItem -Sandbox $sandbox -Name 'SymbolicNoRight'
$link = Join-Path -Path $sandbox -ChildPath 'SymbolicNoRight.txt'
Assert-TestSandboxPath -Sandbox $sandbox -Path $link
$thrown = { New-NTFSSymbolicLink -Path $link -Target $target -ErrorAction Stop } | Should -Throw -PassThru
$thrown.FullyQualifiedErrorId | Should -BeLike '*,NTFSSecurity.NewSymbolicLink'
'0x{0:X8}' -f $thrown.Exception.HResult | Should -Be '0x80070522'
Test-Path2 -Path $link | Should -BeFalse
}
}

22
Tests/OutputTypes.Tests.ps1

@ -56,20 +56,36 @@ Describe 'Declared output types' {
$result = Get-FileHash2 -Path $file
$result.PSObject.TypeNames[0] | Should -Be @((Get-Command -Name Get-FileHash2).OutputType.Name)[0]
$result.PSObject.TypeNames[0] | Should -BeExactly @((Get-Command -Name Get-FileHash2).OutputType.Name)[0]
}
}
Describe 'Privilege cmdlets with -PassThru' {
BeforeAll {
# The tests enable and disable the privileges of the test process; AfterAll restores the states they had (#110).
$fileSystemPrivileges = 'TakeOwnership', 'Restore', 'Backup', 'Security'
$enabledBefore = @(Get-Privileges | Where-Object -FilterScript {
$_.Privilege.ToString() -in $fileSystemPrivileges -and $_.PrivilegeState -eq 'Enabled'
} | ForEach-Object -Process { $_.Privilege })
}
AfterEach {
Disable-Privileges -ErrorAction SilentlyContinue -WarningAction SilentlyContinue
}
# Before 5.0.0, -PassThru wrote the privileges as one collection.
AfterAll {
$process = [System.Diagnostics.Process]::GetCurrentProcess()
foreach ($privilege in $enabledBefore) {
$null = [ProcessPrivileges.ProcessExtensions]::EnablePrivilege($process, $privilege)
}
}
# Before 5.0.0, -PassThru wrote the privileges as one collection. The access token of a basic user holds one
# privilege only, so the test compares with the privileges that the token holds.
It 'Enable-Privileges should write one object per privilege' {
$result = @(Enable-Privileges -PassThru -ErrorAction SilentlyContinue)
$result.Count | Should -BeGreaterThan 1
$result | Should -HaveCount @(Get-Privileges).Count
$result | ForEach-Object -Process { $_ | Should -BeOfType [ProcessPrivileges.PrivilegeAndAttributes] }
}

177
Tests/Owner.Tests.ps1

@ -13,6 +13,8 @@ BeforeDiscovery {
Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force
# With the Backup privilege, Windows may grant reading the owner despite a deny entry.
$canBypassDeny = Test-PrivilegeHeld -Name 'SeBackupPrivilege'
# Assigning an owner other than the user or one of its groups needs the Restore privilege.
$canAssignAnyOwner = Test-PrivilegeHeld -Name 'SeRestorePrivilege'
}
BeforeAll {
@ -97,12 +99,71 @@ Describe 'Current location' {
Should -Not -Throw
}
# The error for the folder is non-terminating since 5.0.0-rc6, so -ErrorAction Stop turns it into the exception.
It 'Get-NTFSHardLink should report the folder of the current location, not a NullReferenceException' {
{ Invoke-WithShadowedPwd -Command { Get-NTFSHardLink -ErrorAction SilentlyContinue } } |
{ Invoke-WithShadowedPwd -Command { Get-NTFSHardLink -ErrorAction Stop } } |
Should -Throw -ExpectedMessage '*must be a file*'
}
}
# Before 5.0.0-rc6, a relative path that started with a dot, but not with .\, lost its first two characters: a command
# on .Dotfile read or changed the item otfile in the same folder when one existed.
Describe 'Relative paths that start with a dot' {
BeforeAll {
$dotFolder = New-TestSandboxItem -Sandbox $sandbox -Name 'Dot' -Directory
Push-Location -LiteralPath $dotFolder
# The names that the defect made of the paths
foreach ($name in '.Dotfile', '..Dotfile', 'otfile', 'Dotfile') {
Assert-TestSandboxPath -Sandbox $sandbox -Path $name
Set-Content -LiteralPath $name -Value $name
}
}
AfterAll {
Pop-Location
}
It 'Should resolve <Path> to the item <Expected> of the current location' -ForEach @(
@{ Path = '.Dotfile'; Expected = '.Dotfile' }
@{ Path = '..Dotfile'; Expected = '..Dotfile' }
@{ Path = '.\.Dotfile'; Expected = '.Dotfile' }
@{ Path = './.Dotfile'; Expected = '.Dotfile' }
@{ Path = '..\{0}\.Dotfile'; Expected = '.Dotfile' }
) {
# {0} is the name of the current folder.
$relative = $Path -f (Split-Path -Path $dotFolder -Leaf)
$result = Get-NTFSOwner -Path $relative -ErrorAction Stop
$result.FullName | Should -Be (Join-Path -Path $dotFolder -ChildPath $Expected)
}
It 'Remove-Item2 should remove the item that the path names, not another item' {
foreach ($name in '.RemoveMe', 'emoveMe') {
Assert-TestSandboxPath -Sandbox $sandbox -Path $name
Set-Content -LiteralPath $name -Value $name
}
Remove-Item2 -Path '.RemoveMe' -ErrorAction Stop
Join-Path -Path $dotFolder -ChildPath '.RemoveMe' | Should -Not -Exist
Join-Path -Path $dotFolder -ChildPath 'emoveMe' | Should -Exist
}
It 'Copy-Item2 should copy to the destination that the path names, not over another item' {
foreach ($name in 'CopySource', 'opyTarget') {
Assert-TestSandboxPath -Sandbox $sandbox -Path $name
Set-Content -LiteralPath $name -Value $name
}
Assert-TestSandboxPath -Sandbox $sandbox -Path '.CopyTarget'
Copy-Item2 -Path 'CopySource' -Destination '.CopyTarget' -Force -ErrorAction Stop
Get-Content -LiteralPath (Join-Path -Path $dotFolder -ChildPath '.CopyTarget') | Should -Be 'CopySource'
Get-Content -LiteralPath (Join-Path -Path $dotFolder -ChildPath 'opyTarget') | Should -Be 'opyTarget'
}
}
Describe 'File and folder objects as arguments' {
# Before 5.0.0, Windows PowerShell bound a folder object that was passed by position as its name, which the
# cmdlets resolved against the current location (#88).
@ -126,3 +187,117 @@ Describe 'File and folder objects as arguments' {
$result.FullName | Should -Be $file
}
}
Describe 'Set-NTFSOwner' {
BeforeAll {
$sidType = [System.Security.Principal.SecurityIdentifier]
$currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
# An owner that the user can assign only with the Restore privilege
$trustedInstaller = 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464'
$privateData = (Get-Module -Name NTFSSecurity).PrivateData
$enablePrivileges = $privateData['EnablePrivileges']
function Get-TestOwner {
param ([string] $Path)
(Get-Acl -LiteralPath $Path).GetOwner($sidType).Value
}
}
AfterAll {
$privateData['EnablePrivileges'] = $enablePrivileges
}
It 'Should make the account the owner and write nothing without -PassThru' {
$file = New-TestSandboxItem -Sandbox $sandbox -Name 'SetOwner'
$result = @(Set-NTFSOwner -Path $file -Account $currentUser -ErrorAction Stop)
$result | Should -BeNullOrEmpty
Get-TestOwner -Path $file | Should -Be $currentUser
}
It 'Should return the new owner of a folder with -PassThru' {
$folder = New-TestSandboxItem -Sandbox $sandbox -Name 'SetOwnerFolder' -Directory
$result = @(Set-NTFSOwner -Path $folder -Account $currentUser -PassThru -ErrorAction Stop)
$result | Should -HaveCount 1
$result[0] | Should -BeOfType [Security2.FileSystemOwner]
$result[0].FullName | Should -Be $folder
$result[0].Owner.Sid | Should -Be $currentUser
Get-TestOwner -Path $folder | Should -Be $currentUser
}
It 'Should take the items from the pipeline' {
$files = 1..2 | ForEach-Object -Process { New-TestSandboxItem -Sandbox $sandbox -Name "SetOwnerPiped$_" }
$result = @(Get-Item2 -Path $files | Set-NTFSOwner -Account $currentUser -PassThru -ErrorAction Stop)
($result.FullName -join '|') | Should -Be ($files -join '|')
}
It 'Should set an owner that only the Restore privilege allows' -Skip:(-not $canAssignAnyOwner) {
$file = New-TestSandboxItem -Sandbox $sandbox -Name 'SetOwnerRestore'
Set-NTFSOwner -Path $file -Account $trustedInstaller -ErrorAction Stop
Get-TestOwner -Path $file | Should -Be $trustedInstaller
}
It 'Should write a read error for a path that does not exist and continue with the next path' {
$missing = Join-Path -Path $sandbox -ChildPath 'SetOwnerMissing.txt'
$file = New-TestSandboxItem -Sandbox $sandbox -Name 'SetOwnerAfterMissing'
Set-NTFSOwner -Path $missing, $file -Account $currentUser -ErrorVariable ownerErrors -ErrorAction SilentlyContinue
$ownerErrors | Should -HaveCount 1
$ownerErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadFileError,*'
Get-TestOwner -Path $file | Should -Be $currentUser
}
Context 'When Windows refuses the owner' {
BeforeAll {
$privateData['EnablePrivileges'] = $false
}
AfterAll {
$privateData['EnablePrivileges'] = $enablePrivileges
}
# Without the Restore privilege, Windows refuses an owner other than the user or one of its groups: (1307) This
# security ID may not be assigned as the owner of this object.
It 'Should write a SetOwnerError and keep the owner' {
$file = New-TestSandboxItem -Sandbox $sandbox -Name 'SetOwnerRefused'
$owner = Get-TestOwner -Path $file
(Get-Privileges | Where-Object -Property Privilege -EQ -Value 'Restore').PrivilegeState | Should -Not -Be 'Enabled'
Set-NTFSOwner -Path $file -Account $trustedInstaller -ErrorVariable ownerErrors -ErrorAction SilentlyContinue
$ownerErrors | Should -HaveCount 1
$ownerErrors[0].FullyQualifiedErrorId | Should -BeLike 'SetOwnerError,*'
Get-TestOwner -Path $file | Should -Be $owner
}
}
Context 'With -SecurityDescriptor' {
It 'Should change only the descriptor in memory until Set-NTFSSecurityDescriptor writes it' {
$file = New-TestSandboxItem -Sandbox $sandbox -Name 'SetOwnerDescriptor'
$owner = Get-TestOwner -Path $file
if ($owner -eq $currentUser) {
# Only an elevated session creates items that the Administrators group owns.
Set-ItResult -Skipped -Because 'the user owns new items, and no other owner can be set without privileges'
return
}
$sd = Get-NTFSSecurityDescriptor -Path $file
$result = @(Set-NTFSOwner -SecurityDescriptor $sd -Account $currentUser -PassThru -ErrorAction Stop)
$result | Should -HaveCount 1
$result[0].Owner.Sid | Should -Be $currentUser
Get-TestOwner -Path $file | Should -Be $owner
Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -ErrorAction Stop
Get-TestOwner -Path $file | Should -Be $currentUser
}
}
}

232
Tests/PathErrors.Tests.ps1

@ -0,0 +1,232 @@
<#
Tests the error handling that the cmdlets with -Path share, with the module built in NTFSSecurity\bin\Release on
files in a sandbox folder: a path that doesn't exist, an item whose owner may not read its permissions, and an item
whose owner may not change its permissions, which the cmdlets that write the DACL handle by taking ownership. Each
error belongs to its path only, and the cmdlet continues with the next one. The tests turn the module setting
EnablePrivileges off, so that an elevated session meets the same denials as a basic user, and restore it.
#>
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
'PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Pester shares variables between blocks.'
)]
param ()
BeforeDiscovery {
Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force
$holdsSecurityPrivilege = Test-PrivilegeHeld -Name 'SeSecurityPrivilege'
$currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
$readEntry = @{ Account = 'S-1-1-0'; AccessRights = 'ReadData' }
# An audit entry on a file has no inheritance flags.
$auditEntry = @{ Account = 'S-1-1-0'; AccessRights = 'ReadData'; InheritanceFlags = 'None'; PropagationFlags = 'None' }
# Output: whether the cmdlet writes an object for the next path, an existing file.
$missingPathCases = @(
@{ Command = 'Get-NTFSAccess'; Parameters = @{}; ErrorId = 'ReadFileError'; Output = $true }
@{ Command = 'Add-NTFSAccess'; Parameters = $readEntry; ErrorId = 'ReadFileError'; Output = $false }
@{ Command = 'Remove-NTFSAccess'; Parameters = $readEntry; ErrorId = 'ReadFileError'; Output = $false }
@{ Command = 'Clear-NTFSAccess'; Parameters = @{}; ErrorId = 'ReadFileError'; Output = $false }
@{ Command = 'Get-NTFSEffectiveAccess'; Parameters = @{ Account = 'S-1-1-0' }; ErrorId = 'ReadFileError'; Output = $true }
@{ Command = 'Get-NTFSOrphanedAccess'; Parameters = @{}; ErrorId = 'ReadFileError'; Output = $false }
@{ Command = 'Get-NTFSInheritance'; Parameters = @{}; ErrorId = 'ReadFileError'; Output = $true }
@{ Command = 'Enable-NTFSAccessInheritance'; Parameters = @{}; ErrorId = 'ReadFileError'; Output = $false }
@{ Command = 'Disable-NTFSAccessInheritance'; Parameters = @{}; ErrorId = 'ReadFileError'; Output = $false }
@{ Command = 'Set-NTFSInheritance'; Parameters = @{ AccessInheritanceEnabled = $true }; ErrorId = 'ReadFileError'; Output = $false }
@{ Command = 'Get-NTFSOwner'; Parameters = @{}; ErrorId = 'ReadFileError'; Output = $true }
@{ Command = 'Set-NTFSOwner'; Parameters = @{ Account = $currentUser }; ErrorId = 'ReadFileError'; Output = $false }
@{ Command = 'Get-NTFSSecurityDescriptor'; Parameters = @{}; ErrorId = 'ReadFileError'; Output = $true }
@{ Command = 'Get-Item2'; Parameters = @{}; ErrorId = 'FileNotFound'; Output = $true }
@{ Command = 'Get-FileHash2'; Parameters = @{}; ErrorId = 'ReadFileError'; Output = $true }
@{ Command = 'Get-NTFSHardLink'; Parameters = @{}; ErrorId = 'FileNotFound'; Output = $true }
)
# The audit cmdlets need the Security privilege for the next path.
$missingPathAuditCases = @(
@{ Command = 'Get-NTFSAudit'; Parameters = @{} }
@{ Command = 'Add-NTFSAudit'; Parameters = $auditEntry }
@{ Command = 'Remove-NTFSAudit'; Parameters = $auditEntry }
@{ Command = 'Clear-NTFSAudit'; Parameters = @{} }
@{ Command = 'Enable-NTFSAuditInheritance'; Parameters = @{} }
@{ Command = 'Disable-NTFSAuditInheritance'; Parameters = @{} }
)
$deniedReadCases = @(
@{ Command = 'Get-NTFSAccess'; Parameters = @{}; Output = $true }
@{ Command = 'Get-NTFSEffectiveAccess'; Parameters = @{ Account = 'S-1-1-0' }; Output = $true }
@{ Command = 'Get-NTFSOrphanedAccess'; Parameters = @{}; Output = $false }
@{ Command = 'Get-NTFSInheritance'; Parameters = @{}; Output = $true }
@{ Command = 'Get-NTFSOwner'; Parameters = @{}; Output = $true }
@{ Command = 'Get-NTFSSecurityDescriptor'; Parameters = @{}; Output = $true }
)
}
BeforeAll {
Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force
$modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\NTFSSecurity\bin\Release\NTFSSecurity.psd1'
Import-Module -Name $modulePath -Force -ErrorAction Stop
$sandbox = New-TestSandbox -Name 'PathErrors'
Push-Location -LiteralPath $sandbox
$privateData = (Get-Module -Name NTFSSecurity).PrivateData
$enablePrivileges = $privateData['EnablePrivileges']
$privateData['EnablePrivileges'] = $false
$sidType = [System.Security.Principal.SecurityIdentifier]
function Get-TestMissingPath {
$path = Join-Path -Path $sandbox -ChildPath ('Missing-{0}' -f [guid]::NewGuid().ToString('N').Substring(0, 8))
Assert-TestSandboxPath -Sandbox $sandbox -Path $path
$path
}
function Get-TestAcl {
# .NET, because Get-Acl of an elevated Windows PowerShell reads also items that deny reading their permissions.
# .NET Core has the method as an extension method.
param ([string] $Path)
$info = New-Object -TypeName 'System.IO.FileInfo' -ArgumentList $Path
if ($PSVersionTable.PSEdition -eq 'Desktop') {
$info.GetAccessControl()
}
else {
[System.IO.FileSystemAclExtensions]::GetAccessControl($info)
}
}
}
AfterAll {
$privateData['EnablePrivileges'] = $enablePrivileges
Pop-Location
Remove-TestSandbox -Sandbox $sandbox
Remove-Module -Name NTFSSecurity -Force -ErrorAction SilentlyContinue
}
Describe 'A path that does not exist' {
It '<Command> should write a <ErrorId> for it and continue with the next path' -ForEach $missingPathCases {
$missing = Get-TestMissingPath
$file = New-TestSandboxItem -Sandbox $sandbox -Name 'Next'
$output = @(& $Command -Path $missing, $file @Parameters -ErrorVariable pathErrors -ErrorAction SilentlyContinue -WarningAction SilentlyContinue)
$pathErrors | Should -HaveCount 1
$pathErrors[0].FullyQualifiedErrorId | Should -BeLike "$ErrorId,*"
$pathErrors[0].TargetObject | Should -Be $missing
if ($Output) {
$output | Should -Not -BeNullOrEmpty
}
}
It '<Command> should write a ReadFileError for it and continue with the next path' -ForEach $missingPathAuditCases -Skip:(-not $holdsSecurityPrivilege) {
$privateData['EnablePrivileges'] = $true
try {
$missing = Get-TestMissingPath
$file = New-TestSandboxItem -Sandbox $sandbox -Name 'NextAudit'
& $Command -Path $missing, $file @Parameters -ErrorVariable pathErrors -ErrorAction SilentlyContinue | Out-Null
}
finally {
$privateData['EnablePrivileges'] = $false
}
$pathErrors | Should -HaveCount 1
$pathErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadFileError,*'
$pathErrors[0].TargetObject | Should -Be $missing
}
}
Describe 'An item whose owner may not read its permissions' {
# A deny entry for OWNER RIGHTS replaces the right of the owner to read the security descriptor. Taking ownership
# can't help: the cmdlet must read the owner first, which needs the same right. Before 5.0.0-rc6,
# Get-NTFSOrphanedAccess reported this as an AddAceError.
It '<Command> should write a ReadSecurityError for it and continue with the next path' -ForEach $deniedReadCases {
$blocked = New-TestSandboxItem -Sandbox $sandbox -Name 'Unreadable'
Block-TestReadPermission -Sandbox $sandbox -Path $blocked
$file = New-TestSandboxItem -Sandbox $sandbox -Name 'NextReadable'
$output = @(& $Command -Path $blocked, $file @Parameters -ErrorVariable pathErrors -ErrorAction SilentlyContinue -WarningAction SilentlyContinue)
$pathErrors | Should -HaveCount 1
$pathErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadSecurityError,*'
$pathErrors[0].TargetObject | Should -Be $blocked
if ($Output) {
$output | Should -Not -BeNullOrEmpty
}
}
}
Describe 'An item whose owner may not change its permissions' {
# A deny entry for OWNER RIGHTS replaces the right of the owner to change the DACL. The cmdlets take ownership,
# which Windows answers by removing the OWNER RIGHTS entries, write the DACL, and set the previous owner back.
BeforeEach {
$file = New-TestSandboxItem -Sandbox $sandbox -Name 'Unchangeable'
$acl = Get-TestAcl -Path $file
$owner = $acl.GetOwner($sidType).Value
}
It 'Add-NTFSAccess should take ownership, add the entry, and set the owner back' {
Add-TestDenyRule -Sandbox $sandbox -Path $file -Rights @{ 'S-1-3-4' = 'ChangePermissions' }
Add-NTFSAccess -Path $file -Account 'S-1-1-0' -AccessRights ReadData -ErrorVariable changeErrors -ErrorAction SilentlyContinue
$changeErrors | Should -BeNullOrEmpty
$acl = Get-TestAcl -Path $file
$acl.GetOwner($sidType).Value | Should -Be $owner
@($acl.GetAccessRules($true, $false, $sidType) | Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-1-0' }) | Should -HaveCount 1
@($acl.GetAccessRules($true, $false, $sidType) | Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-3-4' }) | Should -BeNullOrEmpty
}
It 'Remove-NTFSAccess should take ownership, remove the entry, and set the owner back' {
Add-NTFSAccess -Path $file -Account 'S-1-1-0' -AccessRights ReadData
Add-TestDenyRule -Sandbox $sandbox -Path $file -Rights @{ 'S-1-3-4' = 'ChangePermissions' }
Remove-NTFSAccess -Path $file -Account 'S-1-1-0' -AccessRights ReadData -ErrorVariable changeErrors -ErrorAction SilentlyContinue
$changeErrors | Should -BeNullOrEmpty
$acl = Get-TestAcl -Path $file
$acl.GetOwner($sidType).Value | Should -Be $owner
@($acl.GetAccessRules($true, $false, $sidType) | Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-1-0' }) | Should -BeNullOrEmpty
}
It 'Clear-NTFSAccess should take ownership, remove the explicit entries, and set the owner back' {
Add-NTFSAccess -Path $file -Account 'S-1-1-0' -AccessRights ReadData
Add-TestDenyRule -Sandbox $sandbox -Path $file -Rights @{ 'S-1-3-4' = 'ChangePermissions' }
Clear-NTFSAccess -Path $file -ErrorVariable changeErrors -ErrorAction SilentlyContinue
$changeErrors | Should -BeNullOrEmpty
$acl = Get-TestAcl -Path $file
$acl.GetOwner($sidType).Value | Should -Be $owner
@($acl.GetAccessRules($true, $false, $sidType)) | Should -BeNullOrEmpty
}
It 'Disable-NTFSAccessInheritance should take ownership, protect the DACL, and set the owner back' {
Add-TestDenyRule -Sandbox $sandbox -Path $file -Rights @{ 'S-1-3-4' = 'ChangePermissions' }
Disable-NTFSAccessInheritance -Path $file -ErrorVariable changeErrors -ErrorAction SilentlyContinue
$changeErrors | Should -BeNullOrEmpty
$acl = Get-TestAcl -Path $file
$acl.GetOwner($sidType).Value | Should -Be $owner
$acl.AreAccessRulesProtected | Should -BeTrue
}
It 'Enable-NTFSAccessInheritance should take ownership, let the DACL inherit, and set the owner back' {
Disable-NTFSAccessInheritance -Path $file
Add-TestDenyRule -Sandbox $sandbox -Path $file -Rights @{ 'S-1-3-4' = 'ChangePermissions' }
Enable-NTFSAccessInheritance -Path $file -ErrorVariable changeErrors -ErrorAction SilentlyContinue
$changeErrors | Should -BeNullOrEmpty
$acl = Get-TestAcl -Path $file
$acl.GetOwner($sidType).Value | Should -Be $owner
$acl.AreAccessRulesProtected | Should -BeFalse
}
It 'Set-NTFSInheritance should take ownership, protect the DACL, and set the owner back' {
Add-TestDenyRule -Sandbox $sandbox -Path $file -Rights @{ 'S-1-3-4' = 'ChangePermissions' }
Set-NTFSInheritance -Path $file -AccessInheritanceEnabled $false -ErrorVariable changeErrors -ErrorAction SilentlyContinue
$changeErrors | Should -BeNullOrEmpty
$acl = Get-TestAcl -Path $file
$acl.GetOwner($sidType).Value | Should -Be $owner
$acl.AreAccessRulesProtected | Should -BeTrue
}
}

143
Tests/Privileges.Tests.ps1

@ -29,6 +29,13 @@ BeforeAll {
function Get-BackupPrivilegeState {
(Get-Privileges | Where-Object -Property Privilege -EQ -Value 'Backup').PrivilegeState
}
function Get-EnabledFileSystemPrivilege {
# The names of the privileges that the cmdlets enable, as far as they are enabled now
@(Get-Privileges | Where-Object -FilterScript {
$_.Privilege -in 'TakeOwnership', 'Restore', 'Backup', 'Security' -and $_.PrivilegeState -eq 'Enabled'
} | ForEach-Object -Process { $_.Privilege.ToString() })
}
}
AfterAll {
@ -119,3 +126,139 @@ Describe 'Inheritance cmdlets' {
}
}
}
Describe 'Privileges when the pipeline stops early' {
BeforeAll {
# The cmdlets enable the privileges only with this setting; without it, these tests would prove nothing.
$privateData['EnablePrivileges'] = $true
$files = 1..3 | ForEach-Object -Process { New-TestSandboxItem -Sandbox $sandbox -Name "Stopped$_" }
$missing = Join-Path -Path $sandbox -ChildPath 'StoppedMissing.txt'
}
AfterAll {
$privateData['EnablePrivileges'] = $enablePrivileges
}
BeforeEach {
Disable-Privileges -ErrorAction SilentlyContinue -WarningAction SilentlyContinue
}
AfterEach {
# A failing test must not leave the privileges enabled for the tests that follow.
Disable-Privileges -ErrorAction SilentlyContinue -WarningAction SilentlyContinue
}
# Before 5.0.0-rc6, a cmdlet disabled the privileges that it had enabled only in EndProcessing, which PowerShell
# skips when a later command or a terminating error stops the pipeline. The Backup, Restore, Take Ownership, and
# Security privileges then stayed enabled in the session.
It 'Should disable the privileges after Select-Object -First stops the pipeline' -Skip:(-not $holdsPrivileges) {
Get-BackupPrivilegeState | Should -Be 'Disabled'
$stateWhileRunning = Get-NTFSOwner -Path $files | ForEach-Object -Process { Get-BackupPrivilegeState } |
Select-Object -First 1
$stateWhileRunning | Should -Be 'Enabled'
Get-BackupPrivilegeState | Should -Be 'Disabled'
}
It 'Should disable the privileges after a terminating error' -Skip:(-not $holdsPrivileges) {
Get-BackupPrivilegeState | Should -Be 'Disabled'
$statesWhileRunning = New-Object -TypeName 'System.Collections.Generic.List[string]'
{
Get-NTFSAccess -Path $files[0], $missing -ErrorAction Stop |
ForEach-Object -Process { $statesWhileRunning.Add((Get-BackupPrivilegeState)) }
} | Should -Throw
$statesWhileRunning | Should -Not -BeNullOrEmpty
$statesWhileRunning | Should -Not -Contain 'Disabled'
Get-BackupPrivilegeState | Should -Be 'Disabled'
}
It 'Enable-Privileges should keep the privileges enabled also when the pipeline stops early' -Skip:(-not $holdsPrivileges) {
Enable-Privileges -PassThru | Select-Object -First 1 | Out-Null
Get-BackupPrivilegeState | Should -Be 'Enabled'
}
}
Describe 'Privileges that another command in the pipeline changes' {
BeforeAll {
$privateData['EnablePrivileges'] = $true
$files = 1..3 | ForEach-Object -Process { New-TestSandboxItem -Sandbox $sandbox -Name "Changed$_" }
function Disable-TakeOwnershipOnce {
# Passes the objects on and disables the Take Ownership privilege when the first one passes, as another
# command in the pipeline can. Records the state of the Backup privilege at that moment.
param (
[Parameter(ValueFromPipeline)]
[object]
$InputObject,
[Parameter(Mandatory)]
[AllowEmptyCollection()]
[System.Collections.Generic.List[string]]
$BackupState
)
begin {
$first = $true
}
process {
if ($first) {
$BackupState.Add((Get-BackupPrivilegeState))
$null = [ProcessPrivileges.ProcessExtensions]::DisablePrivilege(
[System.Diagnostics.Process]::GetCurrentProcess(), [ProcessPrivileges.Privilege]::TakeOwnership
)
$first = $false
}
$InputObject
}
}
}
AfterAll {
$privateData['EnablePrivileges'] = $enablePrivileges
}
BeforeEach {
Disable-Privileges -ErrorAction SilentlyContinue -WarningAction SilentlyContinue
}
AfterEach {
Disable-Privileges -ErrorAction SilentlyContinue -WarningAction SilentlyContinue
}
# Before 5.0.0-rc6, a cmdlet decided which privileges to disable on the states that it had read when it enabled
# them. A privilege that another command had disabled since then stopped it with "Priviledge already disabled",
# and the privileges after that one in its list stayed enabled.
It 'Should disable the other privileges when another command disabled one of them' -Skip:(-not $holdsPrivileges) {
$backupState = New-Object -TypeName 'System.Collections.Generic.List[string]'
{ Get-NTFSOwner -Path $files | Disable-TakeOwnershipOnce -BackupState $backupState | Out-Null } | Should -Not -Throw
$backupState | Should -Be 'Enabled'
Get-EnabledFileSystemPrivilege | Should -BeNullOrEmpty
}
It 'Should disable the other privileges when another command disabled one of them and the pipeline stops early' -Skip:(-not $holdsPrivileges) {
$backupState = New-Object -TypeName 'System.Collections.Generic.List[string]'
Get-NTFSOwner -Path $files | Disable-TakeOwnershipOnce -BackupState $backupState | Select-Object -First 1 | Out-Null
$backupState | Should -Be 'Enabled'
Get-EnabledFileSystemPrivilege | Should -BeNullOrEmpty
}
It 'Should not fail when Disable-Privileges runs inside the pipeline' -Skip:(-not $holdsPrivileges) {
{
Get-NTFSOwner -Path $files |
ForEach-Object -Process { Disable-Privileges -WarningAction SilentlyContinue; $_ } |
Out-Null
} | Should -Not -Throw
Get-EnabledFileSystemPrivilege | Should -BeNullOrEmpty
}
}

33
Tests/Repository.Tests.ps1

@ -97,7 +97,7 @@ Describe 'Release metadata' {
# The PowerShell Gallery doesn't accept a version twice. Add every published version to this list
# (Docs/Contributing/05-Releasing.md).
It 'Should not reuse a version that the PowerShell Gallery already has' {
$publishedVersions = '4.0', '4.2.2', '4.2.3', '4.2.4', '4.2.5', '4.2.6', '5.0.0-rc1', '5.0.0-rc2', '5.0.0-rc3', '5.0.0-rc4'
$publishedVersions = '4.0', '4.2.2', '4.2.3', '4.2.4', '4.2.5', '4.2.6', '5.0.0-rc1', '5.0.0-rc2', '5.0.0-rc3', '5.0.0-rc4', '5.0.0-rc5'
$publishedVersions | Should -Not -Contain $version
}
@ -107,3 +107,34 @@ Describe 'Release metadata' {
Should -Not -Match '\d+\.\d+\.\d+-[A-Za-z]'
}
}
Describe 'Invoke-TestsAsBasicUser.ps1' {
BeforeAll {
# Only the parameters of the script, so that a test binds them without running the tests as a basic user
$path = Join-Path -Path $PSScriptRoot -ChildPath '..\.github\scripts\Invoke-TestsAsBasicUser.ps1'
$tokens = $parseErrors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile($path, [ref] $tokens, [ref] $parseErrors)
$bindParameters = [scriptblock]::Create($ast.ParamBlock.Extent.Text)
}
# The title goes into a quoted argument of cmd.exe, which expands environment variables also inside quotes and ends
# the command at a line break.
It 'Should refuse a title with the character <Name>, which would change the command line of cmd.exe' -ForEach @(
@{ Name = '%'; Character = '%' }
@{ Name = 'double quote'; Character = '"' }
@{ Name = 'line feed'; Character = "`n" }
@{ Name = 'carriage return'; Character = "`r" }
) {
{ & $bindParameters -ResultPath 'TestResults\Refused.xml' -Title "CI run $Character 1" } |
Should -Throw -ExpectedMessage "*'Title'*"
}
It 'Should refuse a title that ends with a line break' {
{ & $bindParameters -ResultPath 'TestResults\Refused.xml' -Title "CI run`n" } |
Should -Throw -ExpectedMessage "*'Title'*"
}
It 'Should accept the title <_> of the CI workflow' -ForEach @('Windows PowerShell 5.1 as a basic user', 'PowerShell 7 as a basic user') {
{ & $bindParameters -ResultPath 'TestResults\Accepted.xml' -Title $_ } | Should -Not -Throw
}
}

98
Tests/SecurityDescriptor.Tests.ps1

@ -208,6 +208,65 @@ Describe 'Set-NTFSSecurityDescriptor' {
$setErrors | Should -BeNullOrEmpty
(Get-Acl -LiteralPath $file).GetOwner($sidType).Value | Should -Be 'S-1-5-32-544'
}
# Before 5.0.0-rc6, the cmdlet wrote no object with -PassThru when it had to take ownership for the write.
It 'Should return the written descriptor with -PassThru also when it took ownership for the write' -Skip:(-not $canAssignAnyOwner) {
$file = New-TestSandboxItem -Sandbox $sandbox -Name 'RetryPassThru'
Add-TestDenyRule -Sandbox $sandbox -Path $file -Rights @{ $currentUser = 'ChangePermissions' }
Set-TestOwner -Sandbox $sandbox -Path $file -Sid $trustedInstaller
$sd = Get-NTFSSecurityDescriptor -Path $file
Add-NTFSAccess -SecurityDescriptor $sd -Account 'Everyone' -AccessRights ReadData
$sd.SecurityDescriptor.SetOwner((New-Object -TypeName 'System.Security.Principal.SecurityIdentifier' -ArgumentList 'S-1-5-32-544'))
$result = @(Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -PassThru -ErrorVariable setErrors -ErrorAction SilentlyContinue)
$setErrors | Should -BeNullOrEmpty
$result | Should -HaveCount 1
$result[0].FullName | Should -Be $file
$result[0].SecurityDescriptor.GetOwner($sidType).Value | Should -Be 'S-1-5-32-544'
}
}
Context 'When the written descriptor denies reading it again' {
BeforeAll {
$privateData['EnablePrivileges'] = $false
}
AfterAll {
$privateData['EnablePrivileges'] = $enablePrivileges
}
# Before 5.0.0-rc6, the cmdlet read the item again for -PassThru inside the block that retries a denied write,
# so that a denied read started an ownership retry and ended in a WriteSdError, although the write succeeded.
It 'Should write the descriptor and report a read error for -PassThru, not a write error' {
$file = New-TestSandboxItem -Sandbox $sandbox -Name 'PassThruDenied'
$sd = Get-NTFSSecurityDescriptor -Path $file
# A deny entry for OWNER RIGHTS replaces the right of the owner to read the security descriptor.
Add-NTFSAccess -SecurityDescriptor $sd -Account 'S-1-3-4' -AccessRights ReadPermissions -AccessType Deny -AppliesTo ThisFolderOnly
$result = @(Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -PassThru -ErrorVariable setErrors -ErrorAction SilentlyContinue)
$result | Should -BeNullOrEmpty
$setErrors | Should -HaveCount 1
$setErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadSecurityError,*'
# Get-Acl of an elevated Windows PowerShell still reads the item, so .NET checks that the entry was written;
# .NET Core has the method as an extension method.
$info = New-Object -TypeName 'System.IO.FileInfo' -ArgumentList $file
$denied = $null
try {
if ($PSVersionTable.PSEdition -eq 'Desktop') {
$null = $info.GetAccessControl()
}
else {
$null = [System.IO.FileSystemAclExtensions]::GetAccessControl($info)
}
}
catch {
$denied = $_.Exception.GetBaseException()
}
$denied | Should -BeOfType [System.UnauthorizedAccessException]
}
}
}
@ -234,3 +293,42 @@ Describe 'FileSystemSecurity2.Write with another item' {
(Get-Acl -LiteralPath $target).GetOwner($sidType).Value | Should -Be $targetOwner
}
}
# Before 5.0.0-rc6, comparing a descriptor threw an InvalidCastException, and its hash code a NullReferenceException,
# so -eq and a hashtable with the descriptor as key failed.
Describe 'Comparing security descriptors' {
It 'Should find a descriptor equal to itself and not to another one, and use it as a key' {
$file = New-TestSandboxItem -Sandbox $sandbox -Name 'Compare'
$sd = Get-NTFSSecurityDescriptor -Path $file
$other = Get-NTFSSecurityDescriptor -Path $file
$sd -eq $sd | Should -BeTrue
$sd -eq $other | Should -BeFalse
$sd.Equals('x') | Should -BeFalse
$table = @{}
$table[$sd] = 'first'
$table[$other] = 'second'
$table[$sd] | Should -Be 'first'
$table.Count | Should -Be 2
}
# Before 5.0.0-rc6, the conversion returned a field that was never set, so it gave $null.
It 'Should convert to the security object of .NET that it holds' {
$file = New-TestSandboxItem -Sandbox $sandbox -Name 'ConvertFile'
$folder = New-TestSandboxItem -Sandbox $sandbox -Name 'ConvertFolder' -Directory
$fileSd = Get-NTFSSecurityDescriptor -Path $file
$folderSd = Get-NTFSSecurityDescriptor -Path $folder
[object]::ReferenceEquals([System.Security.AccessControl.FileSecurity] $fileSd, $fileSd.SecurityDescriptor) | Should -BeTrue
[object]::ReferenceEquals([System.Security.AccessControl.DirectorySecurity] $folderSd, $folderSd.SecurityDescriptor) | Should -BeTrue
}
It 'Should be equal only to a descriptor of the module, in both directions' {
$file = New-TestSandboxItem -Sandbox $sandbox -Name 'Symmetric'
$sd = Get-NTFSSecurityDescriptor -Path $file
$raw = $sd.SecurityDescriptor
$sd.Equals($raw) | Should -BeFalse
$raw.Equals($sd) | Should -BeFalse
}
}

165
Tests/SecurityDescriptorSets.Tests.ps1

@ -0,0 +1,165 @@
<#
Tests the SecurityDescriptor parameter sets that no other test file covers, with the module built in
NTFSSecurity\bin\Release on files in a sandbox folder. The cmdlets change a descriptor of Get-NTFSSecurityDescriptor
in memory only, and the item changes when Set-NTFSSecurityDescriptor writes the descriptor; the cmdlets that read
return what their Path parameter set returns. Tests of audit entries need the Security privilege and skip without
it.
#>
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
'PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Pester shares variables between blocks.'
)]
param ()
BeforeDiscovery {
Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force
$holdsSecurityPrivilege = Test-PrivilegeHeld -Name 'SeSecurityPrivilege'
}
BeforeAll {
Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force
$modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\NTFSSecurity\bin\Release\NTFSSecurity.psd1'
Import-Module -Name $modulePath -Force -ErrorAction Stop
$sandbox = New-TestSandbox -Name 'SecurityDescriptorSets'
Push-Location -LiteralPath $sandbox
$sidType = [System.Security.Principal.SecurityIdentifier]
function Get-ExplicitAccessCount {
param ([System.Security.AccessControl.FileSystemSecurity] $Acl)
@($Acl.GetAccessRules($true, $false, $sidType)).Count
}
}
AfterAll {
Pop-Location
Remove-TestSandbox -Sandbox $sandbox
Remove-Module -Name NTFSSecurity -Force -ErrorAction SilentlyContinue
}
Describe 'Cmdlets that change a security descriptor in memory' {
It 'Clear-NTFSAccess should remove the explicit access entries of the descriptor' {
$file = New-TestSandboxItem -Sandbox $sandbox -Name 'ClearAccess'
Add-NTFSAccess -Path $file -Account 'S-1-1-0' -AccessRights ReadData
$sd = Get-NTFSSecurityDescriptor -Path $file
Clear-NTFSAccess -SecurityDescriptor $sd -ErrorAction Stop
Get-ExplicitAccessCount -Acl $sd.SecurityDescriptor | Should -Be 0
Get-ExplicitAccessCount -Acl (Get-Acl -LiteralPath $file) | Should -Be 1
Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -ErrorAction Stop
Get-ExplicitAccessCount -Acl (Get-Acl -LiteralPath $file) | Should -Be 0
}
# Like the Path parameter set, the cmdlet doesn't copy the inherited entries, so the DACL ends up empty.
It 'Clear-NTFSAccess -DisableInheritance should leave the descriptor with an empty, protected DACL' {
$file = New-TestSandboxItem -Sandbox $sandbox -Name 'ClearAccessProtected'
Add-NTFSAccess -Path $file -Account 'S-1-1-0' -AccessRights ReadData
$daclBefore = (Get-Acl -LiteralPath $file).GetSecurityDescriptorSddlForm('Access')
$sd = Get-NTFSSecurityDescriptor -Path $file
Clear-NTFSAccess -SecurityDescriptor $sd -DisableInheritance -ErrorAction Stop
$sd.SecurityDescriptor.AreAccessRulesProtected | Should -BeTrue
@($sd.SecurityDescriptor.GetAccessRules($true, $true, $sidType)) | Should -BeNullOrEmpty
(Get-Acl -LiteralPath $file).GetSecurityDescriptorSddlForm('Access') | Should -Be $daclBefore
Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -ErrorAction Stop
$acl = Get-Acl -LiteralPath $file
$acl.AreAccessRulesProtected | Should -BeTrue
@($acl.GetAccessRules($true, $true, $sidType)) | Should -BeNullOrEmpty
}
It 'Disable-NTFSAccessInheritance should protect the DACL of the descriptor and keep the inherited entries' {
$file = New-TestSandboxItem -Sandbox $sandbox -Name 'DisableAccess'
$inheritedCount = @((Get-Acl -LiteralPath $file).GetAccessRules($false, $true, $sidType)).Count
$inheritedCount | Should -BeGreaterThan 0
$sd = Get-NTFSSecurityDescriptor -Path $file
Disable-NTFSAccessInheritance -SecurityDescriptor $sd -ErrorAction Stop
$sd.SecurityDescriptor.AreAccessRulesProtected | Should -BeTrue
(Get-Acl -LiteralPath $file).AreAccessRulesProtected | Should -BeFalse
Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -ErrorAction Stop
$acl = Get-Acl -LiteralPath $file
$acl.AreAccessRulesProtected | Should -BeTrue
Get-ExplicitAccessCount -Acl $acl | Should -Be $inheritedCount
}
It 'Enable-NTFSAccessInheritance should let the DACL of the descriptor inherit' {
$file = New-TestSandboxItem -Sandbox $sandbox -Name 'EnableAccess'
Disable-NTFSAccessInheritance -Path $file -RemoveInheritedAccessRules
$sd = Get-NTFSSecurityDescriptor -Path $file
Enable-NTFSAccessInheritance -SecurityDescriptor $sd -ErrorAction Stop
$sd.SecurityDescriptor.AreAccessRulesProtected | Should -BeFalse
(Get-Acl -LiteralPath $file).AreAccessRulesProtected | Should -BeTrue
Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -ErrorAction Stop
$acl = Get-Acl -LiteralPath $file
$acl.AreAccessRulesProtected | Should -BeFalse
@($acl.GetAccessRules($false, $true, $sidType)) | Should -Not -BeNullOrEmpty
}
It 'Clear-NTFSAudit should remove the explicit audit entries of the descriptor' -Skip:(-not $holdsSecurityPrivilege) {
$file = New-TestSandboxItem -Sandbox $sandbox -Name 'ClearAudit'
Add-NTFSAudit -Path $file -Account 'S-1-1-0' -AccessRights ReadData -InheritanceFlags None -PropagationFlags None
$sd = Get-NTFSSecurityDescriptor -Path $file
Clear-NTFSAudit -SecurityDescriptor $sd -ErrorAction Stop
@($sd.SecurityDescriptor.GetAuditRules($true, $false, $sidType)) | Should -BeNullOrEmpty
@(Get-NTFSAudit -Path $file -ExcludeInherited) | Should -HaveCount 1
Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -ErrorAction Stop
@(Get-NTFSAudit -Path $file -ExcludeInherited) | Should -BeNullOrEmpty
}
It 'Enable-NTFSAuditInheritance should let the SACL of the descriptor inherit' -Skip:(-not $holdsSecurityPrivilege) {
$file = New-TestSandboxItem -Sandbox $sandbox -Name 'EnableAudit'
Disable-NTFSAuditInheritance -Path $file
$sd = Get-NTFSSecurityDescriptor -Path $file
$sd.SecurityDescriptor.AreAuditRulesProtected | Should -BeTrue
Enable-NTFSAuditInheritance -SecurityDescriptor $sd -ErrorAction Stop
$sd.SecurityDescriptor.AreAuditRulesProtected | Should -BeFalse
(Get-NTFSInheritance -Path $file).AuditInheritanceEnabled | Should -BeFalse
Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -ErrorAction Stop
(Get-NTFSInheritance -Path $file).AuditInheritanceEnabled | Should -BeTrue
}
}
Describe 'Cmdlets that read a security descriptor in memory' {
BeforeAll {
$file = New-TestSandboxItem -Sandbox $sandbox -Name 'Read'
Add-NTFSAccess -Path $file -Account 'S-1-1-0' -AccessRights ReadData
}
It 'Get-NTFSAccess should return the entries that it returns for the path' {
$expected = @(Get-NTFSAccess -Path $file | ForEach-Object -Process { '{0}|{1}|{2}' -f $_.Account.Sid, $_.AccessRights, $_.IsInherited })
$result = @(Get-NTFSSecurityDescriptor -Path $file | Get-NTFSAccess -ErrorAction Stop)
$result | Should -Not -BeNullOrEmpty
($result | ForEach-Object -Process { '{0}|{1}|{2}' -f $_.Account.Sid, $_.AccessRights, $_.IsInherited }) -join ';' | Should -Be ($expected -join ';')
$result | ForEach-Object -Process { $_.FullName | Should -Be $file }
}
It 'Get-NTFSOwner should return the owner that it returns for the path' {
$expected = (Get-NTFSOwner -Path $file).Owner.Sid
$result = @(Get-NTFSSecurityDescriptor -Path $file | Get-NTFSOwner -ErrorAction Stop)
$result | Should -HaveCount 1
$result[0].Owner.Sid | Should -Be $expected
$result[0].FullName | Should -Be $file
}
It 'Get-NTFSAudit should return the audit entries that it returns for the path' -Skip:(-not $holdsSecurityPrivilege) {
Add-NTFSAudit -Path $file -Account 'S-1-1-0' -AccessRights ReadData -InheritanceFlags None -PropagationFlags None
$expected = @(Get-NTFSAudit -Path $file | ForEach-Object -Process { '{0}|{1}|{2}' -f $_.Account.Sid, $_.AccessRights, $_.AuditFlags })
$result = @(Get-NTFSSecurityDescriptor -Path $file | Get-NTFSAudit -ErrorAction Stop)
$result | Should -HaveCount 1
($result | ForEach-Object -Process { '{0}|{1}|{2}' -f $_.Account.Sid, $_.AccessRights, $_.AuditFlags }) -join ';' | Should -Be ($expected -join ';')
}
}

59
Tests/TestHelpers.Tests.ps1

@ -11,6 +11,8 @@ BeforeDiscovery {
Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force
# Assigning an owner other than the user or one of its groups needs the Restore privilege.
$canAssignAnyOwner = Test-PrivilegeHeld -Name 'SeRestorePrivilege'
# Reading and writing audit entries needs the Security privilege.
$holdsSecurityPrivilege = Test-PrivilegeHeld -Name 'SeSecurityPrivilege'
}
BeforeAll {
@ -134,6 +136,28 @@ Describe 'Test helpers' {
It 'Should not change the ACL of the target of a junction' {
(Get-Acl -LiteralPath $target).Sddl | Should -BeExactly $targetSddl
}
# Before 5.0.0-rc6, the teardown couldn't remove paths longer than 260 characters in Windows PowerShell (#110).
It 'Should remove a sandbox with a path longer than 260 characters' {
$longSandbox = New-TestSandbox -Name 'Helpers'
$long = Join-Path -Path $longSandbox -ChildPath (('A' * 100), ('B' * 100), ('C' * 100) -join '\')
Assert-TestSandboxPath -Sandbox $longSandbox -Path $long
# The \\?\ prefix lets Windows PowerShell create the path.
[IO.Directory]::CreateDirectory('\\?\' + $long) | Out-Null
[IO.File]::WriteAllText(('\\?\' + $long + '\File.txt'), 'Long')
$long.Length | Should -BeGreaterThan 260
Remove-TestSandbox -Sandbox $longSandbox
$longSandbox | Should -Not -Exist
}
# Before 5.0.0-rc6, an AfterAll after a failed setup stopped with a binding error that hid the error of the setup.
It 'Should do nothing for a sandbox that a failed setup did not create: <_>' -ForEach @('$null', 'empty string') {
$value = if ($_ -eq '$null') { $null } else { '' }
{ Remove-TestSandbox -Sandbox $value } | Should -Not -Throw
}
}
Context 'Set-TestOwner' {
@ -190,6 +214,41 @@ Describe 'Test helpers' {
$rules[0].AccessControlType | Should -Be 'Deny'
}
# Set-Acl also writes the audit section of an item whose DACL is protected, which fails without the Security
# privilege.
It 'Should add a deny entry to an item whose DACL is protected' {
$file = New-TestSandboxItem -Sandbox $sandbox -Name 'Protected'
& icacls.exe $file /inheritance:d *> $null
$LASTEXITCODE | Should -Be 0
Add-TestDenyRule -Sandbox $sandbox -Path $file -Rights @{ 'S-1-5-32-546' = 'ReadData' }
$acl = Get-Acl -LiteralPath $file
$acl.AreAccessRulesProtected | Should -BeTrue
$rules = @($acl.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) |
Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-5-32-546' })
$rules | Should -HaveCount 1
$rules[0].AccessControlType | Should -Be 'Deny'
}
# With the Security privilege, Set-Acl writes all sections, so the audit entries of the item would be lost.
It 'Should keep the audit entries of the item' -Skip:(-not $holdsSecurityPrivilege) {
$file = New-TestSandboxItem -Sandbox $sandbox -Name 'Audited'
$auditAcl = Get-Acl -LiteralPath $file -Audit
$auditAcl.AddAuditRule((New-Object -TypeName 'System.Security.AccessControl.FileSystemAuditRule' -ArgumentList (
(New-Object -TypeName 'System.Security.Principal.SecurityIdentifier' -ArgumentList 'S-1-1-0'),
[System.Security.AccessControl.FileSystemRights]::Delete,
[System.Security.AccessControl.AuditFlags]::Success
)))
Set-Acl -LiteralPath $file -AclObject $auditAcl
Add-TestDenyRule -Sandbox $sandbox -Path $file -Rights @{ 'S-1-5-32-546' = 'ReadData' }
$auditRules = @((Get-Acl -LiteralPath $file -Audit).GetAuditRules($true, $false, [System.Security.Principal.SecurityIdentifier]))
$auditRules | Should -HaveCount 1
$auditRules[0].IdentityReference.Value | Should -Be 'S-1-1-0'
}
It 'Should refuse an item outside the sandbox' {
{ Add-TestDenyRule -Sandbox $sandbox -Path "$sandbox-Other\File.txt" -Rights @{ 'S-1-5-32-546' = 'ReadData' } } |
Should -Throw -ExpectedMessage 'Refusing to change*'

48
Tests/TestHelpers.psm1

@ -87,10 +87,18 @@ function Remove-TestSandbox {
[CmdletBinding()]
param (
[Parameter(Mandatory)]
[AllowNull()]
[AllowEmptyString()]
[string]
$Sandbox
)
# A setup that failed before New-TestSandbox returned leaves nothing to remove. Before 5.0.0-rc6, the binding error
# hid the error of the setup (#110).
if ([string]::IsNullOrEmpty($Sandbox)) {
return
}
Assert-TestSandboxPath -Sandbox $Sandbox -Path $Sandbox
if (-not (Test-Path -LiteralPath $Sandbox)) {
return
@ -100,17 +108,20 @@ function Remove-TestSandbox {
# the caller uses ErrorAction Stop. Such items can still be deleted through the rights on their folder.
$ErrorActionPreference = 'Continue'
# The prefix lets .NET in Windows PowerShell reach paths longer than 260 characters (#110).
$longPathPrefix = '\\?\'
# Windows PowerShell 5.1 and icacls /T follow directory links, so the links go first. A folder that denies
# listing its content gets its own ACL reset, without /T, before it is listed.
$pending = New-Object -TypeName 'System.Collections.Generic.Stack[string]'
$pending.Push($Sandbox)
$pending.Push($longPathPrefix + $Sandbox)
while ($pending.Count -gt 0) {
$folder = $pending.Pop()
try {
$entries = [IO.Directory]::GetFileSystemEntries($folder)
}
catch {
& icacls.exe $folder /reset /C /Q *> $null
& icacls.exe $folder.Substring($longPathPrefix.Length) /reset /C /Q *> $null
$entries = [IO.Directory]::GetFileSystemEntries($folder)
}
foreach ($entry in $entries) {
@ -129,10 +140,26 @@ function Remove-TestSandbox {
}
}
& icacls.exe $Sandbox /reset /T /C /Q *> $null
Get-ChildItem -LiteralPath $Sandbox -Recurse -Force | ForEach-Object -Process {
$_.Attributes = [IO.FileAttributes]::Normal
Get-ChildItem -LiteralPath $Sandbox -Recurse -Force -ErrorAction SilentlyContinue | ForEach-Object -Process {
# Windows PowerShell returns items below paths longer than 260 characters that it can't change; rd removes them.
try {
$_.Attributes = [IO.FileAttributes]::Normal
}
catch {
Write-Verbose -Message "Keeping the attributes of '$($_.FullName)': $($_.Exception.Message)"
}
}
Remove-Item -LiteralPath $Sandbox -Recurse -Force
Remove-Item -LiteralPath $Sandbox -Recurse -Force -ErrorAction SilentlyContinue
if (Test-Path -LiteralPath $Sandbox) {
# Windows PowerShell can't remove paths longer than 260 characters; rd can with the prefix, and the links are
# gone already.
& cmd.exe /d /c ('rd /s /q "{0}{1}"' -f $longPathPrefix, $Sandbox) *> $null
}
if (Test-Path -LiteralPath $Sandbox) {
Write-Error -Message "The sandbox '$Sandbox' could not be removed."
}
try {
# Fails while another sandbox exists, also one of a test run in parallel
[IO.Directory]::Delete($script:sandboxRoot, $false)
@ -250,6 +277,7 @@ function Add-TestDenyRule {
)
Assert-TestSandboxPath -Sandbox $Sandbox -Path $Path
$item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop
$acl = Get-Acl -LiteralPath $Path
foreach ($sid in $Rights.Keys) {
$identity = New-Object -TypeName 'System.Security.Principal.SecurityIdentifier' -ArgumentList $sid
@ -259,7 +287,15 @@ function Add-TestDenyRule {
)
$acl.AddAccessRule($rule)
}
Set-Acl -LiteralPath $Path -AclObject $acl
# Not Set-Acl: it compares AreAuditRulesProtected with AreAccessRulesProtected, so it also writes the audit
# section of an item whose DACL is protected, which fails without the Security privilege. With the privilege, it
# writes all sections and drops the audit entries. SetAccessControl writes only the DACL, the section that changed.
if ($PSVersionTable.PSEdition -eq 'Core') {
[System.IO.FileSystemAclExtensions]::SetAccessControl($item, $acl)
}
else {
$item.SetAccessControl($acl)
}
}
function Set-TestOwner {

Loading…
Cancel
Save