mirror of https://github.com/raandree/NTFSSecurity
committed by
GitHub
51 changed files with 5747 additions and 114 deletions
|
@ -0,0 +1,796 @@ |
|||||
|
# Explanations of the unvisited code, by rule |
||||
|
|
||||
|
Generated from the classification of the unvisited methods in the aggregated |
||||
|
AltCover report of source `5a5d58b`. |
||||
|
[Quality-Gate-Paths-2026-10-09.md](./Quality-Gate-Paths-2026-10-09.md) |
||||
|
describes the method and the result; the rows of every method are in the CSV |
||||
|
file next to it. An explanation closes a path only as far as its evidence |
||||
|
goes: the evidence line of each rule says whether an executed probe, a static |
||||
|
scan of the compiled code, or reading the source supports it. |
||||
|
|
||||
|
## By category |
||||
|
|
||||
|
| Category | Disposition | Methods | Unvisited sequence points | Unvisited explicit branch points | |
||||
|
| --- | --- | ---: | ---: | ---: | |
||||
|
| unused by cmdlets | Explained | 60 | 173 | 34 | |
||||
|
| parameter/API surface | Explained | 103 | 103 | 0 | |
||||
|
| environment-specific | Explained | 27 | 81 | 18 | |
||||
|
| defensive | Explained | 33 | 77 | 16 | |
||||
|
| unused by cmdlets | Open | 8 | 8 | 2 | |
||||
|
| **Total** | | **231** | **442** | **70** | |
||||
|
|
||||
|
## By rule |
||||
|
|
||||
|
### ACCESS-GENERIC-CATCH |
||||
|
|
||||
|
8 method(s), 32 unvisited sequence point(s), 3 unvisited explicit |
||||
|
branch point(s). Category: defensive. Disposition: Explained. |
||||
|
|
||||
|
Classes: ClearAccess, DisableAccessInheritance, EnableAccessInheritance, |
||||
|
GetAccess, GetInheritance, GetOrphanedAccess, GetSecurityDescriptor, |
||||
|
OwnerCmdlets.GetOwner. |
||||
|
|
||||
|
Evidence: an executed probe. |
||||
|
|
||||
|
Why: The unvisited points are the last catch (Exception) of the per-item loop, |
||||
|
taken when the DACL API fails with anything but access denied after the item |
||||
|
was found. A read or write of the DACL on local NTFS either succeeds or fails |
||||
|
with access denied (covered), and the probes found no other failure for these |
||||
|
cmdlets: an unresolvable SID is accepted, a locked file does not matter |
||||
|
because the security APIs ignore share modes, and a dangling junction is read |
||||
|
as the link itself. One input does trigger that catch, a deny entry without |
||||
|
rights, which .NET refuses with an ArgumentException; Add-NTFSAccess, |
||||
|
Remove-NTFSAccess and Add-NTFSAudit take it and are tested (an Allow entry |
||||
|
without rights is accepted, because the module adds Synchronize to it). A |
||||
|
cmdlet that has no rights parameter, such as the read and inheritance cmdlets, |
||||
|
has no such input. A second trigger exists for the cmdlets that add entries: |
||||
|
an ACL that is full. In both editions the 1,818th entry that was added to a |
||||
|
security descriptor in memory, with -SecurityDescriptor, raised an |
||||
|
OverflowException, "Length of the access control list exceed the allowed |
||||
|
maximum" (probe p37: unresolvable SIDs with ReadData; 1,816 entries were |
||||
|
written and read back without an error, and one more added with -Path |
||||
|
succeeded, so the overflow itself was not run with -Path). By reading the |
||||
|
source, the -Path loop takes that exception in the same handler as the |
||||
|
zero-mask case, whereas the -SecurityDescriptor sets have no handler around |
||||
|
the add, so there the exception ends the cmdlet. It was not turned into a test |
||||
|
of its own. A volume without ACL support or a corrupt descriptor cannot be |
||||
|
created safely on the shared host. |
||||
|
|
||||
|
Residual risk: Low: one WriteError(exception, id, category, path) and |
||||
|
continue, the shape that the access-denied and ReadFileError siblings of the |
||||
|
same loops assert and that the zero-mask tests assert for the cmdlets that add |
||||
|
and remove entries. The catches of the cmdlets that only read or change |
||||
|
inheritance have no known trigger. A catch that wraps the write of the result |
||||
|
is entered also when a later command raises something while it takes the |
||||
|
object; every catch-all whose try block writes directly passes that exception |
||||
|
on (a scan of the source finds no exception; it cannot see a write inside a |
||||
|
helper such as the owner restore of InvokeAsOwner, an accepted limitation), |
||||
|
and the PipelineControl tests run that part for every cmdlet. |
||||
|
|
||||
|
Related tests: Access.Tests and Audit.Tests: a deny or audit entry without |
||||
|
rights for Add-NTFSAccess, Remove-NTFSAccess and Add-NTFSAudit (AddAceError |
||||
|
and RemoveAceError, ArgumentException, WriteError, target, continuation, |
||||
|
nothing written). PathErrors: ReadFileError, ReadSecurityError, denied write |
||||
|
and ownership retry, each with continuation. PipelineControl.Tests: a break, a |
||||
|
continue, Select-Object -First, a throw, and an error with -ErrorAction Stop |
||||
|
after the first object. |
||||
|
|
||||
|
### ALLOCATED-MEMORY-FINALIZER |
||||
|
|
||||
|
2 method(s), 3 unvisited sequence point(s), 1 unvisited explicit |
||||
|
branch point(s). Category: defensive. Disposition: Explained. |
||||
|
|
||||
|
Classes: AllocatedMemory. |
||||
|
|
||||
|
Evidence: reading the source. |
||||
|
|
||||
|
Why: AllocatedMemory is internal and used only in a using block of |
||||
|
GetTokenPrivileges, which disposes it and suppresses the finalizer. The |
||||
|
finalizer and the branch for a pointer that was released already run only for |
||||
|
an instance that nobody disposed. |
||||
|
|
||||
|
Residual risk: None found. |
||||
|
|
||||
|
Related tests: Privileges.Tests: the token handle tests list the privileges |
||||
|
through it. |
||||
|
|
||||
|
### AUDIT-OWNER-RETRY |
||||
|
|
||||
|
10 method(s), 47 unvisited sequence point(s), 2 unvisited explicit |
||||
|
branch point(s). Category: environment-specific. Disposition: Explained. |
||||
|
|
||||
|
Classes: AddAudit, AddAudit/<>c__DisplayClass35_0, ClearAudit, |
||||
|
ClearAudit/<>c__DisplayClass11_0, DisableAuditInheritance, |
||||
|
DisableAuditInheritance/<>c__DisplayClass15_0, EnableAuditInheritance, |
||||
|
EnableAuditInheritance/<>c__DisplayClass15_0, RemoveAudit, |
||||
|
RemoveAudit/<>c__DisplayClass39_0. |
||||
|
|
||||
|
Evidence: an executed probe. |
||||
|
|
||||
|
Why: The unvisited points are the catch (UnauthorizedAccessException) of an |
||||
|
audit write, its InvokeAsOwner retry and the closure of that retry. Local NTFS |
||||
|
never raises that exception for an audit change: as a basic user all seven |
||||
|
audit cmdlets get an IOException, error 1314 (A required privilege is not |
||||
|
held), in both editions (probe); with the Security privilege nothing in a DACL |
||||
|
can deny the privilege-only right, and a loopback administrative share ended |
||||
|
in no exception for all five either (probe, elevated, EnablePrivileges on and |
||||
|
off). The retry is the InvokeAsOwner code that the access cmdlets share, so |
||||
|
its success, failure and RestoreOwnerError paths run there; only the audit |
||||
|
closure bodies, which repeat the first attempt, are unexecuted. |
||||
|
|
||||
|
Residual risk: Low to medium: an audit write that a file server answers with |
||||
|
access denied runs lines that no instrumented test reaches. The live lab suite |
||||
|
runs the audit cmdlets over SMB outside the instrumented run. |
||||
|
|
||||
|
Related tests: PathErrors (denied write and ownership retry for the access |
||||
|
cmdlets), FileHash and SecurityDescriptor tests (ownership retry), Audit.Tests |
||||
|
(privilege missing or disabled). |
||||
|
|
||||
|
### AUDIT-READ-DENIED |
||||
|
|
||||
|
2 method(s), 6 unvisited sequence point(s), 0 unvisited explicit |
||||
|
branch point(s). Category: environment-specific. Disposition: Explained. |
||||
|
|
||||
|
Classes: AuditCmdlets.GetOrphanedAudit, GetAudit. |
||||
|
|
||||
|
Evidence: an executed probe. |
||||
|
|
||||
|
Why: The unvisited points are the catch (UnauthorizedAccessException) that |
||||
|
writes a PermissionDenied ReadSecurityError for an audit read. Without the |
||||
|
Security privilege Windows answers error 1314, which AlphaFS raises as an |
||||
|
IOException (probe as a basic user, both editions) and the generic catch of |
||||
|
the same loop reports as an OpenError; with the privilege, nothing denies the |
||||
|
read locally. An access-denied answer needs a remote file server. |
||||
|
|
||||
|
Residual risk: Low: the same WriteError statement with another category. |
||||
|
|
||||
|
Related tests: Audit.Tests: error without the Security privilege, descriptor |
||||
|
read without audit entries. |
||||
|
|
||||
|
### CHILDITEM2-NON-FOLDER |
||||
|
|
||||
|
1 method(s), 0 unvisited sequence point(s), 1 unvisited explicit |
||||
|
branch point(s). Category: defensive. Disposition: Explained. |
||||
|
|
||||
|
Classes: GetChildItem2. |
||||
|
|
||||
|
Evidence: reading the source. |
||||
|
|
||||
|
Why: The else of the test for a DirectoryInfo at the start of WriteFileSystem: |
||||
|
both callers pass one, ProcessRecord after it returned for a file, and the |
||||
|
recursion with the folders that EnumerateDirectories returned. |
||||
|
|
||||
|
Residual risk: None found. |
||||
|
|
||||
|
Related tests: ItemCmdlets.Tests: files, folders, recursion, filters, depth. |
||||
|
|
||||
|
### CMDLET-GETTER |
||||
|
|
||||
|
103 method(s), 103 unvisited sequence point(s), 0 unvisited explicit |
||||
|
branch point(s). Category: parameter/API surface. Disposition: Explained. |
||||
|
|
||||
|
Classes: AddAccess, AddAudit, ClearAccess, ClearAudit, CopyItem2, |
||||
|
DisableAccessInheritance, DisableAuditInheritance, DisablePrivileges, |
||||
|
EnableAccessInheritance, EnableAuditInheritance, EnablePrivileges, |
||||
|
GetChildItem2, GetDiskSpace, GetEffectiveAccess, GetFileHash2, GetInheritance, |
||||
|
GetItem2, GetSecurityDescriptor, GetSimpleAccess, MoveItem2, NewHardLink, |
||||
|
NewSymbolicLink, RemoveAccess, RemoveAudit, RemoveItem2, SetInheritance, |
||||
|
SetOwner, SetSecurityDescriptor. |
||||
|
|
||||
|
Evidence: reading the source. |
||||
|
|
||||
|
Why: A one-line getter of a cmdlet parameter. PowerShell binds a parameter |
||||
|
through its setter and the cmdlet code reads the private field, so no caller |
||||
|
invokes the getter. It has no logic, so a test that reads it back repeats the |
||||
|
field assignment and proves no behavior. |
||||
|
|
||||
|
Residual risk: None found. |
||||
|
|
||||
|
Related tests: Every parameter-set test binds the setters; Help.Tests and |
||||
|
OutputTypes.Tests read the parameter metadata. |
||||
|
|
||||
|
### CODEMEMBERS-NULL-BASE |
||||
|
|
||||
|
1 method(s), 1 unvisited sequence point(s), 1 unvisited explicit |
||||
|
branch point(s). Category: defensive. Disposition: Explained. |
||||
|
|
||||
|
Classes: FileSystemCodeMembers. |
||||
|
|
||||
|
Evidence: an executed probe. |
||||
|
|
||||
|
Why: The second null check tests the base object of the PSObject that the |
||||
|
first check let through. A PSObject cannot wrap null: new PSObject($null) and |
||||
|
PSObject.AsPSObject($null) throw PSArgumentNullException in both editions |
||||
|
(probe), and PowerShell hands a $null argument over as null, which the first |
||||
|
check covers. |
||||
|
|
||||
|
Residual risk: None found. |
||||
|
|
||||
|
Related tests: ItemCmdlets.Tests: the Mode of files, of a folder, and of no |
||||
|
object. |
||||
|
|
||||
|
### DISKSPACE-EMPTY-VOLUME |
||||
|
|
||||
|
1 method(s), 0 unvisited sequence point(s), 1 unvisited explicit |
||||
|
branch point(s). Category: environment-specific. Disposition: Explained. |
||||
|
|
||||
|
Classes: GetDiskSpace. |
||||
|
|
||||
|
Evidence: reading the source. |
||||
|
|
||||
|
Why: The unvisited branch skips a volume that reports zero bytes, such as a |
||||
|
card reader or an optical drive without media. The host has none, and a test |
||||
|
cannot attach one without changing the shared machine. |
||||
|
|
||||
|
Residual risk: Low: the volume is skipped silently. |
||||
|
|
||||
|
Related tests: ItemCmdlets.Tests: volumes with a size, drive letter without a |
||||
|
volume. |
||||
|
|
||||
|
### EFFECTIVE-ACCESS-CATCH |
||||
|
|
||||
|
2 method(s), 10 unvisited sequence point(s), 0 unvisited explicit |
||||
|
branch point(s). Category: defensive. Disposition: Explained. |
||||
|
|
||||
|
Classes: GetEffectiveAccess, GetEffectiveAccess/<>c__DisplayClass20_0. |
||||
|
|
||||
|
Evidence: reading the source. |
||||
|
|
||||
|
Why: EffectiveAccess.GetEffectiveAccess hides every failure of its Authz calls |
||||
|
in a result with OperationFailed, which the cmdlet turns into |
||||
|
GetEffectiveAccessError. The catch (Exception) blocks and the InvokeAsOwner |
||||
|
retry therefore see an exception only from what runs outside those calls: the |
||||
|
read of the descriptor of the item (EffectiveAccess.cs, the FileSystemInfo |
||||
|
overload) or the conversion of the account. The only local failure of the |
||||
|
descriptor read is access denied, which the UnauthorizedAccessException branch |
||||
|
handles and the tests cover; no other local trigger was found, and the |
||||
|
unresolved identity, which the conversion accepts, ends in OperationFailed. |
||||
|
|
||||
|
Residual risk: Low: a failure of the descriptor read other than access denied, |
||||
|
such as an I/O error of the volume, would run the unvisited catch, which |
||||
|
writes one ReadEffectivePermissionError and continues like its tested |
||||
|
siblings. |
||||
|
|
||||
|
Related tests: Access.Tests: unresolved identity for a path and a descriptor, |
||||
|
remote fallback, privilege warnings. |
||||
|
|
||||
|
### ENABLEPRIVILEGES-INIT |
||||
|
|
||||
|
1 method(s), 2 unvisited sequence point(s), 0 unvisited explicit |
||||
|
branch point(s). Category: defensive. Disposition: Explained. |
||||
|
|
||||
|
Classes: EnablePrivileges. |
||||
|
|
||||
|
Evidence: reading the source. |
||||
|
|
||||
|
Why: The branches that test whether the caller is a script named |
||||
|
NTFSSecurity.Init.ps1 are run by Privileges.Tests, which writes a script of |
||||
|
that name and of another name and runs each in a child process. The tests |
||||
|
check the state of the Backup privilege and the verbose message that only the |
||||
|
cmdlet writes, because with the setting $true the module enables the |
||||
|
privileges itself before the cmdlet runs: from the Init script the cmdlet |
||||
|
enables them for EnablePrivileges $true and does nothing for $false, from |
||||
|
another script it enables them for both. The script that the module ships |
||||
|
under that name does not call Enable-Privileges (it adds the types). What |
||||
|
stays unvisited is the catch that rethrows a ParseException for a malformed |
||||
|
EnablePrivileges value: the base BeginProcessing casts the same value first |
||||
|
and fails earlier, and the cmdlet takes no pipeline input, so only a consumer |
||||
|
of the debug or verbose stream could change the setting between the two calls. |
||||
|
|
||||
|
Residual risk: Low: the catch rethrows with a message that names the setting; |
||||
|
it was not run. |
||||
|
|
||||
|
Related tests: Privileges.Tests: Enable-Privileges in the script |
||||
|
NTFSSecurity.Init.ps1 (three cases), Enable-Privileges and Disable-Privileges. |
||||
|
|
||||
|
### FILESECURITY-CONVERSION |
||||
|
|
||||
|
2 method(s), 2 unvisited sequence point(s), 0 unvisited explicit |
||||
|
branch point(s). Category: unused by cmdlets. Disposition: Open. |
||||
|
|
||||
|
Classes: FileSystemSecurity2. |
||||
|
|
||||
|
Evidence: an executed probe. |
||||
|
|
||||
|
Why: The implicit conversions from FileSecurity and DirectorySecurity |
||||
|
construct FileSystemSecurity2 from FileInfo("") and DirectoryInfo(""), so they |
||||
|
always throw ArgumentException (probe: "Path is a zero-length string"). A test |
||||
|
of the intended behavior would fail, and a test of the actual behavior would |
||||
|
cement a defect in an API that no cmdlet uses. |
||||
|
|
||||
|
Residual risk: Library users who convert a .NET descriptor get an exception. |
||||
|
Fix or remove is a maintainer decision. |
||||
|
|
||||
|
Related tests: None, on purpose. |
||||
|
|
||||
|
### FSSEC2-CONSTRUCTOR-FALLBACK |
||||
|
|
||||
|
1 method(s), 2 unvisited sequence point(s), 0 unvisited explicit |
||||
|
branch point(s). Category: environment-specific. Disposition: Explained. |
||||
|
|
||||
|
Classes: FileSystemSecurity2. |
||||
|
|
||||
|
Evidence: reading the source. |
||||
|
|
||||
|
Why: The unvisited points are the last fallback of the constructor with one |
||||
|
argument: the DACL read alone after the DACL read together with the owner and |
||||
|
the group failed. The first fallback, without the SACL when the Security |
||||
|
privilege is missing, runs in the basic configurations. The owner and the |
||||
|
group need the same READ_CONTROL right as the DACL, so the third read succeeds |
||||
|
only where a volume or a server answers them differently. |
||||
|
|
||||
|
Residual risk: Low: the descriptor that the fallback returns holds the DACL |
||||
|
only, and the cmdlets that need more report it. |
||||
|
|
||||
|
Related tests: SecurityDescriptor.Tests and Audit.Tests: a descriptor read |
||||
|
without the Security privilege. |
||||
|
|
||||
|
### FSSEC2-DRIVE-LETTER-CHARS |
||||
|
|
||||
|
1 method(s), 0 unvisited sequence point(s), 1 unvisited explicit |
||||
|
branch point(s). Category: defensive. Disposition: Explained. |
||||
|
|
||||
|
Classes: FileSystemSecurity2. |
||||
|
|
||||
|
Evidence: reading the source. |
||||
|
|
||||
|
Why: The unvisited branch is a first character that is no letter in front of |
||||
|
the colon. AlphaFS normalizes the full name, so a name of two characters that |
||||
|
ends in a colon always begins with a drive letter, in upper or lower case |
||||
|
(both run); a digit or another character cannot form such a name. |
||||
|
|
||||
|
Residual risk: None found. |
||||
|
|
||||
|
Related tests: DriveRoot.Tests: the system drive and a mapped drive; |
||||
|
ObjectApis.Tests: a lowercase drive letter. |
||||
|
|
||||
|
### FSSEC2-HASHCODE-NULL |
||||
|
|
||||
|
1 method(s), 0 unvisited sequence point(s), 1 unvisited explicit |
||||
|
branch point(s). Category: defensive. Disposition: Explained. |
||||
|
|
||||
|
Classes: FileSystemSecurity2. |
||||
|
|
||||
|
Evidence: reading the source. |
||||
|
|
||||
|
Why: The zero is for a descriptor without a wrapped .NET descriptor. Both |
||||
|
constructors set it, because GetSecurity returns a descriptor or throws, and |
||||
|
the conversions from FileSecurity and DirectorySecurity throw before an object |
||||
|
exists. Only a derived class that sets the field to null reaches it. |
||||
|
|
||||
|
Residual risk: None found. |
||||
|
|
||||
|
Related tests: SecurityDescriptor.Tests: equality, hash code, and use as a |
||||
|
key. |
||||
|
|
||||
|
### GENERATED-RESOURCES |
||||
|
|
||||
|
6 method(s), 12 unvisited sequence point(s), 2 unvisited explicit |
||||
|
branch point(s). Category: unused by cmdlets. Disposition: Explained. |
||||
|
|
||||
|
Classes: Properties.Resources. |
||||
|
|
||||
|
Evidence: reading the source. |
||||
|
|
||||
|
Why: The designer-generated resource class of two icons that no code |
||||
|
references. |
||||
|
|
||||
|
Residual risk: None found. |
||||
|
|
||||
|
Related tests: None needed. |
||||
|
|
||||
|
### HARDLINK-DENIED |
||||
|
|
||||
|
1 method(s), 3 unvisited sequence point(s), 0 unvisited explicit |
||||
|
branch point(s). Category: environment-specific. Disposition: Explained. |
||||
|
|
||||
|
Classes: GetHardLink. |
||||
|
|
||||
|
Evidence: an executed probe. |
||||
|
|
||||
|
Why: The unvisited points are the catch (UnauthorizedAccessException) of the |
||||
|
hard link enumeration. No local input was found that raises it: with deny |
||||
|
entries for ReadAttributes, ReadData and ReadPermissions for OWNER RIGHTS and |
||||
|
for the user, and automatic privileges off, Get-NTFSHardLink still listed the |
||||
|
names (probe p32, elevated, both editions), and Links.Tests asserts that |
||||
|
listing while the data of the file cannot be read (Get-Content fails). Whether |
||||
|
a denial of ReadAttributes or ReadPermissions alone could change that was not |
||||
|
shown: an elevated session read the permissions despite the deny entries, so |
||||
|
the test asserts only the refused data. The IOException of a network share, |
||||
|
(50), is covered. |
||||
|
|
||||
|
Residual risk: Low: one WriteError with the PermissionDenied category. If a |
||||
|
Windows version or a file server refuses the listing, the new test fails and |
||||
|
this catch is reached. |
||||
|
|
||||
|
Related tests: Links.Tests: the names of a file whose read rights are denied, |
||||
|
error for a file on a network share, for a folder and for a missing path. |
||||
|
|
||||
|
### HASH-POLICY-AND-RETRY |
||||
|
|
||||
|
2 method(s), 7 unvisited sequence point(s), 0 unvisited explicit |
||||
|
branch point(s). Category: environment-specific. Disposition: Explained. |
||||
|
|
||||
|
Classes: GetFileHash2, GetFileHash2/<>c__DisplayClass9_0. |
||||
|
|
||||
|
Evidence: an executed probe. |
||||
|
|
||||
|
Why: Two groups. The second catch around the algorithm check handles an |
||||
|
algorithm that a FIPS policy refuses; the policy of the host cannot be |
||||
|
switched in a test. The closing points after InvokeAsOwner run only when the |
||||
|
hash succeeds after taking ownership: ownership grants READ_CONTROL and |
||||
|
WRITE_DAC but never data read, so the retry can only fail, which two tests |
||||
|
assert. Probes looked for a way around that: with the owner changed to the |
||||
|
user the read still ended in access denied (error 5, plus a RestoreOwnerError |
||||
|
1307 for an owner that the user cannot set back), and an OWNER RIGHTS deny is |
||||
|
no way in either, because Windows drops that entry when the owner changes. |
||||
|
|
||||
|
Residual risk: Low: the success path after the retry has no local trigger. A |
||||
|
file server that grants the read to the owner only would run it. |
||||
|
|
||||
|
Related tests: FileHash.Tests: unavailable algorithms, unreadable file, |
||||
|
restore of the previous owner, failed restore. |
||||
|
|
||||
|
### IDENTITY-NULL-REFERENCE |
||||
|
|
||||
|
1 method(s), 0 unvisited sequence point(s), 1 unvisited explicit |
||||
|
branch point(s). Category: defensive. Disposition: Explained. |
||||
|
|
||||
|
Classes: IdentityReference2. |
||||
|
|
||||
|
Evidence: reading the source. |
||||
|
|
||||
|
Why: The unvisited branch is an IdentityReference that is neither an NTAccount |
||||
|
nor a SecurityIdentifier, which only null is: the .NET class has no other |
||||
|
public subclass. The object then has no SID and every member fails with a |
||||
|
NullReferenceException. No cmdlet passes null, and a test would cement that |
||||
|
failure. |
||||
|
|
||||
|
Residual risk: Low: a library caller that passes null gets an unusable object. |
||||
|
|
||||
|
Related tests: ObjectApis.Tests: identity constructors and their errors. |
||||
|
|
||||
|
### INHERITED-FROM-EMPTY-LIST |
||||
|
|
||||
|
1 method(s), 0 unvisited sequence point(s), 1 unvisited explicit |
||||
|
branch point(s). Category: defensive. Disposition: Explained. |
||||
|
|
||||
|
Classes: FileSystemAuditRule2. |
||||
|
|
||||
|
Evidence: an executed probe. |
||||
|
|
||||
|
Why: The access twin is tested: a NULL DACL, which Windows reads as an ACL |
||||
|
with no entries and .NET reports as one entry for Everyone, runs the branch |
||||
|
where the list of sources is empty (Access.Tests). The unvisited branch that |
||||
|
remains is the second operand of getInheritedFrom && inheritedFrom.Count > 0 |
||||
|
in the audit twin. GetInheritedFrom returns one source for each entry of the |
||||
|
SACL that it reads, also for the fallback text of an unknown parent, and an |
||||
|
empty list only for a descriptor without a SACL. The loop that holds the |
||||
|
condition runs over the audit entries of the same SACL, so an empty list means |
||||
|
that the loop has no entry and does not run, as for every item without a SACL |
||||
|
(Audit.Tests: the item has no SACL). The setting that turns the lookup off and |
||||
|
the fallback cover the other outcomes. A probe shows that an integrity label |
||||
|
in the SACL of a folder is not part of the SACL that the module reads, so the |
||||
|
sources of the audit entries stay aligned with the entries (probe, both |
||||
|
editions). |
||||
|
|
||||
|
Residual risk: Low: the two lists are aligned by position, which assumes that |
||||
|
the .NET API returns a rule for every entry of the ACL that Windows names a |
||||
|
source for; an entry type that it skips would shift the sources. No such entry |
||||
|
was found on NTFS, and none was tested. |
||||
|
|
||||
|
Related tests: Access.Tests: InheritedFrom with the setting on and off, for an |
||||
|
unknown parent and for a NULL DACL; Audit.Tests: InheritedFrom of audit |
||||
|
entries and an item without a SACL; ObjectApis.Tests: an empty DACL. |
||||
|
|
||||
|
### NATIVE-HANDLES |
||||
|
|
||||
|
16 method(s), 34 unvisited sequence point(s), 6 unvisited explicit |
||||
|
branch point(s). Category: unused by cmdlets. Disposition: Explained. |
||||
|
|
||||
|
Classes: IntPtrExtensions, SafeAuthzRMHandle, SafeHGlobalHandle, |
||||
|
SafeTokenHandle. |
||||
|
|
||||
|
Evidence: reading the source. |
||||
|
|
||||
|
Why: Native memory and handle wrappers. The overloads that the Authz and |
||||
|
descriptor code use run; the unvisited ones have no caller: further |
||||
|
AllocHGlobal overloads, InvalidHandle getters, ReleaseHandle for handles that |
||||
|
the module never creates, and Increment. They wrap Marshal.AllocHGlobal and |
||||
|
FreeHGlobal, so a test would repeat the BCL. |
||||
|
|
||||
|
Residual risk: Low. |
||||
|
|
||||
|
Related tests: Effective access tests run the used overloads. |
||||
|
|
||||
|
### PRIVILEGE-DISABLE-FAILURE |
||||
|
|
||||
|
4 method(s), 10 unvisited sequence point(s), 2 unvisited explicit |
||||
|
branch point(s). Category: defensive. Disposition: Explained. |
||||
|
|
||||
|
Classes: BaseCmdletWithPrivControl. |
||||
|
|
||||
|
Evidence: reading the source. |
||||
|
|
||||
|
Why: These are the failure branches of disabling a privilege. DisablePrivilege |
||||
|
reads the current state first and does nothing unless the privilege is |
||||
|
Enabled, so a privilege that another command disabled, or that the token does |
||||
|
not hold, never reaches them. They run only when AdjustTokenPrivileges fails |
||||
|
for an enabled privilege, for example for an invalid token handle; a test |
||||
|
would have to corrupt the process token. |
||||
|
|
||||
|
Residual risk: Low: a failed cleanup is a warning that names the privilege. |
||||
|
The deferred review "failed privilege-disable retry" stays not reproduced. |
||||
|
|
||||
|
Related tests: Privileges.Tests: another command disables a privilege, early |
||||
|
pipeline stop, token holds only some privileges. |
||||
|
|
||||
|
### PRIVILEGE-ENABLER-RACE |
||||
|
|
||||
|
1 method(s), 0 unvisited sequence point(s), 1 unvisited explicit |
||||
|
branch point(s). Category: defensive. Disposition: Explained. |
||||
|
|
||||
|
Classes: PrivilegeEnabler. |
||||
|
|
||||
|
Evidence: reading the source. |
||||
|
|
||||
|
Why: The third operand of the condition, that the adjustment returned |
||||
|
PrivilegeModified, is false only when another thread enabled the privilege |
||||
|
between the state check and the adjustment of the same call. |
||||
|
|
||||
|
Residual risk: None found. |
||||
|
|
||||
|
Related tests: Privileges.Tests: the first and the second enabler, a privilege |
||||
|
that was enabled before, and a privilege that the token does not hold. |
||||
|
|
||||
|
### PRIVILEGECONTROL-DEAD-ELSE |
||||
|
|
||||
|
2 method(s), 2 unvisited sequence point(s), 2 unvisited explicit |
||||
|
branch point(s). Category: defensive. Disposition: Explained. |
||||
|
|
||||
|
Classes: PrivilegeControl. |
||||
|
|
||||
|
Evidence: reading the source. |
||||
|
|
||||
|
Why: The unvisited points are the last else of each method, taken when the |
||||
|
third read of the privilege state finds none of Disabled, Removed, or Enabled. |
||||
|
PrivilegeState has exactly these three values and GetPrivilegeState returns |
||||
|
one of them, so the else can run only when another thread changes the |
||||
|
privilege between the reads of one call. |
||||
|
|
||||
|
Residual risk: None found: the message would be Unknown Error, for a state |
||||
|
that cannot exist. |
||||
|
|
||||
|
Related tests: Privileges.Tests: enabling and disabling a held privilege, |
||||
|
repeating either, and a privilege that the token does not hold. |
||||
|
|
||||
|
### READ-RETRY-CLOSURE |
||||
|
|
||||
|
4 method(s), 8 unvisited sequence point(s), 0 unvisited explicit |
||||
|
branch point(s). Category: defensive. Disposition: Explained. |
||||
|
|
||||
|
Classes: GetAccess/<>c__DisplayClass20_0, GetInheritance/<>c__DisplayClass7_0, |
||||
|
GetOrphanedAccess/<>c__DisplayClass1_0, |
||||
|
GetSecurityDescriptor/<>c__DisplayClass4_0. |
||||
|
|
||||
|
Evidence: reading the source. |
||||
|
|
||||
|
Why: The closure re-reads the item inside InvokeAsOwner. InvokeAsOwner reads |
||||
|
the owner first, which needs the same READ_CONTROL right as the read that |
||||
|
failed, so the retry stops before the closure runs; the documentation and |
||||
|
PathErrors assert the resulting ReadSecurityError. A DACL that denies reading |
||||
|
the DACL but not the owner cannot be built. |
||||
|
|
||||
|
Residual risk: None found: the closure repeats the first attempt. |
||||
|
|
||||
|
Related tests: PathErrors: An item whose owner may not read its permissions. |
||||
|
|
||||
|
### REGISTRY-MODEL |
||||
|
|
||||
|
34 method(s), 105 unvisited sequence point(s), 20 unvisited explicit |
||||
|
branch point(s). Category: unused by cmdlets. Disposition: Explained. |
||||
|
|
||||
|
Classes: RegistryAccessRule2, RegistryEffectivePermissionEntry, |
||||
|
RegistryInheritanceInfo, RegistryKeyOpenException, |
||||
|
RegistryKeySetSecurityException, Win32RegistrySecurity. |
||||
|
|
||||
|
Evidence: a static scan of the compiled code. |
||||
|
|
||||
|
Why: The registry ACL object model of the original project. No cmdlet accepts |
||||
|
a registry path and no other class of the four assemblies calls it (static IL |
||||
|
scan). SetRegistryOwner takes ownership of registry keys, which no test may do |
||||
|
on the shared host, and the model is not part of the documented module |
||||
|
surface. Decisions 21 and 22 leave these classes to the maintainer. |
||||
|
|
||||
|
Residual risk: Untested and undocumented library surface; a script that calls |
||||
|
it has no test behind it. Keep or remove is a maintainer decision. |
||||
|
|
||||
|
Related tests: None; see the decision. |
||||
|
|
||||
|
### RELATIVE-PATH-EMPTY |
||||
|
|
||||
|
1 method(s), 1 unvisited sequence point(s), 1 unvisited explicit |
||||
|
branch point(s). Category: defensive. Disposition: Explained. |
||||
|
|
||||
|
Classes: BaseCmdlet. |
||||
|
|
||||
|
Evidence: an executed probe. |
||||
|
|
||||
|
Why: The unvisited points are the first branch, which replaces an empty path |
||||
|
with the current location. Every caller passes a validated value: each -Path |
||||
|
and -Target parameter of the cmdlets carries ValidateNotNullOrEmpty, which |
||||
|
rejects an empty value and an empty element of an array in both editions |
||||
|
(probe), and the mandatory -Destination of Copy-Item2 and Move-Item2 rejects |
||||
|
an empty string. A cmdlet without -Path passes the current location itself. |
||||
|
|
||||
|
Residual risk: None found: only a class derived from the cmdlet base class |
||||
|
could pass an empty path, and it would get the current location. |
||||
|
|
||||
|
Related tests: ItemCmdlets.Tests: Get-Item2 resolves ., .., and relative |
||||
|
names. |
||||
|
|
||||
|
### REMOVEALL-ACCOUNT-FILTER |
||||
|
|
||||
|
6 method(s), 6 unvisited sequence point(s), 2 unvisited explicit |
||||
|
branch point(s). Category: unused by cmdlets. Disposition: Open. |
||||
|
|
||||
|
Classes: FileSystemAccessRule2, FileSystemAccessRule2/<>c__DisplayClass36_0, |
||||
|
FileSystemAccessRule2/<>c__DisplayClass36_1, FileSystemAuditRule2, |
||||
|
FileSystemAuditRule2/<>c__DisplayClass7_0, |
||||
|
FileSystemAuditRule2/<>c__DisplayClass7_1. |
||||
|
|
||||
|
Evidence: reading the source. |
||||
|
|
||||
|
Why: The methods run: the cmdlets call RemoveFileSystemAccessRuleAll and |
||||
|
RemoveFileSystemAuditRuleAll without an account list. Only the branch for a |
||||
|
non-null list is unvisited, and no cmdlet reaches it. That branch discards the |
||||
|
result of the LINQ Where that should filter the rules, so the account list has |
||||
|
no effect and every explicit entry is removed (the deferred #113 finding); its |
||||
|
predicate, Count() > 1, would also match no account that appears once, so |
||||
|
using the result as it stands would remove nothing. |
||||
|
|
||||
|
Residual risk: Library users who pass accounts lose all entries. A test of the |
||||
|
intended behavior would fail; the defect stays with the maintainer (Decision |
||||
|
16 and #113). |
||||
|
|
||||
|
Related tests: None for the account list, on purpose; the cmdlets that call |
||||
|
the methods without one are tested. |
||||
|
|
||||
|
### TESTPATH-DEAD-CATCH |
||||
|
|
||||
|
1 method(s), 6 unvisited sequence point(s), 0 unvisited explicit |
||||
|
branch point(s). Category: defensive. Disposition: Explained. |
||||
|
|
||||
|
Classes: TestPath2. |
||||
|
|
||||
|
Evidence: an executed probe. |
||||
|
|
||||
|
Why: The catch (FileNotFoundException) is dead: TryGetFileSystemInfo2 returns |
||||
|
false for a missing item and never throws it. The catch |
||||
|
(NotSupportedException) was not reproduced with a colon, wildcard, device, |
||||
|
long or trailing-dot path in either edition (probes); the ArgumentException of |
||||
|
Windows PowerShell for illegal characters is covered. |
||||
|
|
||||
|
Residual risk: Low: both branches write $false or an error for a path that |
||||
|
cannot exist. |
||||
|
|
||||
|
Related tests: ItemCmdlets.Tests: Test-Path2 with illegal characters and the |
||||
|
debug message. |
||||
|
|
||||
|
### TOKEN-NATIVE-FAILURE |
||||
|
|
||||
|
6 method(s), 11 unvisited sequence point(s), 10 unvisited explicit |
||||
|
branch point(s). Category: environment-specific. Disposition: Explained. |
||||
|
|
||||
|
Classes: AccessTokenHandle, Privileges, ProcessHandle. |
||||
|
|
||||
|
Evidence: an executed probe. |
||||
|
|
||||
|
Why: The unvisited points are the branches that throw a Win32Exception when a |
||||
|
native call fails, and the branches for a size query that succeeds, which it |
||||
|
never does. They are OpenProcessToken in the constructor of AccessTokenHandle |
||||
|
(internal; the token of an exited process still opens, probe), CloseHandle in |
||||
|
ReleaseHandle (ProcessHandle is only created with ownsHandle false, so Windows |
||||
|
never releases it, and AccessTokenHandle releases a handle that it opened |
||||
|
itself), LookupPrivilegeValue in GetLuid (the names come from a fixed |
||||
|
dictionary), LookupPrivilegeName in GetPrivilegeName (the LUIDs come from the |
||||
|
token), and the second GetTokenInformation call after the size query. A test |
||||
|
would have to corrupt the token handle of the process or run on a Windows |
||||
|
version that lacks a privilege. The failures that a caller can cause, a handle |
||||
|
without the right to query or to adjust privileges, run in the Privileges |
||||
|
tests. |
||||
|
|
||||
|
Residual risk: Low: each branch throws the Win32Exception of the failed call, |
||||
|
the shape that the covered branches assert. |
||||
|
|
||||
|
Related tests: Privileges.Tests: a handle that may only query cannot enable a |
||||
|
privilege, and a handle that may only adjust cannot be queried. |
||||
|
|
||||
|
### UNUSED-CMDLET-HELPER |
||||
|
|
||||
|
2 method(s), 8 unvisited sequence point(s), 4 unvisited explicit |
||||
|
branch point(s). Category: unused by cmdlets. Disposition: Explained. |
||||
|
|
||||
|
Classes: BaseCmdlet. |
||||
|
|
||||
|
Evidence: a static scan of the compiled code. |
||||
|
|
||||
|
Why: A protected helper or an empty override without a caller in the four |
||||
|
assemblies (static IL scan): the System.IO variant of |
||||
|
BaseCmdlet.GetFileSystemInfo, which every cmdlet replaces with |
||||
|
GetFileSystemInfo2, and the empty BaseCmdlet.ProcessRecord that every cmdlet |
||||
|
overrides. Neither can be called from a test without a running cmdlet that |
||||
|
derives from the class, and they stay until the maintainer decides about |
||||
|
unused classes (Decisions 21 and 22). The scan misses generic instantiations: |
||||
|
it called the Extensions.ForEach and GetParent helpers unused although cmdlets |
||||
|
call them, so they are tested directly now (ObjectApis.Tests) and no longer |
||||
|
listed here. |
||||
|
|
||||
|
Residual risk: Low: not reachable from a cmdlet. |
||||
|
|
||||
|
Related tests: None needed; ObjectApis.Tests runs the public Extensions |
||||
|
helpers. |
||||
|
|
||||
|
### WIN32-AUTHZ-FAILURE |
||||
|
|
||||
|
4 method(s), 5 unvisited sequence point(s), 5 unvisited explicit |
||||
|
branch point(s). Category: environment-specific. Disposition: Explained. |
||||
|
|
||||
|
Classes: Win32. |
||||
|
|
||||
|
Evidence: an executed probe. |
||||
|
|
||||
|
Why: The unvisited points are the failures of the Authz calls other than the |
||||
|
two answers that the module expects of an unreachable computer, RPC server |
||||
|
unavailable and endpoint not registered, and the failure of the local resource |
||||
|
manager that follows. Every server name that a test can give, an empty one, |
||||
|
one with a space, backslashes, a colon, a bracket, 300 characters, malformed |
||||
|
and well-formed addresses, ends in one of the two expected answers (probe), |
||||
|
and the local resource manager does not fail. A test would need a remote |
||||
|
computer that answers with another error. |
||||
|
|
||||
|
Residual risk: Low: the cmdlet reports the exception as |
||||
|
GetEffectiveAccessError, the shape that the covered unresolved-identity case |
||||
|
asserts. |
||||
|
|
||||
|
Related tests: Access.Tests: an unresolved identity, a computer that cannot be |
||||
|
reached, names of this computer, an empty name. |
||||
|
|
||||
|
### WIN32-LOCAL-NAME-LOOKUP |
||||
|
|
||||
|
1 method(s), 2 unvisited sequence point(s), 0 unvisited explicit |
||||
|
branch point(s). Category: defensive. Disposition: Explained. |
||||
|
|
||||
|
Classes: Win32. |
||||
|
|
||||
|
Evidence: reading the source. |
||||
|
|
||||
|
Why: The unvisited points are the catch of a NetworkInformationException from |
||||
|
the lookup of the host and domain name of this computer, which returns false. |
||||
|
The lookup reads the local TCP/IP parameters, which this host has, and a test |
||||
|
cannot remove them. |
||||
|
|
||||
|
Residual risk: None found: the name is then treated as another computer, with |
||||
|
the warning. |
||||
|
|
||||
|
Related tests: Access.Tests: names of this computer, a computer that cannot be |
||||
|
reached, an empty name. |
||||
|
|
||||
|
### WIN32-RAW-DESCRIPTOR |
||||
|
|
||||
|
2 method(s), 14 unvisited sequence point(s), 2 unvisited explicit |
||||
|
branch point(s). Category: unused by cmdlets. Disposition: Explained. |
||||
|
|
||||
|
Classes: Win32. |
||||
|
|
||||
|
Evidence: a static scan of the compiled code. |
||||
|
|
||||
|
Why: Win32 is an internal class. GetRawSecurityDescriptor is private and has |
||||
|
no caller, and the only caller of GetByteSecurityDescriptor is |
||||
|
GetRawSecurityDescriptor (static IL scan). They read a descriptor from a |
||||
|
handle, which the module does through AlphaFS. |
||||
|
|
||||
|
Residual risk: None found: dead code. Removing it is a maintainer decision |
||||
|
(Decisions 21 and 22). |
||||
|
|
||||
|
Related tests: None needed. |
||||
|
|
Can't render this file because it contains an unexpected character in line 1 and column 7.
|
|
|
@ -0,0 +1,649 @@ |
|||||
|
# Quality-gate path review, 2026-10-09 |
||||
|
|
||||
|
Handoff 1 of the quality gate before 5.0.0. The C# code that no test visited |
||||
|
at `3442194` was inventoried again. Each path is now covered by a behavior |
||||
|
test or explained from source, and the evidence of every explanation is |
||||
|
named. The work is on `ai/quality-gate-paths`, from the reviewed head |
||||
|
`f11ff41` of `ai/quality-gate-coverage` (#117). Nothing was pushed, merged, |
||||
|
tagged, or published; the stable version stays 4.2.6 and rc6 is the latest |
||||
|
published candidate. Cmdlet design and the fate of unused classes stay with |
||||
|
the maintainer (Decisions 16, 21, and 22; Decision 22 is still proposed). |
||||
|
|
||||
|
This report does not close the quality gate. A coverage percentage never |
||||
|
closes it, the [open items](#open-items-for-the-maintainer) remain, and the |
||||
|
fixes below still need the lab acceptance of gate 3. |
||||
|
|
||||
|
## Candidate and source identity |
||||
|
|
||||
|
- Measured source: `5a5d58b` (`5a5d58b88c639ca560bc1e66b17d57cc6e682eca`). |
||||
|
Two commits follow it and change no test and no executable code: the |
||||
|
first rewords a code comment in `NTFSSecurity\BaseCmdlets.cs` and one |
||||
|
sentence of the `Get-ChildItem2` page with its generated help, as the last |
||||
|
review passes asked; the second adds this report, its tables (with the |
||||
|
red/green rows), the appendix, and the Memory Bank notes. |
||||
|
- Base `f11ff41`. Since then 26 commits up to the measured source (8 `fix`, |
||||
|
1 `refactor`, 16 `test`, 1 `docs`): 40 files, 3,708 insertions and 80 |
||||
|
deletions. Production code is 18 files with 277 insertions and 52 |
||||
|
deletions (the four projects, without the generated help); the rest is |
||||
|
tests, documentation, and help. |
||||
|
- Build: Release, .NET Framework 4.5.2, 0 errors and 317 warnings: 296 of |
||||
|
CS1591, 19 of CS1574, one of CS0169, and one of CS0618. All are legacy and |
||||
|
none was suppressed. |
||||
|
- Tools: Windows PowerShell 5.1.26100, PowerShell 7.6.6, Pester 5.7.1, |
||||
|
AltCover 9.0.145 (net472). |
||||
|
- The measured assemblies are the Release files that the Validate run used. |
||||
|
AltCover saved them unchanged before it instrumented them: |
||||
|
|
||||
|
| Assembly | SHA-256 | |
||||
|
| --- | --- | |
||||
|
| `NTFSSecurity.dll` | `155DE103C14A7CC69BA179D0AE4EEFA81DCF6F43A34B1856BC01393DDB76D443` | |
||||
|
| `Security2.dll` | `F6E4B6340F55A070B8E7D7678B7A6289E1A8430D393618EC269A315D70F32FC7` | |
||||
|
| `ProcessPrivileges.dll` | `7D037B32AED6C5879993C431F3EB614D61A621C8AF3474B59A6E0A5C451E3016` | |
||||
|
| `PrivilegeControl.dll` | `06FBD3337FAB9CE541F9030803468B7759935E152FB814E99F1C15CE9FA82D73` | |
||||
|
|
||||
|
The Release build is not byte-reproducible: another build of the same source |
||||
|
gives other hashes, so these identify the measured files, not the source. |
||||
|
|
||||
|
## Local validation |
||||
|
|
||||
|
Four configurations in separate processes with the CI wrappers: Windows |
||||
|
PowerShell 5.1 and PowerShell 7, each elevated and as a basic user through |
||||
|
a restricted token. Each discovered 1,310 test cases (914 at `f11ff41`), |
||||
|
and no test failed. The instrumented coverage runs that followed gave the same |
||||
|
counts. |
||||
|
|
||||
|
| Configuration | Passed | Failed | Skipped | Total | |
||||
|
| --- | ---: | ---: | ---: | ---: | |
||||
|
| Windows PowerShell 5.1, elevated | 1,286 | 0 | 24 | 1,310 | |
||||
|
| Windows PowerShell 5.1, basic user | 1,076 | 0 | 234 | 1,310 | |
||||
|
| PowerShell 7, elevated | 1,255 | 0 | 55 | 1,310 | |
||||
|
| PowerShell 7, basic user | 1,045 | 0 | 265 | 1,310 | |
||||
|
|
||||
|
Skip eligibility is checked by row, not by name. NUnit keeps the placeholders |
||||
|
of a skipped data row, so skipped and executed names do not match. Each |
||||
|
configuration ran the suite once more in one Pester process, and every row |
||||
|
was recorded with its file, line, path, name, data, and result. The skipped |
||||
|
rows were 24, 234, 55, and 265, the same counts as in the validation runs. |
||||
|
The 578 skipped rows are 137 distinct tests, 350 of them rows with data. |
||||
|
Every skipped row has the same test, with the same data, executed in two |
||||
|
other configurations; no skipped row lacks an executed counterpart. |
||||
|
|
||||
|
## Coverage |
||||
|
|
||||
|
AltCover 9.0.145 OpenCover report of the frozen source, with the method of |
||||
|
the `3442194` baseline: the four configurations ran one after the other |
||||
|
without `--save`, from copied Release assemblies with their PDBs, AlphaFS and |
||||
|
System.Management.Automation excluded, and `runner --collect` recalculated |
||||
|
the report. The ratios are visited sequence or branch points divided by their |
||||
|
totals, not unique source lines. |
||||
|
|
||||
|
| Assembly | Sequence points | Sequence coverage | Branch points | Branch coverage | |
||||
|
| --- | ---: | ---: | ---: | ---: | |
||||
|
| NTFSSecurity | 1,912/2,168 | 88.19% | 683/1,094 | 62.43% | |
||||
|
| Security2 | 1,055/1,225 | 86.12% | 506/742 | 68.19% | |
||||
|
| ProcessPrivileges | 205/219 | 93.61% | 72/125 | 57.60% | |
||||
|
| PrivilegeControl | 20/22 | 90.91% | 12/17 | 70.59% | |
||||
|
| Aggregate | 3,192/3,634 | 87.84% | 1,273/1,978 | 64.36% | |
||||
|
|
||||
|
| Measurement | Sequence points | Branch points | |
||||
|
| --- | ---: | ---: | |
||||
|
| `3442194`, the baseline of this work | 2,641/3,559 (74.21%) | 974/1,933 (50.39%) | |
||||
|
| `73a0a7e` | 2,826/3,563 (79.32%) | 1,091/1,935 (56.38%) | |
||||
|
| `360417a` | 2,860/3,566 (80.20%) | 1,105/1,939 (56.99%) | |
||||
|
| `51412e0` | 3,067/3,566 (86.01%) | 1,192/1,939 (61.48%) | |
||||
|
| `d0acda3` | 3,079/3,566 (86.34%) | 1,207/1,939 (62.25%) | |
||||
|
| `3c19747` | 3,133/3,592 (87.22%) | 1,250/1,966 (63.58%) | |
||||
|
| `ae3078f` | 3,134/3,592 (87.25%) | 1,252/1,966 (63.68%) | |
||||
|
| `40bf6a8` | 3,166/3,626 (87.31%) | 1,257/1,977 (63.58%) | |
||||
|
| `a50070a` | 3,168/3,626 (87.37%) | 1,259/1,977 (63.68%) | |
||||
|
| `7aa8315` | 3,168/3,626 (87.37%) | 1,257/1,975 (63.65%) | |
||||
|
| `d61dffa` | 3,192/3,634 (87.84%) | 1,273/1,978 (64.36%) | |
||||
|
| `5a5d58b`, final | 3,192/3,634 (87.84%) | 1,273/1,978 (64.36%) | |
||||
|
|
||||
|
- The sequence-point ratio rose from 74.21% to 87.84%, and the number of |
||||
|
unvisited points fell from 918 to 442. The production code changed as well |
||||
|
(3,559 to 3,634 points, because of the fixes), so the ratios are not |
||||
|
increments of one source. |
||||
|
- The branch summary counts 820 compiler-generated points (patterns such as |
||||
|
`foreach` and `using`), of which only 185 are visited. The explicit branch |
||||
|
points that a person wrote are 1,088/1,158 (93.96%). |
||||
|
- The interim measurements stopped at the commits that are named; they show |
||||
|
the progress, not a different method. `d61dffa` and `5a5d58b` give the same |
||||
|
counts: the commits between them changed tests, documentation, and the |
||||
|
wording of one code comment only. |
||||
|
|
||||
|
## What the work changed |
||||
|
|
||||
|
### Tests |
||||
|
|
||||
|
136 `It` blocks were added; with data rows the suite grew from 914 to |
||||
|
1,310 cases per configuration. The tests assert state on disk, error ID, |
||||
|
category and target, continuation after a failure, the owner of the item, and |
||||
|
that a failed item writes no success-shaped object. |
||||
|
|
||||
|
| Area | Test files | New `It` blocks | |
||||
|
| --- | --- | ---: | |
||||
|
| Public rule, identity, descriptor, and comparison objects, inheritance helpers, privilege objects, the extension methods | ObjectApis | 46 | |
||||
|
| Token handles, the privilege enabler and its finalizer, privilege control, the Init script, a privilege that a later command interrupts | Privileges | 17 | |
||||
|
| Item cmdlets, filters and their dot rules, depth, links, hard links, root-drive changes, disk space | ItemCmdlets, DriveRoot, Links | 24 | |
||||
|
| Access, audit, inheritance, owner, hash, and path errors; deny entries without rights; a NULL DACL | Access, Audit, Inheritance, Owner, FileHash, PathErrors | 25 | |
||||
|
| Security descriptors | SecurityDescriptor | 3 | |
||||
|
| A later command that ends the pipeline, for all 30 cmdlets and for the verbose, debug, and error streams | PipelineControl | 15 | |
||||
|
| The test helpers | TestHelpers | 6 | |
||||
|
|
||||
|
### Defects found and fixed |
||||
|
|
||||
|
| # | Defect | Fixed in | Regression guard | |
||||
|
| --- | --- | --- | --- | |
||||
|
| 1 | Public rule constructors lost the supplied path; simplified audit entries kept `ReadData` and were compared with access entries; boxed privilege values were compared wrongly | `b14c90b` | ObjectApis.Tests; 8 rows in each configuration | |
||||
|
| 2 | `Clear-NTFSAccess -DisableInheritance` and `Set-NTFSSecurityDescriptor` reported `RestoreOwnerError` for an owner that had not changed | `c7a0383` | PathErrors and SecurityDescriptor tests; 2 rows in each configuration | |
||||
|
| 3 | `InheritedFrom` read `unknown paren` and showed it for explicit entries | `2909a1c` | Access and Audit tests; 2 rows in the elevated configurations and 1 in the basic ones (the audit row needs the privilege) | |
||||
|
| 4 | Nine cmdlets handled the end of the pipeline (`break`, `continue`, `Select-Object -First`) as a failure of the item and went on; `Remove-Item2 -PassThru \| Select-Object -First 1` removed every item | `c77ecbf` | PipelineControl.Tests; 42 rows in each configuration: 35 for the nine cmdlets, 2 for the error of a nested folder, and 5 that pin the new check by type | |
||||
|
| 5 | `Get-ChildItem2 -Filter` read a bracket as a character class, so `Report[1].txt` was not found by its name | `ee7c105` | ItemCmdlets.Tests; 1 row in each configuration | |
||||
|
| 6 | A null `-Filter` ended in a `NullReferenceException`; introduced by fix 5 and never released | `ae3078f` | ItemCmdlets.Tests; 1 row in each configuration | |
||||
|
| 7 | A `throw` of a later command reaches a cmdlet through its Write call as an ordinary exception; the catch for the failures of an item reported it as the error of the item and went on, so that `Remove-Item2` removed the next item and the caller never saw the exception. Fix 4 found only the end of the pipeline by its type. `Set-NTFSSecurityDescriptor`, `Get-FileHash2`, and `Set-NTFSOwner` caught it also at a verbose or debug message | `40bf6a8` | PipelineControl.Tests; 16 rows in each configuration: 9 for a `throw` of the later command, 5 for a verbose or debug message, and 2 for a thrown type that a cmdlet handles. The rows for `Write-Error -ErrorAction Stop` of the later command turn green with fix 4, not with this one | |
||||
|
| 8 | `Get-ChildItem2 -Filter *.*` dropped the items without a dot in their names, most folders among them, so a listing with that filter missed them | `40bf6a8` | ItemCmdlets.Tests; 1 row in each configuration; found by a probe and by the independent review | |
||||
|
| 9 | The failed lookup of `InheritedFrom` leaked its native buffer | `40bf6a8` | none: no observable behavior, and the fallback tests run the path | |
||||
|
| 10 | `Get-ChildItem2` swallowed what a later command threw for an error that the cmdlet wrote for a nested folder, for example `Get-ChildItem2 -Recurse -File 2>&1 \| ForEach-Object { throw 'x' }`: the recursion took it for a failure of the folder above, wrote a verbose message, and ended the listing early, and the caller never saw the exception | `d44a200` | PipelineControl.Tests; 1 row in each configuration. The `break`, `continue`, and `-ErrorAction Stop` cases on the same error pass before and after | |
||||
|
| 11 | A cmdlet that enables the privileges left a privilege enabled when a later command ended the pipeline (`5>&1 \| Select-Object -First 2`) or threw at the debug message after the enabling: it noted the privilege only after that message, and `TryEnablePrivilege` took the exception for a failure to enable it and went on, so that all four privileges stayed enabled and the exception was lost | `d44a200` | Privileges.Tests; 2 rows in the elevated configurations (they need the privileges) | |
||||
|
|
||||
|
Fixes 4, 7, and 10 changed the catch blocks of 10 cmdlets (`Get-DiskSpace` |
||||
|
now writes outside its try). Fixes 7 and 10 added a record of the exception |
||||
|
that the `WriteObject`, `WriteError`, `WriteVerbose`, and `WriteDebug` |
||||
|
methods of `BaseCmdlet` raise, which every catch-all that writes directly |
||||
|
passes on. `WriteWarning` is not noted: no catch-all encloses it. A scan of |
||||
|
the source finds 85 catch-all handlers under `NTFSSecurity\`. Sixteen of |
||||
|
them enclose a Write call directly in their try block: eleven pass the |
||||
|
exception on, and the other five are the Write methods themselves, which |
||||
|
record it and rethrow. Seventeen enclose only a helper, `InvokeAsOwner` or |
||||
|
`WriteChangesAsOwner`, whose owner restore can write a `RestoreOwnerError`; |
||||
|
they do not pass the exception on (open item 8). The scan is a text match |
||||
|
and cannot see a write inside another helper. The type check |
||||
|
`PipelineControl.IsEnd` remains as a second line of defense (open item 9). |
||||
|
|
||||
|
### Red before, green after |
||||
|
|
||||
|
The last column of the table above counts the test rows that fail on the |
||||
|
production code before a fix and pass after it. The first runs that showed |
||||
|
this were taken as each fix was written, with the tests of that commit. Their |
||||
|
logs were deleted with the temporary run folders, so their counts could not be |
||||
|
reproduced, and they are not used here. The evidence was measured again with |
||||
|
the final tests. |
||||
|
|
||||
|
The eight test files that guard the fixes (ObjectApis, Access, Audit, |
||||
|
PathErrors, SecurityDescriptor, PipelineControl, ItemCmdlets, and Privileges: |
||||
|
650 cases per configuration) were laid over the production code of ten states |
||||
|
of the branch: the base `f11ff41`, the commit of each fix, the refactoring |
||||
|
`7aa8315`, and `d44a200`, the last commit that changes behavior. Each state was |
||||
|
built in Release in a separate worktree and run in the four configurations |
||||
|
with the focused runner of this work, the one that the mutation rounds use. It |
||||
|
runs only these eight files with its own Pester configuration, sets |
||||
|
`$ErrorActionPreference` to `Stop` like the CI wrappers, starts the basic runs |
||||
|
with `runas /trustlevel:0x20000`, and writes no NUnit file. A row that fails |
||||
|
at a state and passes at the next one is a guard of the fix between them; a |
||||
|
row that passes before and fails after would be a break. |
||||
|
|
||||
|
| Step | Defects | Rows red before and green after: elevated 5.1, elevated 7, basic 5.1, basic 7 | Test files | |
||||
|
| --- | --- | --- | --- | |
||||
|
| Rows red at the base `f11ff41` | all | 76, 76, 73, 73 | | |
||||
|
| `f11ff41` to `b14c90b` | 1 | 8, 8, 8, 8 | ObjectApis | |
||||
|
| `b14c90b` to `c7a0383` | 2 | 2, 2, 2, 2 | PathErrors, SecurityDescriptor | |
||||
|
| `c7a0383` to `2909a1c` | 3 | 2, 2, 1, 1 | Access, Audit | |
||||
|
| `2909a1c` to `c77ecbf` | 4 | 42, 42, 42, 42 | PipelineControl | |
||||
|
| `c77ecbf` to `ee7c105` | 5 | 1, 1, 1, 1 | ItemCmdlets | |
||||
|
| `ee7c105` to `ae3078f` | 6 | 1, 1, 1, 1 | ItemCmdlets | |
||||
|
| `ae3078f` to `40bf6a8` | 7, 8, 9 | 17, 17, 17, 17 | ItemCmdlets, PipelineControl | |
||||
|
| `40bf6a8` to `7aa8315` | none (refactoring) | 0, 0, 0, 0 | none | |
||||
|
| `7aa8315` to `d44a200` | 10, 11 | 3, 3, 1, 1 | PipelineControl, Privileges | |
||||
|
| Rows red at `d44a200`, the control | none | 0, 0, 0, 0 | | |
||||
|
|
||||
|
The steps add up to the rows that are red at the base, and no row passes at one |
||||
|
state and fails at the next in any configuration. At `d44a200` no row of the |
||||
|
650 fails in any configuration; the production code after it differs only in |
||||
|
an XML comment of `BaseCmdlets.cs`. The counts are of rows, not of names: |
||||
|
three rows that Pester lists under one unexpanded template name (see below) |
||||
|
make a count of names two lower for the first four states. In all 40 logs (10 |
||||
|
states, 4 configurations), the failed count of the `RESULT` line, the number |
||||
|
of `FAILEDTEST` lines, and the rows in the CSV file agree. The commit that |
||||
|
each state was built from is the `source=` line of its build log, and it equals |
||||
|
the commit that the table names for all ten states; the assemblies of the |
||||
|
states were not hashed, because each build rewrote the hash file of the |
||||
|
frozen runner. |
||||
|
[`Quality-Gate-Paths-2026-10-09-RedGreen.csv`](Quality-Gate-Paths-2026-10-09-RedGreen.csv) |
||||
|
lists each guard test with its test file and its rows per configuration, and |
||||
|
[`Quality-Gate-Paths-2026-10-09-RedGreen-Logs.csv`](Quality-Gate-Paths-2026-10-09-RedGreen-Logs.csv) |
||||
|
lists the 40 logs with their sizes and SHA-256 values. |
||||
|
|
||||
|
- Of the 42 rows of fix 4, 37 call the cmdlets. The other five pin the new |
||||
|
check by type, `IsEnd`, through reflection, and are red before only because |
||||
|
that check did not exist. Three of them are the rows of one data-driven |
||||
|
test; the `BeforeAll` of their block fails without the type, so Pester |
||||
|
lists them under the unexpanded template name. |
||||
|
- The 17 rows of the step to `40bf6a8` are 16 for defect 7 and one for defect |
||||
|
8, the `*.*` row. Defect 9 has no row. |
||||
|
- The audit row of fix 3 and the two rows of fix 11 need the privileges. They |
||||
|
are skipped in the basic configurations, and the eligibility check shows |
||||
|
that each of them is executed in the elevated ones. |
||||
|
- The matrix ran the verbose and debug rows of defect 7 as they are, with |
||||
|
`-ErrorAction SilentlyContinue`. They need it: under `Stop` and without it, |
||||
|
a handler that reports the exception of the later command as an error of the |
||||
|
item ends the pipeline with it, and the row cannot tell that from passing |
||||
|
the exception on (the mutations M19 and M20 escaped for this reason at |
||||
|
`d61dffa`, see below). |
||||
|
- One of these rows fails without a message before fix 7, in all four |
||||
|
configurations: `Set-NTFSSecurityDescriptor`, verbose message, |
||||
|
`Select-Object -First 1`. The other rows name what they expected. The |
||||
|
matrix alone does not say why this one fails. The attribution to fix 7 rests |
||||
|
on the source and on a mutation: at `ae3078f` the verbose message is written |
||||
|
inside a try whose catch reports the exception as `WriteSdError` and goes on |
||||
|
(`SetSecurityDescriptor.cs`, lines 47, 51, and 68), and the mutation M16, |
||||
|
which removes that pass-on at the final source, fails this row with the same |
||||
|
empty message in all four configurations. |
||||
|
- The matrix lays the final tests over earlier production code. It shows that |
||||
|
a guard fails without its fix and passes with it, not how the test looked |
||||
|
when it was first written, and a test that needs two fixes flips at the |
||||
|
later step. The rows that pass at the base are characterization tests and |
||||
|
tests of behavior that no fix changed; the mutations of the next section, |
||||
|
not this matrix, show that they detect a change. |
||||
|
|
||||
|
### Where a test or a classification was wrong |
||||
|
|
||||
|
These cases are why an explanation below is a claim with evidence, not a |
||||
|
fact. |
||||
|
|
||||
|
- The first version of the restored-owner test passed without reaching the |
||||
|
retry, because a deny entry for the user does not stop an owner. It now |
||||
|
uses an OWNER RIGHTS deny and asserts that a plain write is denied. |
||||
|
- The `continue` after the second name comparison of `Get-ChildItem2` was |
||||
|
first classified as defensive. A probe showed that `*.*` reaches it, which |
||||
|
led to defect 8. |
||||
|
- A mutation that removed that comparison escaped at `630926f`, and |
||||
|
checking why exposed defect 5. |
||||
|
- The throw rows that the independent review asked for exposed defect 7. |
||||
|
- The review claimed that `Get-ChildItem2 -Recurse -ErrorAction Stop` |
||||
|
swallows the error of a nested folder. A probe on the build of `7aa8315` |
||||
|
showed that this error reaches the caller in both editions (a pipeline |
||||
|
stop, which the catch already passes on). The same cause was real for a |
||||
|
`throw` of a later command that takes the error through `2>&1`, which is |
||||
|
defect 10; the review withdrew the claim. |
||||
|
- Three tests that take the error of a nested folder through `2>&1` relied |
||||
|
on the default error action and failed in the first frozen run, because |
||||
|
the CI wrappers set `$ErrorActionPreference` to `Stop`. The focused runner |
||||
|
of this work had not set it. It does now, and the tests name |
||||
|
`-ErrorAction Continue`. |
||||
|
- With the runner at `Stop`, the mutation rounds at `d61dffa` showed that the |
||||
|
verbose and debug `throw` rows of the later-command tests ran without an |
||||
|
error action. Under `Stop`, the handler that reports the exception of the |
||||
|
later command as an item error ends the pipeline with it, and the row |
||||
|
cannot tell that from passing the exception on. Two mutations escaped (the |
||||
|
verbose record in two configurations, the debug record in all four). The |
||||
|
red evidence of defect 7 for these rows had been taken with the default |
||||
|
preference, so at CI they would not have been red. The rows now name |
||||
|
`-ErrorAction SilentlyContinue`, the rounds ran again, and the matrix above |
||||
|
shows these rows red before `40bf6a8` under a runner that sets `Stop`. |
||||
|
- The first Init-script tests asserted only the state of the Backup |
||||
|
privilege. With the module setting `$true` the module enables the |
||||
|
privileges before the cmdlet runs, so the branch that the test names could |
||||
|
not fail it; the review predicted the mutation that then escaped (the |
||||
|
condition of that branch). The tests now also assert the verbose message |
||||
|
that only the cmdlet writes. |
||||
|
- A test variable named `$forEach` is the automatic variable of `foreach` |
||||
|
and was empty inside Pester. |
||||
|
- A `Get-Acl` precondition for the hard-link test failed in the elevated |
||||
|
configurations: the permissions were read despite the deny entries. The |
||||
|
claim about them was dropped instead of asserted. |
||||
|
- Explanation rules that were wrong, or too strong, before they were |
||||
|
checked by probes and the review: a rule listed the `Extensions.ForEach` |
||||
|
and `GetParent` helpers as unused (the static scan does not see calls of |
||||
|
generic methods, which hid the callers of `ForEach`, and the rule did not |
||||
|
use the result of the scan for `GetParent`, which `Get-NTFSSimpleAccess` |
||||
|
calls), so both are tested directly now; the catch-all rule said that no |
||||
|
input could trigger it (a deny entry without rights does, and so does a |
||||
|
full ACL); the effective-access rule said that the library never throws |
||||
|
(the descriptor read is outside its try); the retry of the hash cmdlet |
||||
|
cannot be made to succeed with an OWNER RIGHTS entry, because Windows drops |
||||
|
it when the owner changes (probe); a dangling junction is read as the link |
||||
|
itself and triggers nothing (probe); a NULL DACL does reach the branch that |
||||
|
was called unreachable, and is tested; the hard-link rule said that no file |
||||
|
ACL is checked, which the probe shows only for the refused data. |
||||
|
|
||||
|
## Do the new tests detect faults? |
||||
|
|
||||
|
Bounded mutations change one statement of a frozen copy of the source, rebuild |
||||
|
it, and run the guarding tests in the four configurations. A mutation is |
||||
|
detected when its guard test fails. The source is restored exactly (the |
||||
|
diff of the frozen copy is empty) and Release is rebuilt before any green |
||||
|
validation. The mutations of a round are applied together only when no |
||||
|
guard can fail because of another mutation of the round; the failures that |
||||
|
no guard of the round names are listed in the logs and are the collateral of |
||||
|
the mutations (for example, the `throw` rows next to the `Select-Object` rows |
||||
|
that guard the same catch). |
||||
|
|
||||
|
Final rounds on the frozen source `5a5d58b`: 26 mutations in four rounds. |
||||
|
Twenty-five are detected by their guard test in every configuration where |
||||
|
that test runs (2 of 2 for the guards that need the privileges). The 26th, |
||||
|
M25, is an equivalent mutant and survives as expected: with the check by type |
||||
|
(`IsEnd`) removed from `IsFromLaterCommand`, the recording of the write |
||||
|
methods still passes on every exception that a test can raise, so no test |
||||
|
fails; the direct tests of `IsEnd` pin its rules (open item 9). The failures |
||||
|
that no guard of a round names are the other tests of the mutated code: in |
||||
|
round 3, the Init test for `$false` under M28, the privilege test that |
||||
|
throws at the debug message under M26, and the audit test for the error |
||||
|
category under M34. M25 has none. |
||||
|
|
||||
|
| Round | Mutation | File | Change | Guard | Detected | |
||||
|
| ---: | --- | --- | --- | --- | --- | |
||||
|
| 1 | M10 | `BaseCmdlets.cs` | `IsEnd` no longer recognizes a flow-control exception by the name of its base type | Should recognize an exception whose base type is the flow control exception of PowerShell | 4/4 | |
||||
|
| 1 | M12 | `GetChildItem2.cs` | `[ValidateNotNull]` removed from `-Filter` | Should reject a null -Filter | 4/4 | |
||||
|
| 1 | M13 | `SetSecurityDescriptor.cs` | the previous owner is not set back after a write that took ownership | Should set a previous owner back that the user can assign after the write that took ownership | 2/2 | |
||||
|
| 1 | M14 | `FileSystemAccessRule2.RemoveFileSystemAccessRules.cs` | a deny entry is not removed from a descriptor | Remove-NTFSAccess should remove a deny entry from the descriptor and leave the item unchanged | 4/4 | |
||||
|
| 1 | M16 | `SetSecurityDescriptor.cs` | the catch no longer passes on the exception of a later command | Set-NTFSSecurityDescriptor should stop at the verbose message for Select-Object -First 1 of the later command | 4/4 | |
||||
|
| 1 | M17 | `GetFileHash2.cs` | the catch no longer passes on the exception of a later command | Get-FileHash2 should stop at the verbose message for Select-Object -First 1 of the later command | 4/4 | |
||||
|
| 1 | M22 | `GetChildItem2.cs` | the second comparison of the name with the pattern is skipped | Should return only the items that match the whole pattern for -Filter *.*.* | 4/4 | |
||||
|
| 1 | M23 | `BaseCmdlets.cs` | `WriteError` no longer records its exception | Should pass on what a later command throws when it takes the error of a nested folder | 4/4 | |
||||
|
| 1 | M27 | `BaseCmdlets.cs` | `TryEnablePrivilege` no longer passes on the exception of a later command | Should pass on what a later command throws at the message after the enabling and disable the privileges | 2/2 | |
||||
|
| 2 | M11 | `GetChildItem2.cs` | `*.*` is no longer treated as `*` | Should return every item for -Filter *.*, also the ones without a dot in their names | 4/4 | |
||||
|
| 2 | M18 | `BaseCmdlets.cs` | `WriteObject` no longer records its exception | Copy-Item2 should stop for a terminating error (throw) of the later command | 4/4 | |
||||
|
| 2 | M19 | `BaseCmdlets.cs` | `WriteVerbose` no longer records its exception | Get-FileHash2 should stop at the verbose message for throw of the later command | 4/4 | |
||||
|
| 2 | M20 | `BaseCmdlets.cs` | `WriteDebug` no longer records its exception | Set-NTFSOwner should stop at the debug message for throw of the later command | 4/4 | |
||||
|
| 2 | M7 | `GetChildItem2.cs` | the catch of the recursion no longer passes on the exception of a later command | Get-ChildItem2 should leave the loop for break after its first object | 4/4 | |
||||
|
| 2 | M8 | `RemoveItem2.cs` | the catch no longer passes on the exception of a later command | Remove-Item2 should leave the loop for break after its first object | 4/4 | |
||||
|
| 2 | M9 | `BaseCmdlets.cs` | `IsEnd` no longer recognizes `PipelineStoppedException` | Should recognize a PipelineStoppedException | 4/4 | |
||||
|
| 3 | M25 | `BaseCmdlets.cs` | `IsFromLaterCommand` without the check by type (`IsEnd`) | none: expected to survive | 0/4, as expected | |
||||
|
| 3 | M26 | `BaseCmdlets.cs` | `EnablePrivilege` notes the privilege after the debug message again | Should disable the privilege when Select-Object -First ends the pipeline at the message after its enabling | 2/2 | |
||||
|
| 3 | M28 | `OtherCmdlets.cs` | `Enable-Privileges` in the Init script tests `EnablePrivileges == false` | Should enable the privileges when the module setting EnablePrivileges is $true | 2/2 | |
||||
|
| 3 | M29 | `FileSystemAccessRule2.GetFileSystemAccessRules.cs` | the index guard of the `InheritedFrom` sources is removed | Should return the one entry that .NET reports for a NULL DACL, without a source | 4/4 | |
||||
|
| 3 | M30 | `Extensions.cs` | `ForEach` accepts a null source | ForEach should reject a source that is null | 4/4 | |
||||
|
| 3 | M31 | `Extensions.cs` | `GetParent` returns a `DirectoryInfo` for a parent path that names a file | Should return a parent path that names a file as a FileInfo of AlphaFS | 4/4 | |
||||
|
| 3 | M32 | `AddAccess.cs` | the loop goes on to `-PassThru` after an `AddAceError` | Add-NTFSAccess, a deny entry without rights: Should write an AddAceError | 4/4 | |
||||
|
| 3 | M33 | `RemoveAccess.cs` | another ID for the `RemoveAceError` | Remove-NTFSAccess, a deny entry without rights: Should write a RemoveAceError | 4/4 | |
||||
|
| 3 | M34 | `AddAudit.cs` | another category for the `AddAceError` | Add-NTFSAudit, rights None: Should write an AddAceError | 2/2 | |
||||
|
| 4 | M24 | `BaseCmdlets.cs` | `IsFromLaterCommand` is always false | Should leave the loop for a break of a later command that takes the error of a nested folder | 4/4 | |
||||
|
|
||||
|
Rounds at earlier commits found what these repeat. At `630926f`, nine of ten |
||||
|
mutations were detected; the one that escaped, the second name comparison of |
||||
|
`Get-ChildItem2`, exposed defect 5. At `ae3078f`, four more were detected. At |
||||
|
`a50070a`, seven of nine were detected: M11 escaped because another mutation |
||||
|
of the same round bypassed the same line (my overlap, so it moved to another |
||||
|
round), and M21 was an equivalent mutant, an exception filter that no |
||||
|
exception can reach, which `7aa8315` removed. At `d61dffa` the 26 mutations |
||||
|
above ran once and three escaped: M19 and M20, because the verbose and debug |
||||
|
rows ran under the error action of the CI runner, and M28, because the Init |
||||
|
test could not fail for the branch it names. `f4a16e1` fixed the tests, and |
||||
|
the rounds above ran again at the final source. Each round restored the |
||||
|
source exactly (the diff of the frozen copy was empty) and rebuilt Release. |
||||
|
|
||||
|
## The remaining unvisited code |
||||
|
|
||||
|
The aggregate report leaves 231 methods with 442 unvisited sequence points |
||||
|
and 70 unvisited explicit branch points; at `3442194` it left 918 sequence |
||||
|
points. Every method is classified and none is unclassified: 223 are |
||||
|
explained and 8 are open for the maintainer. The largest explained blocks |
||||
|
are the 103 one-line getters of cmdlet parameters, the registry model that no |
||||
|
cmdlet uses (105 points), the ownership retry of an audit write (47 points), |
||||
|
the unused native handle wrappers (34 points), and the catch-all of the |
||||
|
per-item loops (32 points). |
||||
|
|
||||
|
| Category | Disposition | Methods | Unvisited sequence points | Unvisited explicit branch points | |
||||
|
| --- | --- | ---: | ---: | ---: | |
||||
|
| unused by cmdlets | Explained | 60 | 173 | 34 | |
||||
|
| parameter/API surface | Explained | 103 | 103 | 0 | |
||||
|
| defensive | Explained | 33 | 77 | 16 | |
||||
|
| environment-specific | Explained | 27 | 81 | 18 | |
||||
|
| unused by cmdlets | Open | 8 | 8 | 2 | |
||||
|
| **Total** | | **231** | **442** | **70** | |
||||
|
|
||||
|
The explanation of every rule is in |
||||
|
[Quality-Gate-Paths-2026-10-09-Explanations.md](./Quality-Gate-Paths-2026-10-09-Explanations.md), |
||||
|
each with its evidence (an executed probe, a static scan of the compiled |
||||
|
code, or reading the source), its residual risk, and the tests that run the |
||||
|
neighboring paths. [The method rows](./Quality-Gate-Paths-2026-10-09-Methods.csv) |
||||
|
give, for every method, the source file, the unvisited lines, the number of |
||||
|
unvisited sequence and branch points, whether a cmdlet reaches it in the |
||||
|
compiled code, and its rule. "Explained" means a source-backed explanation |
||||
|
exists, not that a test runs the path. The scan behind the column "reachable |
||||
|
from a cmdlet" reads the compiled code and does not see calls of generic |
||||
|
methods: it reported `Extensions.ForEach` as unreachable although cmdlets |
||||
|
call it. Treat that column as a hint, not as evidence. |
||||
|
|
||||
|
## Per-cmdlet coverage |
||||
|
|
||||
|
[The cmdlet rows](./Quality-Gate-Paths-2026-10-09-Cmdlets.csv) give the |
||||
|
sequence and branch points of each of the 36 cmdlets, including their |
||||
|
closures. Every cmdlet has 72.5% or more of its sequence points visited. The |
||||
|
lowest are `Clear-NTFSAudit` (72.5%), `Disable-NTFSAuditInheritance` and |
||||
|
`Enable-NTFSAuditInheritance` (73.5%), `Add-NTFSAudit` (75.7%), and |
||||
|
`Remove-NTFSAudit` (75.9%): their unvisited points are the retry after an |
||||
|
access denial, which a local audit write never raises (rule |
||||
|
AUDIT-OWNER-RETRY). `Get-NTFSSecurityDescriptor` (77.8%) has the catch-all of |
||||
|
its loop and the closure of its owner retry, `Get-NTFSEffectiveAccess` |
||||
|
(80.3%) the catch blocks around a library that hides its own failures, and |
||||
|
`Get-NTFSInheritance` (80.9%) the catch and the retry of its loop. |
||||
|
|
||||
|
### Parameter sets |
||||
|
|
||||
|
The 36 cmdlets have 64 parameter sets, and 20 of the cmdlets have several: |
||||
|
path or security descriptor, and for the four cmdlets that add and remove |
||||
|
entries also simple or complex. Two kinds of evidence exist; neither is a |
||||
|
matrix of judged error and state tests for each set. |
||||
|
|
||||
|
- A parser-based scan of the test files counts, for each set, the |
||||
|
invocations that can only bind it |
||||
|
([the sets](./Quality-Gate-Paths-2026-10-09-ParameterSets.csv)). Fifty-seven |
||||
|
sets have at least one. Seven have none, because their tests splat the |
||||
|
parameters, pipe the descriptor, or call the command through a variable: |
||||
|
`Add-NTFSAudit` (SDSimple), `Get-NTFSOrphanedAudit` (SD), `Get-NTFSOwner` |
||||
|
(SecurityDescriptor), `Remove-NTFSAccess` (PathSimple and SDSimple), and |
||||
|
`Remove-NTFSAudit` (PathSimple and SDSimple). |
||||
|
- No unvisited point lies on a parameter-set switch. The 20 cmdlets with |
||||
|
several sets have 168 unvisited points, 165 sequence points and 3 branch |
||||
|
points. Sixty-nine sequence points are property getters; the other 96 |
||||
|
sequence points and 3 branch points are catch handlers with their closing |
||||
|
braces, their `WriteError` and `continue`, and the closures of the owner |
||||
|
retry. None of them mentions `ParameterSetName`, the descriptor list, or |
||||
|
`AppliesTo`. |
||||
|
|
||||
|
## Open items for the maintainer |
||||
|
|
||||
|
None of these was changed or reclassified as harmless. |
||||
|
|
||||
|
1. `FileSystemSecurity2` converts from `FileSecurity` and `DirectorySecurity` |
||||
|
through `FileInfo("")` and `DirectoryInfo("")`, so every conversion |
||||
|
throws. No cmdlet uses it; fix or remove it. |
||||
|
2. `RemoveFileSystemAccessRuleAll` and `RemoveFileSystemAuditRuleAll` ignore |
||||
|
their account list and remove every explicit entry (finding #113). No |
||||
|
cmdlet passes an account list. The predicate of the discarded filter, |
||||
|
`Count() > 1`, would match no account that appears once, so using its |
||||
|
result as it stands would remove nothing. |
||||
|
3. The overloads of `AddFileSystemAccessRule` and `AddFileSystemAuditRule` |
||||
|
for a path and several accounts are lazy iterators, so nothing is written |
||||
|
until the caller enumerates the result; the overloads for an item and for |
||||
|
a descriptor write at once. |
||||
|
4. A `PrivilegeEnabler` that enabled a privilege and was never disposed |
||||
|
keeps the privilege enabled: a static list holds it, so its finalizer |
||||
|
never runs. |
||||
|
5. `Get-ChildItem2 -Filter` matches names twice, in the AlphaFS enumeration |
||||
|
and in the cmdlet, and their rules for a dot differ from those of |
||||
|
`Get-ChildItem` (probe p32, both editions unless noted). `*.*` now means |
||||
|
every item, but `Report.*` does not return the file `Report` and `Rep*.` |
||||
|
returns nothing where `Get-ChildItem` returns `Report`; `Report.` returns |
||||
|
nothing, as in PowerShell 7 but not in Windows PowerShell 5.1, which |
||||
|
returns `Report`; an empty value returns nothing without an error. The |
||||
|
documentation lists this and `ItemCmdlets.Tests` pins it, so a change is a |
||||
|
decision. Decide whether to align the rules. |
||||
|
6. The registry model, the unused helper classes, and the raw descriptor |
||||
|
readers have no caller (Decisions 21 and 22 govern them). Their |
||||
|
explanations say so; removing them is your decision. |
||||
|
7. The ownership retry of an audit write over SMB has no test that a local |
||||
|
volume can run: a local audit write never fails with access denied. The |
||||
|
lab suite covers the cmdlets over SMB; gate 3 should repeat it. |
||||
|
8. Seventeen handlers enclose only a helper that can write a |
||||
|
`RestoreOwnerError` (`InvokeAsOwner`, `WriteChangesAsOwner`) and do not |
||||
|
pass on what a later command raises at that write. By reading the code, |
||||
|
the handler then writes the error of the item, which raises again for |
||||
|
`-ErrorAction Stop` and for a stopped pipeline, so those still end the |
||||
|
command (not run); a later command that throws only for the |
||||
|
`RestoreOwnerError` record would have its exception swallowed. Closing it |
||||
|
means one check in each handler and a test with an owner that cannot be |
||||
|
set back (elevated only). |
||||
|
9. The type check `PipelineControl.IsEnd` backs up the recording of the |
||||
|
write methods for calls into PowerShell that nothing records, for example |
||||
|
`ShouldProcess` in the try block of `Remove-Item2`. No test makes that |
||||
|
call raise it, and the mutation that removes it is not detected (round 3); |
||||
|
only direct tests of its type rules cover it. Keep it as defense in depth |
||||
|
or remove it. |
||||
|
10. The catch of `Enable-Privileges` that rethrows a `ParseException` for a |
||||
|
malformed module setting is unvisited: the base class casts the same |
||||
|
value first. |
||||
|
11. By reading the source, the `-SecurityDescriptor` sets of the four cmdlets |
||||
|
that add and remove entries have no handler around the change: an |
||||
|
exception, such as one for a deny entry without rights, ends the cmdlet |
||||
|
with a terminating error, where the `-Path` sets write an error for the |
||||
|
item and go on. The probe of a full ACL ran into it with |
||||
|
`-SecurityDescriptor`. The zero-mask tests use `-Path` only. Whether the |
||||
|
descriptor sets should report per item is a design decision. |
||||
|
12. The test helpers `Add-TestDenyRule`, `Set-TestOwner`, and |
||||
|
`Block-TestReadPermission` and `Block-TestWritePermission` accept an |
||||
|
existing link as the item: the check of `Assert-TestSandboxPath` covers |
||||
|
the folders of the path, not the item. Only `Set-TestNullDacl` refuses a |
||||
|
link. No test passes a link to them. |
||||
|
13. Decision 22 remains proposed, and the stable 5.0.0 gate stays open. |
||||
|
|
||||
|
## Completion matrix |
||||
|
|
||||
|
| Criterion of the handoff | Status | Evidence | |
||||
|
| --- | --- | --- | |
||||
|
| Every currently unvisited method is inventoried | Done | 231 methods in the CSV, none unclassified | |
||||
|
| Each path is tested or has a source-backed explanation | Done, with open items | explanations by rule; 8 methods stay open for the maintainer | |
||||
|
| Reachable gaps closed first, without duplicating earlier tests | Done | 136 new `It` blocks; defects 1 to 11 | |
||||
|
| Behavior tests before production changes | Not evidenced | each of the eight fix commits carries its tests and its fix together, and the red runs that were taken while the tests were written were deleted with their run folders; the order cannot be shown from the history or from kept logs. The first test of defect 8 pinned `*.*` as the design and was changed after a review finding | |
||||
|
| Regressions that fail without the fix | Done, with exceptions | the red/green matrix: 76 rows (73 in the basic configurations) fail at the base, each is green at the step of its fix, none breaks later; the mutations. Exceptions: defect 9 has no guard; the guards of defect 11 and the audit row of defect 3 run only elevated | |
||||
|
| All cmdlets and parameter sets have meaningful error and state tests | Partly | per-cmdlet coverage of 72.5% or more; the parameter-set evidence is a parser count and the coverage of the switches, not a matrix of judged tests | |
||||
|
| Full suite in both editions and privilege modes | Passed | 1,310 cases in each, zero failures | |
||||
|
| No required case is skipped across the matrix | Passed | 578 skipped rows, each executed in two other configurations | |
||||
|
| Frozen Release measurement with all four configurations | Done | source pin, XML, hashes, exclusions | |
||||
|
| Differences from the `3442194` baseline explained | Done | the coverage section | |
||||
|
| Self-review and one independent finished-diff review | Done, see the limits | the review section | |
||||
|
| Docs, help, changelog, Memory Bank | Done | `CHANGELOG.md`, cmdlet page, help, Memory Bank | |
||||
|
| Commit locally, no remote change | Done | the commits above; no push | |
||||
|
|
||||
|
## Checks not run, and limits |
||||
|
|
||||
|
- The lab suite, the published-package acceptance, and the OS matrix were not |
||||
|
run here; they belong to gate 3. |
||||
|
- A matrix of error and state tests for each parameter set was not built. |
||||
|
The evidence is described under [Parameter sets](#parameter-sets): a |
||||
|
parser-based count of the test invocations and the coverage of the |
||||
|
parameter-set switches. It does not judge how meaningful each test is. |
||||
|
- The explanations are not tests. Each one names its evidence; an |
||||
|
explanation that rests on reading the source only says so. |
||||
|
- The custom `security-reviewer` agent could not start because of its |
||||
|
configured model, and no model setting was changed. The built-in read-only |
||||
|
`code-review` agent made the static review passes below; none of them built |
||||
|
or ran anything. |
||||
|
- The coverage percentages are not a measure of the quality of the |
||||
|
assertions; the mutations are the measure for the new tests. A mutation |
||||
|
that is not detected is reported, not hidden. |
||||
|
- The CI scripts set `$ErrorActionPreference` to `Stop`, and a test that |
||||
|
relies on a non-terminating error must name its error action. The tests |
||||
|
were checked for this by reading and by the mutation rounds, and the |
||||
|
focused runner of this work sets `Stop` too, but nothing enforces it: a new |
||||
|
row without an error action would regain the weakness silently. A test |
||||
|
that every `Run` block of `PipelineControl.Tests` names one is a possible |
||||
|
hardening that was not added. |
||||
|
- The red/green matrix measures the final tests on older production code. It |
||||
|
does not show the order in which a test and its fix were written; the red |
||||
|
runs of that time were not kept. The ten states are identified by the |
||||
|
commit of their build, not by hashes of their assemblies. |
||||
|
- All runs are on one Windows Server 2025 host. The privileged tests skip |
||||
|
without the privileges and run elevated, so a basic-user run cannot show |
||||
|
them; the eligibility check shows that each of them runs elsewhere. |
||||
|
|
||||
|
## Handoff to gate 3 |
||||
|
|
||||
|
Repeat the affected acceptance on the packaged candidate before it is |
||||
|
published. Each fix changes behavior that the lab can observe: |
||||
|
|
||||
|
| Fix | What to repeat | |
||||
|
| --- | --- | |
||||
|
| `c7a0383` | `Clear-NTFSAccess -DisableInheritance` and `Set-NTFSSecurityDescriptor` on an item that the user owns, over SMB: no `RestoreOwnerError` | |
||||
|
| `2909a1c` | `Get-NTFSAccess` and `Get-NTFSAudit` with `GetInheritedFrom` for an item whose parent folder is unreadable | |
||||
|
| `c77ecbf`, `40bf6a8`, `d44a200` | `Remove-Item2`, `Copy-Item2`, `Move-Item2`, `Set-NTFSOwner`, `Set-NTFSSecurityDescriptor`, and `Get-ChildItem2` with `Select-Object -First 1`, `break`, and a `throw` of a later command, also on the verbose, debug, and error streams: no further item changes, and the caller sees the error | |
||||
|
| `d44a200` | `Get-NTFSOwner` or `Get-NTFSAccess` with the debug stream taken by `Select-Object -First 2`: the Take Ownership privilege is disabled again afterwards (elevated) | |
||||
|
| `ee7c105`, `ae3078f`, `40bf6a8` | `Get-ChildItem2 -Filter` with brackets, `*.*`, and a null value on a share | |
||||
|
| `b14c90b` | the rule, audit, and privilege objects in the packaged module | |
||||
|
|
||||
|
## Review |
||||
|
|
||||
|
The custom `security-reviewer` agent could not start because of its |
||||
|
configured model, and no model setting was changed. A built-in read-only |
||||
|
`code-review` agent made nine static passes: seven over the diff in ranges, |
||||
|
which also read the draft appendix and the draft report; a read of the final |
||||
|
report, the appendix, the tables, the Memory Bank notes, and the lab README; |
||||
|
and a read of the red/green evidence against its raw logs. It built and ran |
||||
|
nothing. No pass found a Blocker or a Major issue. Every finding was either |
||||
|
fixed, turned into an open item, or answered with a probe; one finding |
||||
|
(`-ErrorAction Stop` swallowed by the recursion) was refuted by a probe and |
||||
|
withdrawn by the reviewer. |
||||
|
|
||||
|
| Pass | Range | Findings (Minor unless stated) | What became of them | |
||||
|
| ---: | --- | --- | --- | |
||||
|
| 1 | `f11ff41..cd56f49` | A break or continue test whose first output was above the recursion, so it never reached the frame where `Get-ChildItem2` swallowed both; tests that pinned the lazy path overloads as correct; a `PrivilegeEnabler` test without `finally`; three Nits | defect 4 for every cmdlet; open item 3; `finally` added | |
||||
|
| 2 | `cd56f49..630926f` | No test for the sandbox guard of the drive-mapping helper; its drive letter collided with two tests; no `throw` or `-ErrorAction Stop` row, which the engine could deliver as another exception | guard tests, the free-letter choice, and the rows that found defect 7 | |
||||
|
| 3 | `630926f..ae3078f` | `*.*` pinned as design; a restored-owner test that could pass without reaching the restore; the help dropped the asterisks of the `-Filter` paragraph; wording (Nit) | defect 8; a precondition in the test; the paragraph rewritten for platyPS | |
||||
|
| 4 | `ae3078f..40bf6a8` and the first appendix | The recording of the write methods judged sound; gaps in how handlers were counted; eight rules doubted with evidence | handlers and rules corrected, the tests of this report added | |
||||
|
| 5 | `40bf6a8..7aa8315` and the draft report | The draft described an older commit; the check by type had no behavior test; wording about "a later command" (Nits) | the report regenerated; reflection tests of the check by type; typed-throw rows | |
||||
|
| 6 | `7aa8315..d0bd1af` | The dot paragraph named the wrong layer; Init tests that could not fail for their branch; the first-nested-folder assertion; a link as the item of `Set-TestNullDacl`; wording (Nits) | `f4a16e1` and `5a5d58b` | |
||||
|
| 7 | `d0bd1af..5a5d58b` | A comment named three cmdlets where one calls `ShouldProcess` in a try block (Nit); optional: a test that every `Run` block names an error action | the comment fixed after the measured source; the guard test is not added (limits) | |
||||
|
| 8 | the final report, appendix, tables, Memory Bank notes, lab README | Four Minor: the production total left out `ProcessPrivileges`; the red runs of the fixes were not preserved, and "six `throw` cases" could not be reproduced; the hard-link rule stated more than its evidence; the Memory Bank said that AlphaFS follows the Windows dot rules. Nine Nits: the dot rule that depends on the edition, the count of unvisited points, the helper callers, the warning codes, the completion row together with the "test-first" wording of the Memory Bank, the wording "no test can make that call raise it", the trigger of a full ACL, and two missing items (the test helpers that accept a link, and the dependence on the ambient error action) | the numbers and wording corrected; the red evidence measured again (the red/green matrix above); the rule and the Memory Bank corrected; open items 11 and 12 and the limit about the error action added | |
||||
|
| 9 | the red/green section, its CSV, the completion row, the Memory Bank notes, against the 40 raw logs | Two Minor: the test file of defect 2 was named wrongly (PathErrors, not Access); the completion row said that tests came before the fixes, which no kept evidence shows. Four Nits: the focused runner was called the CI wrappers and its result an NUnit result; defect 7 read "a `throw` or a terminating error", where only the `throw` rows turn green with fix 7; one guard row fails without a message; the 40 logs had no fingerprint | the CSV has a test-file column and the step table follows it; the row is split into an order that is not evidenced and a guard that is measured; the wording corrected; the empty message explained by the source and the mutation M16; a manifest with the SHA-256 of every log | |
||||
|
|
||||
|
Pass 8 checked the identity paragraph, the pass and skip table, the skipped |
||||
|
rows, the assembly and evidence hashes, the mutation table, the coverage and |
||||
|
category numbers, and the parameter-set counts against git and the CSVs, and |
||||
|
found them to agree; the production total was the one exception. Pass 9 |
||||
|
recomputed the step table, the sub-counts of the defects table, and the |
||||
|
claims about the unexpanded name and about the production code after |
||||
|
`d44a200` from the raw logs and git, and found them to agree; its findings |
||||
|
are about the test file, the order of tests and fixes, and wording. The |
||||
|
corrections that followed pass 9 were checked by the author and by no other |
||||
|
pass. No pass is a substitute for the lab acceptance of gate 3. |
||||
|
|
||||
|
## Evidence outside git |
||||
|
|
||||
|
Raw evidence is local and not committed. It is in the session folder |
||||
|
`C:\Users\install\.copilot\session-state\4b12e2f4-d4c7-4a5d-883a-ddb7421c4848\files\qg-paths`: |
||||
|
the OpenCover XML of the four configurations and the aggregate, the NUnit |
||||
|
results of the Validate runs, the unvisited-method and unvisited-point |
||||
|
inventories, the per-row eligibility CSV files, the mutation logs, the logs of |
||||
|
the red/green matrix (the focused run of each of the ten states in the four |
||||
|
configurations), the probe scripts and their results, and the scripts that |
||||
|
produce the tables. |
||||
|
|
||||
|
| File | SHA-256 | |
||||
|
| --- | --- | |
||||
|
| `coverage/coverage.xml` | `3EFAE43A574B62AB5DDC80022E52D28B8606E82F0BF9045BECB87B2ACBADB8D4` | |
||||
|
| `coverage/elevated-Desktop.xml` | `1DA9DAE5D5CDA1EB19079CFD69137EAC3314B8B926AD0D33C90026F2DE894B7D` | |
||||
|
| `coverage/basic-Desktop.xml` | `E43E7B457161E4C63A044215362694E5AB10AAE2AA3E057E459AD2033DA98462` | |
||||
|
| `coverage/elevated-Core.xml` | `DE329D7E640BD3E4F6C2DCC9EBF2217EF5E0E7F38C114738D0591FEBEAAEDD94` | |
||||
|
| `coverage/basic-Core.xml` | `55D167E4764A9EE5DA2EBBAE64C3E9776434D70144DD6C6A810DEA8890524AA6` | |
||||
|
| `validate/elevated-Desktop.xml` | `0FC57455840153FBCBAB53A1AEBF6ABA02088C3A647CD4116CAE29E23F587AAE` | |
||||
|
| `validate/basic-Desktop.xml` | `9E27622F9ED0A92948C6BF59DD46AEF8AB6D4BEBF45BEAE4B0DB2AFDDF5D3011` | |
||||
|
| `validate/elevated-Core.xml` | `1B8A5DB7A4A9337AAB5E63011E4807CCCC1C43BF1E171ADF3E15BB312AEED80E` | |
||||
|
| `validate/basic-Core.xml` | `BFA5D4BC57EBC36A108F465418D5567FF05A0E4BFBF2E7096E4F785CAC74E8C2` | |
||||
|
| `inventory-5a5d58b/unvisited-methods.csv` | `F5B736EDF662C9C0E3DEBD4302EE6F7A59DF0623FDCC2A479ECCA4C89773F007` | |
||||
|
| `inventory-5a5d58b/unvisited-points.csv` | `56AA0B5DDA67B45545CCCCEF41497CB300B54A487BAFB2CAA4EA4E0D024749A5` | |
||||
|
| `classification-5a5d58b.csv` | `E2CD8700C5C8DFB52D33D6E6DB96E3EBD630D94CB4A4D224B1BD1C1F356CECB0` | |
||||
|
| `eligibility-run-5a5d58b/skipped-rows.csv` | `AD584A885AB5C73B4E4CC7C3CF42CC92114C198E48ABE0D367BB001AB93E7489` | |
||||
|
| `measured-assemblies-5a5d58b.csv` | `A8030B7AD00E2B4632C88C46C336DFBC4873383A021F6B446DA4CED6462DA51C` | |
||||
|
| `mutations-5a5d58b-r1/mutation-results.csv` | `29D4E5EEC5FCCD91E8E2C1323C030A75691B5FA530C8345757729E9DCB58EC61` | |
||||
|
| `mutations-5a5d58b-r2/mutation-results.csv` | `87EC131C64C6F3AD0F440C41FA8CF0F0618E7875AAB5EC7D6A1C647CF361C98E` | |
||||
|
| `mutations-5a5d58b-r3/mutation-results.csv` | `05D7752A5C9B0DA5F5A3F35ED83C471FCEDDAE5EA967D557AA0AD334A02A244C` | |
||||
|
| `mutations-5a5d58b-r4/mutation-results.csv` | `EB774D4621658DFA8B4390FFC6201F913E88A214FB2510B69A5B9F50D922B18D` | |
||||
|
| `redgreen/redgreen-results.csv` | `C2327B991608489A85CC32CE17CC58F258348677EA31815F123D228AECBCB55A` | |
||||
|
| `redgreen/redgreen-failed-rows.csv` | `5AA4E1514F75CEEAACD8206F3E8C7727C2E970E2D45E7D545FD51F02EF21ED1F` | |
||||
|
| `redgreen/redgreen-guards.csv` (the CSV file of this folder with the red/green rows) | `D5D4720B2E6DA3619889C75DF3A2876E505FEED19BDD34695C92EF7421CD3558` | |
||||
|
| `redgreen/redgreen-summary.csv` | `46987DE40D526A279871E5F1915CDC346C576B3E644E169A1E3C79B7181B9052` | |
||||
|
| `redgreen/redgreen-log-manifest.csv` (the CSV file of this folder with the hash of each of the 40 logs) | `3E45ABFD850155F276DA5AF0E3FBEE83800D2C269DBB59B800042AC0080D4615` | |
||||
|
| `redgreen/redgreen-verify.csv` | `717AB27434D35A59750C35DB8B33F548FC6F7F3E01353F3BC290629516735DF7` | |
||||
|
| `redgreen/redgreen-driver.log` | `FCB9AE5980092BE97E362B2BE412CAE771C0EFB0CC326CBE814726A246D535EA` | |
||||
@ -0,0 +1,903 @@ |
|||||
|
<# |
||||
|
Tests the public object APIs used with cmdlet output, on files and folders in a sandbox folder. |
||||
|
#> |
||||
|
[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 'ObjectApis' |
||||
|
$objectPath = Join-Path -Path $sandbox -ChildPath 'Rule.txt' |
||||
|
$identity = [Security2.IdentityReference2] 'S-1-1-0' |
||||
|
$sid = New-Object -TypeName 'System.Security.Principal.SecurityIdentifier' -ArgumentList 'S-1-1-0' |
||||
|
} |
||||
|
|
||||
|
AfterAll { |
||||
|
Remove-TestSandbox -Sandbox $sandbox |
||||
|
Remove-Module -Name NTFSSecurity -Force -ErrorAction SilentlyContinue |
||||
|
} |
||||
|
|
||||
|
Describe 'Rule constructors with a path' { |
||||
|
It 'Should preserve the supplied path and name of an <Kind> rule' -ForEach @( |
||||
|
@{ Kind = 'access' } |
||||
|
@{ Kind = 'audit' } |
||||
|
) { |
||||
|
if ($Kind -eq 'access') { |
||||
|
$raw = New-Object -TypeName 'System.Security.AccessControl.FileSystemAccessRule' -ArgumentList ( |
||||
|
$sid, [System.Security.AccessControl.FileSystemRights]::ReadData, |
||||
|
[System.Security.AccessControl.AccessControlType]::Allow |
||||
|
) |
||||
|
$rule = New-Object -TypeName 'Security2.FileSystemAccessRule2' -ArgumentList $raw, $objectPath |
||||
|
} |
||||
|
else { |
||||
|
$raw = New-Object -TypeName 'System.Security.AccessControl.FileSystemAuditRule' -ArgumentList ( |
||||
|
$sid, [System.Security.AccessControl.FileSystemRights]::ReadData, |
||||
|
[System.Security.AccessControl.AuditFlags]::Success |
||||
|
) |
||||
|
$rule = New-Object -TypeName 'Security2.FileSystemAuditRule2' -ArgumentList $raw, $objectPath |
||||
|
} |
||||
|
|
||||
|
$rule.FullName | Should -BeExactly $objectPath |
||||
|
$rule.Name | Should -BeExactly 'Rule.txt' |
||||
|
$rule.InheritanceEnabled = $true |
||||
|
$rule.InheritedFrom = $sandbox |
||||
|
$rule.InheritanceEnabled | Should -BeTrue |
||||
|
$rule.InheritedFrom | Should -BeExactly $sandbox |
||||
|
$rule.GetHashCode() | Should -Be $raw.GetHashCode() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Describe 'Simplified audit entries' { |
||||
|
It 'Should reduce <Rights> to <Expected>' -ForEach @( |
||||
|
@{ Rights = 'None'; Expected = 'None' } |
||||
|
@{ Rights = 'ReadData'; Expected = 'Read' } |
||||
|
@{ Rights = 'Read'; Expected = 'Read' } |
||||
|
@{ Rights = 'CreateFiles'; Expected = 'Write' } |
||||
|
@{ Rights = 'AppendData'; Expected = 'Write' } |
||||
|
@{ Rights = 'ReadExtendedAttributes'; Expected = 'Read' } |
||||
|
@{ Rights = 'WriteExtendedAttributes'; Expected = 'Write' } |
||||
|
@{ Rights = 'ExecuteFile'; Expected = 'Read' } |
||||
|
@{ Rights = 'DeleteSubdirectoriesAndFiles'; Expected = 'Delete' } |
||||
|
@{ Rights = 'ReadAttributes'; Expected = 'Read' } |
||||
|
@{ Rights = 'WriteAttributes'; Expected = 'Write' } |
||||
|
@{ Rights = 'Delete'; Expected = 'Delete' } |
||||
|
@{ Rights = 'ReadPermissions'; Expected = 'Read' } |
||||
|
@{ Rights = 'ChangePermissions'; Expected = 'Write' } |
||||
|
@{ Rights = 'TakeOwnership'; Expected = 'Write' } |
||||
|
@{ Rights = 'Synchronize'; Expected = 'Read' } |
||||
|
@{ Rights = 'FullControl'; Expected = 'Read, Write, Delete' } |
||||
|
@{ Rights = 'GenericRead'; Expected = 'Read' } |
||||
|
@{ Rights = 'GenericWrite'; Expected = 'Write' } |
||||
|
@{ Rights = 'GenericExecute'; Expected = 'Read' } |
||||
|
@{ Rights = 'GenericAll'; Expected = 'Read, Write, Delete' } |
||||
|
) { |
||||
|
$rule = New-Object -TypeName 'Security2.SimpleFileSystemAuditRule' -ArgumentList ( |
||||
|
$objectPath, $identity, [Security2.FileSystemRights2] $Rights |
||||
|
) |
||||
|
|
||||
|
$rule.AccessRights | Should -Be ([Security2.SimpleFileSystemAccessRights] $Expected) |
||||
|
$rule.FullName | Should -BeExactly $objectPath |
||||
|
$rule.Name | Should -BeExactly 'Rule.txt' |
||||
|
$rule.Identity.Sid | Should -BeExactly 'S-1-1-0' |
||||
|
} |
||||
|
|
||||
|
It 'Should compare audit entries reflexively and symmetrically, never as access entries' { |
||||
|
$first = New-Object -TypeName 'Security2.SimpleFileSystemAuditRule' -ArgumentList ( |
||||
|
$objectPath, $identity, [Security2.FileSystemRights2]::Read |
||||
|
) |
||||
|
$second = New-Object -TypeName 'Security2.SimpleFileSystemAuditRule' -ArgumentList ( |
||||
|
$objectPath, $identity, [Security2.FileSystemRights2]::Read |
||||
|
) |
||||
|
$access = New-Object -TypeName 'Security2.SimpleFileSystemAccessRule' -ArgumentList ( |
||||
|
$objectPath, $identity, [Security2.FileSystemRights2]::Read, |
||||
|
[System.Security.AccessControl.AccessControlType]::Allow |
||||
|
) |
||||
|
|
||||
|
$first.Equals($first) | Should -BeTrue |
||||
|
$first.Equals($second) | Should -BeTrue |
||||
|
$second.Equals($first) | Should -BeTrue |
||||
|
$first.GetHashCode() | Should -Be $second.GetHashCode() |
||||
|
$first.Equals($access) | Should -BeFalse |
||||
|
$access.Equals($first) | Should -BeFalse |
||||
|
$first.Equals($null) | Should -BeFalse |
||||
|
$first.Equals('Read') | Should -BeFalse |
||||
|
$second.AccessControlType = 'Deny' |
||||
|
$first.Equals($second) | Should -BeFalse |
||||
|
} |
||||
|
|
||||
|
It 'Should preserve the path, account and ReadData when converting an audit entry' { |
||||
|
$raw = New-Object -TypeName 'System.Security.AccessControl.FileSystemAuditRule' -ArgumentList ( |
||||
|
$sid, [System.Security.AccessControl.FileSystemRights]::ReadData, |
||||
|
[System.Security.AccessControl.AuditFlags]::Success |
||||
|
) |
||||
|
$wrapped = New-Object -TypeName 'Security2.FileSystemAuditRule2' -ArgumentList $raw, $objectPath |
||||
|
|
||||
|
$simple = $wrapped.ToSimpleFileSystemAuditRule2() |
||||
|
|
||||
|
$simple.FullName | Should -BeExactly $objectPath |
||||
|
$simple.Identity.Sid | Should -BeExactly 'S-1-1-0' |
||||
|
$simple.AccessRights | Should -Be ([Security2.SimpleFileSystemAccessRights]::Read) |
||||
|
$wrapped.ToString() | Should -BeExactly $raw.ToString() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Describe 'Identity comparisons and conversions' { |
||||
|
It 'Should compare <Value> with the identity by SID or resolved name' -ForEach @( |
||||
|
@{ Value = 'self'; Expected = $true } |
||||
|
@{ Value = 'same SID'; Expected = $true } |
||||
|
@{ Value = 'different SID'; Expected = $false } |
||||
|
@{ Value = 'SecurityIdentifier'; Expected = $true } |
||||
|
@{ Value = 'NTAccount'; Expected = $true } |
||||
|
@{ Value = 'SID string'; Expected = $true } |
||||
|
@{ Value = 'account name'; Expected = $true } |
||||
|
@{ Value = 'different string'; Expected = $false } |
||||
|
@{ Value = 'null'; Expected = $false } |
||||
|
@{ Value = 'other type'; Expected = $false } |
||||
|
) { |
||||
|
$other = switch ($Value) { |
||||
|
'self' { $identity } |
||||
|
'same SID' { [Security2.IdentityReference2] 'S-1-1-0' } |
||||
|
'different SID' { [Security2.IdentityReference2] 'S-1-5-32-546' } |
||||
|
'SecurityIdentifier' { $sid } |
||||
|
'NTAccount' { $sid.Translate([System.Security.Principal.NTAccount]) } |
||||
|
'SID string' { 'S-1-1-0' } |
||||
|
'account name' { $identity.AccountName.ToUpperInvariant() } |
||||
|
'different string' { 'NTFSSecurity-not-an-account' } |
||||
|
'null' { $null } |
||||
|
'other type' { 42 } |
||||
|
} |
||||
|
|
||||
|
$identity.Equals($other) | Should -Be $Expected |
||||
|
} |
||||
|
|
||||
|
It 'Should compare null operands and distinct instances through both operators' { |
||||
|
$same = [Security2.IdentityReference2] 'S-1-1-0' |
||||
|
$different = [Security2.IdentityReference2] 'S-1-5-32-546' |
||||
|
|
||||
|
[Security2.IdentityReference2]::op_Equality($null, $null) | Should -BeTrue |
||||
|
[Security2.IdentityReference2]::op_Equality($identity, $null) | Should -BeFalse |
||||
|
[Security2.IdentityReference2]::op_Equality($null, $identity) | Should -BeFalse |
||||
|
[Security2.IdentityReference2]::op_Equality($identity, $same) | Should -BeTrue |
||||
|
[Security2.IdentityReference2]::op_Inequality($identity, $identity) | Should -BeFalse |
||||
|
[Security2.IdentityReference2]::op_Inequality($identity, $null) | Should -BeTrue |
||||
|
[Security2.IdentityReference2]::op_Inequality($null, $identity) | Should -BeTrue |
||||
|
[Security2.IdentityReference2]::op_Inequality($identity, $different) | Should -BeTrue |
||||
|
[Security2.IdentityReference2]::op_Inequality($identity, $same) | Should -BeFalse |
||||
|
$identity.GetHashCode() | Should -Be $same.GetHashCode() |
||||
|
} |
||||
|
|
||||
|
It 'Should round-trip native identities and the binary SID without changing the account' { |
||||
|
$account = $sid.Translate([System.Security.Principal.NTAccount]) |
||||
|
$fromSid = [Security2.IdentityReference2]::op_Explicit($sid) |
||||
|
$fromAccount = [Security2.IdentityReference2]::op_Explicit($account) |
||||
|
|
||||
|
$fromSid.Sid | Should -BeExactly 'S-1-1-0' |
||||
|
$fromAccount.Sid | Should -BeExactly $fromSid.Sid |
||||
|
([System.Security.Principal.SecurityIdentifier] $fromSid).Value | Should -BeExactly 'S-1-1-0' |
||||
|
([System.Security.Principal.NTAccount] $fromAccount).Value | Should -BeExactly $account.Value |
||||
|
$binarySid = New-Object -TypeName 'System.Security.Principal.SecurityIdentifier' -ArgumentList ( |
||||
|
$fromSid.GetBinaryForm(), 0 |
||||
|
) |
||||
|
$binarySid.Value | Should -BeExactly 'S-1-1-0' |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Describe 'Unrepresentable inheritance flags' { |
||||
|
It 'Should reject propagation flags without inheritance instead of inventing an AppliesTo value' { |
||||
|
$failure = $null |
||||
|
try { |
||||
|
$null = [Security2.FileSystemSecurity2]::ConvertToApplyTo('None', 'InheritOnly') |
||||
|
} |
||||
|
catch { |
||||
|
$failure = $_.Exception.GetBaseException() |
||||
|
} |
||||
|
|
||||
|
$failure | Should -BeOfType [Security2.RightsConverionException] |
||||
|
$failure.Message | Should -BeExactly 'The combination of InheritanceFlags and PropagationFlags could not be translated' |
||||
|
} |
||||
|
} |
||||
|
Describe 'Privilege output comparisons and formatting' { |
||||
|
It 'Should compare boxed and typed privilege values consistently without accepting an attributes enum' { |
||||
|
$values = @(Get-Privileges) |
||||
|
$values.Count | Should -BeGreaterThan 0 |
||||
|
$first = $values[0].PSObject.BaseObject |
||||
|
$copy = $values[0].PSObject.BaseObject |
||||
|
# PowerShell prefers the typed overload; reflection selects the public boxed-object contract explicitly. |
||||
|
$equalsObject = [ProcessPrivileges.PrivilegeAndAttributes].GetMethod('Equals', [type[]] @([object])) |
||||
|
|
||||
|
$equalsObject.Invoke($first, [object[]] @($copy)) | Should -BeTrue |
||||
|
$first.Equals($copy) | Should -BeTrue |
||||
|
[ProcessPrivileges.PrivilegeAndAttributes]::op_Equality($first, $copy) | Should -BeTrue |
||||
|
[ProcessPrivileges.PrivilegeAndAttributes]::op_Inequality($first, $copy) | Should -BeFalse |
||||
|
$first.GetHashCode() | Should -Be $copy.GetHashCode() |
||||
|
$equalsObject.Invoke($first, [object[]] @($null)) | Should -BeFalse |
||||
|
$equalsObject.Invoke($first, [object[]] @('Backup')) | Should -BeFalse |
||||
|
$equalsObject.Invoke($first, [object[]] @([ProcessPrivileges.PrivilegeAttributes]::Disabled)) | Should -BeFalse |
||||
|
} |
||||
|
|
||||
|
It 'Should format the collection with one aligned privilege and attributes row per value' { |
||||
|
$control = New-Object -TypeName 'Security2.PrivilegeControl' |
||||
|
$values = $control.GetPrivileges() |
||||
|
$expectedWidth = ($values | ForEach-Object { $_.Privilege.ToString().Length } | Measure-Object -Maximum).Maximum |
||||
|
|
||||
|
$text = $values.ToString() |
||||
|
|
||||
|
$rows = @($text.TrimEnd("`r", "`n") -split '\r?\n') |
||||
|
$rows | Should -HaveCount $values.Count |
||||
|
for ($index = 0; $index -lt $values.Count; $index++) { |
||||
|
$value = $values[$index] |
||||
|
$rows[$index] | Should -BeExactly ('{0} => {1}' -f $value.Privilege.ToString().PadRight($expectedWidth), |
||||
|
$value.PrivilegeAttributes) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Describe 'Legacy effective-permission output objects' { |
||||
|
It 'Should retain a <Mask> mask and report the supplied path and identity without a native access check' -ForEach @( |
||||
|
@{ Mask = 0; ObjectName = 'Effective.txt' } |
||||
|
@{ Mask = 1; ObjectName = 'Effective.txt' } |
||||
|
@{ Mask = 3; ObjectName = $null } |
||||
|
) { |
||||
|
$path = if ($ObjectName) { Join-Path -Path $sandbox -ChildPath $ObjectName } else { $null } |
||||
|
$entry = New-Object -TypeName 'Security2.FileSystemEffectivePermissionEntry' -ArgumentList ( |
||||
|
$identity, [uint32] $Mask, $path |
||||
|
) |
||||
|
|
||||
|
$entry.Account.Sid | Should -BeExactly 'S-1-1-0' |
||||
|
$entry.AccessMask | Should -Be $Mask |
||||
|
[int] $entry.AccessRights | Should -Be $Mask |
||||
|
$entry.FullName | Should -BeExactly ([string] $path) |
||||
|
$entry.Name | Should -BeExactly $ObjectName |
||||
|
$entry.AccessAsString | Should -Not -BeNullOrEmpty |
||||
|
if ($Mask -eq 0) { |
||||
|
$entry.AccessAsString | Should -Be @('None') |
||||
|
} |
||||
|
else { |
||||
|
$entry.AccessAsString | Should -Not -Contain 'None' |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
Describe 'Simplified entry comparison branches' { |
||||
|
It 'Should distinguish identities, rights and types in <Kind> entries and keep equal hashes consistent' -ForEach @( |
||||
|
@{ Kind = 'access' } |
||||
|
@{ Kind = 'audit' } |
||||
|
) { |
||||
|
$typeName = if ($Kind -eq 'access') { 'Security2.SimpleFileSystemAccessRule' } else { 'Security2.SimpleFileSystemAuditRule' } |
||||
|
$arguments = @($objectPath, $identity, [Security2.FileSystemRights2]::Read) |
||||
|
if ($Kind -eq 'access') { $arguments += [System.Security.AccessControl.AccessControlType]::Allow } |
||||
|
$first = New-Object -TypeName $typeName -ArgumentList $arguments |
||||
|
$equal = New-Object -TypeName $typeName -ArgumentList $arguments |
||||
|
$arguments[1] = [Security2.IdentityReference2] 'S-1-5-32-546' |
||||
|
$differentIdentity = New-Object -TypeName $typeName -ArgumentList $arguments |
||||
|
$arguments[1] = $identity |
||||
|
$arguments[2] = [Security2.FileSystemRights2]::Delete |
||||
|
$differentRights = New-Object -TypeName $typeName -ArgumentList $arguments |
||||
|
|
||||
|
$first.Equals($equal) | Should -BeTrue |
||||
|
$first.GetHashCode() | Should -Be $equal.GetHashCode() |
||||
|
$first.Equals($differentIdentity) | Should -BeFalse |
||||
|
$first.Equals($differentRights) | Should -BeFalse |
||||
|
$first.Equals($null) | Should -BeFalse |
||||
|
$equal.AccessControlType = 'Deny' |
||||
|
$first.Equals($equal) | Should -BeFalse |
||||
|
$first.Name | Should -BeExactly 'Rule.txt' |
||||
|
} |
||||
|
|
||||
|
It 'Should reduce the generic <Rights> mask in access entries' -ForEach @( |
||||
|
@{ Rights = 'GenericRead'; Expected = 'Read' } |
||||
|
@{ Rights = 'GenericWrite'; Expected = 'Write' } |
||||
|
@{ Rights = 'GenericExecute'; Expected = 'Read' } |
||||
|
@{ Rights = 'GenericAll'; Expected = 'Read, Write, Delete' } |
||||
|
) { |
||||
|
$entry = New-Object -TypeName 'Security2.SimpleFileSystemAccessRule' -ArgumentList ( |
||||
|
$objectPath, $identity, [Security2.FileSystemRights2] $Rights, |
||||
|
[System.Security.AccessControl.AccessControlType]::Allow |
||||
|
) |
||||
|
|
||||
|
$entry.AccessRights | Should -Be ([Security2.SimpleFileSystemAccessRights] $Expected) |
||||
|
} |
||||
|
|
||||
|
It 'Should convert an identity implicitly to its resolved display name and reject an unresolved name' { |
||||
|
$conversion = [Security2.IdentityReference2].GetMethods([Reflection.BindingFlags] 'Public, Static') | |
||||
|
Where-Object { $_.Name -eq 'op_Implicit' -and $_.ReturnType -eq [string] } |
||||
|
$conversion.Invoke($null, [object[]] @($identity.PSObject.BaseObject)) | Should -BeExactly $identity.ToString() |
||||
|
$unresolved = [Security2.IdentityReference2] 'S-1-5-21-1-2-3-1001' |
||||
|
$unresolved.Equals('Not-resolved') | Should -BeFalse |
||||
|
$identity.Equals($identity.AccountName) | Should -BeTrue |
||||
|
} |
||||
|
|
||||
|
It 'Should compare different privilege values as unequal' { |
||||
|
$constructor = [ProcessPrivileges.PrivilegeAndAttributes].GetConstructor( |
||||
|
[Reflection.BindingFlags] 'NonPublic, Instance', $null, |
||||
|
[type[]] @([ProcessPrivileges.Privilege], [ProcessPrivileges.PrivilegeAttributes]), $null |
||||
|
) |
||||
|
$first = $constructor.Invoke(@([ProcessPrivileges.Privilege]::Backup, [ProcessPrivileges.PrivilegeAttributes]::Disabled)) |
||||
|
$different = $constructor.Invoke(@([ProcessPrivileges.Privilege]::Restore, [ProcessPrivileges.PrivilegeAttributes]::Disabled)) |
||||
|
|
||||
|
$first.Equals($different) | Should -BeFalse |
||||
|
[ProcessPrivileges.PrivilegeAndAttributes]::op_Inequality($first, $different) | Should -BeTrue |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Describe 'Access rule helpers that take a path' { |
||||
|
BeforeAll { |
||||
|
$allow = [System.Security.AccessControl.AccessControlType]::Allow |
||||
|
$noInheritance = [System.Security.AccessControl.InheritanceFlags]::None |
||||
|
$noPropagation = [System.Security.AccessControl.PropagationFlags]::None |
||||
|
$users = [Security2.IdentityReference2] 'S-1-5-32-545' |
||||
|
|
||||
|
function Get-ExplicitEntries { |
||||
|
[Diagnostics.CodeAnalysis.SuppressMessageAttribute( |
||||
|
'PSUseSingularNouns', '', Justification = 'The helper returns the explicit entries of an item.' |
||||
|
)] |
||||
|
param ([string] $Path, [string] $Account = 'S-1-1-0') |
||||
|
|
||||
|
$acl = Get-Acl -LiteralPath $Path |
||||
|
@($acl.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) | |
||||
|
Where-Object -FilterScript { $_.IdentityReference.Value -eq $Account }) |
||||
|
} |
||||
|
|
||||
|
function New-AccountList { |
||||
|
[Diagnostics.CodeAnalysis.SuppressMessageAttribute( |
||||
|
'PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Test helper that only creates a list.' |
||||
|
)] |
||||
|
param () |
||||
|
|
||||
|
$accounts = New-Object -TypeName 'System.Collections.Generic.List[Security2.IdentityReference2]' |
||||
|
$accounts.Add($identity) |
||||
|
$accounts.Add($users) |
||||
|
, $accounts |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
It 'Should add an allow entry with Synchronize to a <Kind> by its path' -ForEach @( |
||||
|
@{ Kind = 'file'; Directory = $false } |
||||
|
@{ Kind = 'folder'; Directory = $true } |
||||
|
) { |
||||
|
$path = New-TestSandboxItem -Sandbox $sandbox -Name 'AddByPath' -Directory:$Directory |
||||
|
|
||||
|
$rule = [Security2.FileSystemAccessRule2]::AddFileSystemAccessRule( |
||||
|
$path, $identity, [Security2.FileSystemRights2]::ReadData, $allow, $noInheritance, $noPropagation |
||||
|
) |
||||
|
|
||||
|
$rule.Account.Sid | Should -BeExactly 'S-1-1-0' |
||||
|
$entries = @(Get-ExplicitEntries -Path $path) |
||||
|
$entries | Should -HaveCount 1 |
||||
|
$entries[0].AccessControlType | Should -Be 'Allow' |
||||
|
$entries[0].FileSystemRights | Should -Be ([System.Security.AccessControl.FileSystemRights] 'ReadData, Synchronize') |
||||
|
} |
||||
|
|
||||
|
# The overload that takes a path returns an iterator, so the caller must enumerate the result to write the entries. |
||||
|
# The overloads that take an item write them at once; this test doesn't pin the difference. |
||||
|
It 'Should add the entries of several accounts to a <Kind> by its path when the result is enumerated' -ForEach @( |
||||
|
@{ Kind = 'file'; Directory = $false } |
||||
|
@{ Kind = 'folder'; Directory = $true } |
||||
|
) { |
||||
|
$path = New-TestSandboxItem -Sandbox $sandbox -Name 'AddListByPath' -Directory:$Directory |
||||
|
$accounts = New-AccountList |
||||
|
|
||||
|
$pending = [Security2.FileSystemAccessRule2]::AddFileSystemAccessRule( |
||||
|
$path, $accounts, [Security2.FileSystemRights2]::ReadData, $allow, $noInheritance, $noPropagation |
||||
|
) |
||||
|
|
||||
|
@($pending) | Should -HaveCount 2 |
||||
|
@(Get-ExplicitEntries -Path $path) | Should -HaveCount 1 |
||||
|
@(Get-ExplicitEntries -Path $path -Account 'S-1-5-32-545') | Should -HaveCount 1 |
||||
|
} |
||||
|
|
||||
|
It 'Should add the deny entries of several accounts without Synchronize when the result is enumerated' { |
||||
|
$path = New-TestSandboxItem -Sandbox $sandbox -Name 'DenyListByPath' |
||||
|
$accounts = New-AccountList |
||||
|
$deny = [System.Security.AccessControl.AccessControlType]::Deny |
||||
|
|
||||
|
@([Security2.FileSystemAccessRule2]::AddFileSystemAccessRule( |
||||
|
$path, $accounts, [Security2.FileSystemRights2]::ReadData, $deny, $noInheritance, $noPropagation |
||||
|
)) | Should -HaveCount 2 |
||||
|
|
||||
|
foreach ($account in 'S-1-1-0', 'S-1-5-32-545') { |
||||
|
$entries = @(Get-ExplicitEntries -Path $path -Account $account) |
||||
|
$entries | Should -HaveCount 1 |
||||
|
$entries[0].AccessControlType | Should -Be 'Deny' |
||||
|
$entries[0].FileSystemRights | Should -Be ([System.Security.AccessControl.FileSystemRights]::ReadData) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
It 'Should add and remove a deny entry by its path without adding Synchronize' { |
||||
|
$path = New-TestSandboxItem -Sandbox $sandbox -Name 'DenyByPath' |
||||
|
$deny = [System.Security.AccessControl.AccessControlType]::Deny |
||||
|
|
||||
|
[void] [Security2.FileSystemAccessRule2]::AddFileSystemAccessRule( |
||||
|
$path, $identity, [Security2.FileSystemRights2]::ReadData, $deny, $noInheritance, $noPropagation |
||||
|
) |
||||
|
|
||||
|
$entries = @(Get-ExplicitEntries -Path $path) |
||||
|
$entries | Should -HaveCount 1 |
||||
|
$entries[0].AccessControlType | Should -Be 'Deny' |
||||
|
$entries[0].FileSystemRights | Should -Be ([System.Security.AccessControl.FileSystemRights]::ReadData) |
||||
|
|
||||
|
[Security2.FileSystemAccessRule2]::RemoveFileSystemAccessRule( |
||||
|
$path, $identity, [Security2.FileSystemRights2]::ReadData, $deny, $noInheritance, $noPropagation |
||||
|
) |
||||
|
|
||||
|
@(Get-ExplicitEntries -Path $path) | Should -BeNullOrEmpty |
||||
|
} |
||||
|
|
||||
|
It 'Should return no entries for an empty DACL when the sources of inherited entries are requested' { |
||||
|
$path = New-TestSandboxItem -Sandbox $sandbox -Name 'EmptyDacl' |
||||
|
Clear-NTFSAccess -Path $path -DisableInheritance -ErrorAction Stop |
||||
|
|
||||
|
$rules = @([Security2.FileSystemAccessRule2]::GetFileSystemAccessRules($path, $true, $true, $true)) |
||||
|
|
||||
|
$rules | Should -BeNullOrEmpty |
||||
|
} |
||||
|
|
||||
|
It 'Should add the entry of a rule that carries its path' { |
||||
|
$path = New-TestSandboxItem -Sandbox $sandbox -Name 'AddRule' |
||||
|
$raw = New-Object -TypeName 'System.Security.AccessControl.FileSystemAccessRule' -ArgumentList ( |
||||
|
$sid, [System.Security.AccessControl.FileSystemRights]::ReadData, $allow |
||||
|
) |
||||
|
$rule = New-Object -TypeName 'Security2.FileSystemAccessRule2' -ArgumentList $raw, $path |
||||
|
|
||||
|
[Security2.FileSystemAccessRule2]::AddFileSystemAccessRule($rule) |
||||
|
|
||||
|
@(Get-ExplicitEntries -Path $path) | Should -HaveCount 1 |
||||
|
} |
||||
|
|
||||
|
# RemoveSpecific removes only an entry that matches exactly; without it, Windows removes the named rights from the |
||||
|
# matching entry. |
||||
|
It 'Should remove only an exactly matching entry of a <Kind> with removeSpecific and the named rights without it' -ForEach @( |
||||
|
@{ Kind = 'file'; Directory = $false } |
||||
|
@{ Kind = 'folder'; Directory = $true } |
||||
|
) { |
||||
|
$path = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveByPath' -Directory:$Directory |
||||
|
[void] [Security2.FileSystemAccessRule2]::AddFileSystemAccessRule( |
||||
|
$path, $identity, [Security2.FileSystemRights2] 'ReadData, WriteData', $allow, $noInheritance, $noPropagation |
||||
|
) |
||||
|
|
||||
|
[Security2.FileSystemAccessRule2]::RemoveFileSystemAccessRule( |
||||
|
$path, $identity, [Security2.FileSystemRights2]::ReadData, $allow, $noInheritance, $noPropagation, $true |
||||
|
) |
||||
|
@(Get-ExplicitEntries -Path $path)[0].FileSystemRights | |
||||
|
Should -Be ([System.Security.AccessControl.FileSystemRights] 'ReadData, WriteData, Synchronize') |
||||
|
|
||||
|
[Security2.FileSystemAccessRule2]::RemoveFileSystemAccessRule( |
||||
|
$path, $identity, [Security2.FileSystemRights2]::ReadData, $allow, $noInheritance, $noPropagation, $false |
||||
|
) |
||||
|
@(Get-ExplicitEntries -Path $path)[0].FileSystemRights | |
||||
|
Should -Be ([System.Security.AccessControl.FileSystemRights] 'WriteData, Synchronize') |
||||
|
|
||||
|
[Security2.FileSystemAccessRule2]::RemoveFileSystemAccessRule( |
||||
|
$path, $identity, [Security2.FileSystemRights2]::WriteData, $allow, $noInheritance, $noPropagation, $true |
||||
|
) |
||||
|
@(Get-ExplicitEntries -Path $path) | Should -BeNullOrEmpty |
||||
|
} |
||||
|
|
||||
|
It 'Should remove the entries of several accounts of a <Kind> by path' -ForEach @( |
||||
|
@{ Kind = 'file'; Directory = $false } |
||||
|
@{ Kind = 'folder'; Directory = $true } |
||||
|
) { |
||||
|
$path = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveListByPath' -Directory:$Directory |
||||
|
$accounts = New-AccountList |
||||
|
@([Security2.FileSystemAccessRule2]::AddFileSystemAccessRule( |
||||
|
$path, $accounts, [Security2.FileSystemRights2]::ReadData, $allow, $noInheritance, $noPropagation |
||||
|
)) | Should -HaveCount 2 |
||||
|
|
||||
|
[Security2.FileSystemAccessRule2]::RemoveFileSystemAccessRule( |
||||
|
$path, $accounts, [Security2.FileSystemRights2]::ReadData, $allow, $noInheritance, $noPropagation, $false |
||||
|
) |
||||
|
|
||||
|
@(Get-ExplicitEntries -Path $path) | Should -BeNullOrEmpty |
||||
|
@(Get-ExplicitEntries -Path $path -Account 'S-1-5-32-545') | Should -BeNullOrEmpty |
||||
|
} |
||||
|
|
||||
|
It 'Should remove the entry that a rule object describes from an item' { |
||||
|
$path = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveRuleObject' |
||||
|
[void] [Security2.FileSystemAccessRule2]::AddFileSystemAccessRule( |
||||
|
$path, $identity, [Security2.FileSystemRights2]::ReadData, $allow, $noInheritance, $noPropagation |
||||
|
) |
||||
|
$raw = New-Object -TypeName 'System.Security.AccessControl.FileSystemAccessRule' -ArgumentList ( |
||||
|
$sid, [System.Security.AccessControl.FileSystemRights]::ReadData, $allow |
||||
|
) |
||||
|
$item = New-Object -TypeName 'Alphaleonis.Win32.Filesystem.FileInfo' -ArgumentList $path |
||||
|
|
||||
|
[Security2.FileSystemAccessRule2]::RemoveFileSystemAccessRule($item, $raw, $false) |
||||
|
|
||||
|
@(Get-ExplicitEntries -Path $path) | Should -BeNullOrEmpty |
||||
|
} |
||||
|
|
||||
|
It 'Should return the explicit entries of a folder, and its inherited ones when asked, by its path' { |
||||
|
$path = New-TestSandboxItem -Sandbox $sandbox -Name 'GetByPath' -Directory |
||||
|
[void] [Security2.FileSystemAccessRule2]::AddFileSystemAccessRule( |
||||
|
$path, $identity, [Security2.FileSystemRights2]::ReadData, $allow, $noInheritance, $noPropagation |
||||
|
) |
||||
|
|
||||
|
$explicit = @([Security2.FileSystemAccessRule2]::GetFileSystemAccessRules($path, $true, $false, $false)) |
||||
|
$all = @([Security2.FileSystemAccessRule2]::GetFileSystemAccessRules($path, $true, $true, $true)) |
||||
|
|
||||
|
$explicit | Should -HaveCount 1 |
||||
|
$explicit[0].Account.Sid | Should -BeExactly 'S-1-1-0' |
||||
|
$all.Count | Should -BeGreaterThan 1 |
||||
|
@($all | Where-Object -FilterScript { $_.Account.Sid -eq 'S-1-1-0' }) | Should -HaveCount 1 |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Describe 'Audit rule helpers that take a path' -Skip:(-not $holdsSecurityPrivilege) { |
||||
|
BeforeAll { |
||||
|
$success = [System.Security.AccessControl.AuditFlags]::Success |
||||
|
$noInheritance = [System.Security.AccessControl.InheritanceFlags]::None |
||||
|
$noPropagation = [System.Security.AccessControl.PropagationFlags]::None |
||||
|
$users = [Security2.IdentityReference2] 'S-1-5-32-545' |
||||
|
|
||||
|
function Get-AuditEntries { |
||||
|
[Diagnostics.CodeAnalysis.SuppressMessageAttribute( |
||||
|
'PSUseSingularNouns', '', Justification = 'The helper returns the audit entries of an item.' |
||||
|
)] |
||||
|
param ([string] $Path, [string] $Account = 'S-1-1-0') |
||||
|
|
||||
|
$descriptor = Get-NTFSSecurityDescriptor -Path $Path |
||||
|
@($descriptor.SecurityDescriptor.GetAuditRules($true, $false, [System.Security.Principal.SecurityIdentifier]) | |
||||
|
Where-Object -FilterScript { $_.IdentityReference.Value -eq $Account }) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
It 'Should add an audit entry to a <Kind> by its path' -ForEach @( |
||||
|
@{ Kind = 'file'; Directory = $false } |
||||
|
@{ Kind = 'folder'; Directory = $true } |
||||
|
) { |
||||
|
$path = New-TestSandboxItem -Sandbox $sandbox -Name 'AuditByPath' -Directory:$Directory |
||||
|
|
||||
|
$rule = [Security2.FileSystemAuditRule2]::AddFileSystemAuditRule( |
||||
|
$path, $identity, [Security2.FileSystemRights2]::Delete, $success, $noInheritance, $noPropagation |
||||
|
) |
||||
|
|
||||
|
$rule.Account.Sid | Should -BeExactly 'S-1-1-0' |
||||
|
$entries = @(Get-AuditEntries -Path $path) |
||||
|
$entries | Should -HaveCount 1 |
||||
|
$entries[0].AuditFlags | Should -Be 'Success' |
||||
|
$entries[0].FileSystemRights | Should -Be ([System.Security.AccessControl.FileSystemRights]::Delete) |
||||
|
$found = @([Security2.FileSystemAuditRule2]::GetFileSystemAuditRules($path, $true, $false)) |
||||
|
$found | Should -HaveCount 1 |
||||
|
$found[0].Account.Sid | Should -BeExactly 'S-1-1-0' |
||||
|
$found[0].FullName | Should -BeExactly $path |
||||
|
} |
||||
|
|
||||
|
It 'Should add the entries of several accounts to a <Kind> by its path when the result is enumerated, and remove them again' -ForEach @( |
||||
|
@{ Kind = 'file'; Directory = $false } |
||||
|
@{ Kind = 'folder'; Directory = $true } |
||||
|
) { |
||||
|
$path = New-TestSandboxItem -Sandbox $sandbox -Name 'AuditListByPath' -Directory:$Directory |
||||
|
$accounts = New-Object -TypeName 'System.Collections.Generic.List[Security2.IdentityReference2]' |
||||
|
$accounts.Add($identity) |
||||
|
$accounts.Add($users) |
||||
|
|
||||
|
$pending = [Security2.FileSystemAuditRule2]::AddFileSystemAuditRule( |
||||
|
$path, $accounts, [Security2.FileSystemRights2]::Delete, $success, $noInheritance, $noPropagation |
||||
|
) |
||||
|
|
||||
|
@($pending) | Should -HaveCount 2 |
||||
|
@(Get-AuditEntries -Path $path) | Should -HaveCount 1 |
||||
|
@(Get-AuditEntries -Path $path -Account 'S-1-5-32-545') | Should -HaveCount 1 |
||||
|
[Security2.FileSystemAuditRule2]::RemoveFileSystemAuditRule( |
||||
|
$path, $identity, [Security2.FileSystemRights2]::Delete, $success, $noInheritance, $noPropagation, $true |
||||
|
) |
||||
|
@(Get-AuditEntries -Path $path) | Should -BeNullOrEmpty |
||||
|
@(Get-AuditEntries -Path $path -Account 'S-1-5-32-545') | Should -HaveCount 1 |
||||
|
[Security2.FileSystemAuditRule2]::RemoveFileSystemAuditRule( |
||||
|
$path, $users, [Security2.FileSystemRights2]::Delete, $success, $noInheritance, $noPropagation, $false |
||||
|
) |
||||
|
@(Get-AuditEntries -Path $path -Account 'S-1-5-32-545') | Should -BeNullOrEmpty |
||||
|
} |
||||
|
|
||||
|
It 'Should name the item of a rule, replay it, read it by path, and remove it by its rule object' { |
||||
|
$path = New-TestSandboxItem -Sandbox $sandbox -Name 'AuditReplay' |
||||
|
$raw = New-Object -TypeName 'System.Security.AccessControl.FileSystemAuditRule' -ArgumentList ( |
||||
|
$sid, [System.Security.AccessControl.FileSystemRights]::Delete, $success |
||||
|
) |
||||
|
$item = New-Object -TypeName 'Alphaleonis.Win32.Filesystem.FileInfo' -ArgumentList $path |
||||
|
$rule = New-Object -TypeName 'Security2.FileSystemAuditRule2' -ArgumentList $raw, $item |
||||
|
$rule.FullName | Should -BeExactly $path |
||||
|
$rule.Name | Should -BeExactly (Split-Path -Path $path -Leaf) |
||||
|
|
||||
|
[Security2.FileSystemAuditRule2]::AddFileSystemAuditRule($rule) |
||||
|
|
||||
|
@(Get-AuditEntries -Path $path) | Should -HaveCount 1 |
||||
|
$found = @([Security2.FileSystemAuditRule2]::GetFileSystemAuditRules($path, $true, $true)) |
||||
|
$found | Should -HaveCount 1 |
||||
|
$found[0].Account.Sid | Should -BeExactly 'S-1-1-0' |
||||
|
[Security2.FileSystemAuditRule2]::RemoveFileSystemAuditRule($item, $raw) |
||||
|
@(Get-AuditEntries -Path $path) | Should -BeNullOrEmpty |
||||
|
} |
||||
|
|
||||
|
It 'Should remove a rule object from an item without audit entries and change nothing' { |
||||
|
$path = New-TestSandboxItem -Sandbox $sandbox -Name 'AuditNothing' |
||||
|
$raw = New-Object -TypeName 'System.Security.AccessControl.FileSystemAuditRule' -ArgumentList ( |
||||
|
$sid, [System.Security.AccessControl.FileSystemRights]::Delete, $success |
||||
|
) |
||||
|
$item = New-Object -TypeName 'Alphaleonis.Win32.Filesystem.FileInfo' -ArgumentList $path |
||||
|
$before = (Get-Acl -LiteralPath $path).Sddl |
||||
|
|
||||
|
[Security2.FileSystemAuditRule2]::RemoveFileSystemAuditRule($item, $raw) |
||||
|
|
||||
|
(Get-Acl -LiteralPath $path).Sddl | Should -BeExactly $before |
||||
|
@(Get-AuditEntries -Path $path) | Should -BeNullOrEmpty |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Describe 'Inheritance helpers that take a path' { |
||||
|
It 'Should block and restore the access inheritance of a <Kind> by its path' -ForEach @( |
||||
|
@{ Kind = 'file'; Directory = $false; Remove = $false } |
||||
|
@{ Kind = 'folder'; Directory = $true; Remove = $true } |
||||
|
) { |
||||
|
$path = New-TestSandboxItem -Sandbox $sandbox -Name 'InheritanceByPath' -Directory:$Directory |
||||
|
$inherited = @((Get-Acl -LiteralPath $path).GetAccessRules($false, $true, [System.Security.Principal.SecurityIdentifier])).Count |
||||
|
$inherited | Should -BeGreaterThan 0 |
||||
|
|
||||
|
[Security2.FileSystemInheritanceInfo]::DisableAccessInheritance($path, $Remove) |
||||
|
|
||||
|
$acl = Get-Acl -LiteralPath $path |
||||
|
$acl.AreAccessRulesProtected | Should -BeTrue |
||||
|
@($acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])).Count | |
||||
|
Should -Be $(if ($Remove) { 0 } else { $inherited }) |
||||
|
|
||||
|
[Security2.FileSystemInheritanceInfo]::EnableAccessInheritance($path, $Remove) |
||||
|
|
||||
|
$acl = Get-Acl -LiteralPath $path |
||||
|
$acl.AreAccessRulesProtected | Should -BeFalse |
||||
|
@($acl.GetAccessRules($false, $true, [System.Security.Principal.SecurityIdentifier])).Count | Should -Be $inherited |
||||
|
} |
||||
|
|
||||
|
It 'Should read the access inheritance of a file by its path and keep what the caller sets on the result' { |
||||
|
$path = New-TestSandboxItem -Sandbox $sandbox -Name 'InheritanceInfo' |
||||
|
|
||||
|
$info = [Security2.FileSystemInheritanceInfo]::GetFileSystemInheritanceInfo($path) |
||||
|
|
||||
|
$info.AccessInheritanceEnabled | Should -BeTrue |
||||
|
$info.Item.FullName | Should -BeExactly $path |
||||
|
$info.AccessInheritanceEnabled = $false |
||||
|
$info.AuditInheritanceEnabled = $true |
||||
|
$info.Item = New-Object -TypeName 'Alphaleonis.Win32.Filesystem.FileInfo' -ArgumentList $path |
||||
|
$info.AccessInheritanceEnabled | Should -BeFalse |
||||
|
$info.AuditInheritanceEnabled | Should -BeTrue |
||||
|
(Get-Acl -LiteralPath $path).AreAccessRulesProtected | Should -BeFalse |
||||
|
} |
||||
|
|
||||
|
# The overloads that take a path do nothing for a path that is neither a file nor a folder. |
||||
|
It '<Method> should change nothing for a path that does not exist' -ForEach @( |
||||
|
@{ Method = 'EnableAccessInheritance' } |
||||
|
@{ Method = 'DisableAccessInheritance' } |
||||
|
@{ Method = 'EnableAuditInheritance' } |
||||
|
@{ Method = 'DisableAuditInheritance' } |
||||
|
) { |
||||
|
$missing = Join-Path -Path $sandbox -ChildPath ('Missing-{0}' -f [guid]::NewGuid().ToString('N')) |
||||
|
Assert-TestSandboxPath -Sandbox $sandbox -Path $missing |
||||
|
|
||||
|
{ [Security2.FileSystemInheritanceInfo]::$Method($missing, $true) } | Should -Not -Throw |
||||
|
|
||||
|
Test-Path -LiteralPath $missing | Should -BeFalse |
||||
|
} |
||||
|
|
||||
|
It 'Should block and restore the audit inheritance of a <Kind> by its path' -Skip:(-not $holdsSecurityPrivilege) -ForEach @( |
||||
|
@{ Kind = 'file'; Directory = $false } |
||||
|
@{ Kind = 'folder'; Directory = $true } |
||||
|
) { |
||||
|
$path = New-TestSandboxItem -Sandbox $sandbox -Name 'AuditInheritanceByPath' -Directory:$Directory |
||||
|
(Get-NTFSInheritance -Path $path).AuditInheritanceEnabled | Should -BeTrue |
||||
|
|
||||
|
[Security2.FileSystemInheritanceInfo]::DisableAuditInheritance($path, $false) |
||||
|
|
||||
|
(Get-NTFSInheritance -Path $path).AuditInheritanceEnabled | Should -BeFalse |
||||
|
|
||||
|
[Security2.FileSystemInheritanceInfo]::EnableAuditInheritance($path, $false) |
||||
|
|
||||
|
(Get-NTFSInheritance -Path $path).AuditInheritanceEnabled | Should -BeTrue |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Describe 'Owner and descriptor objects' { |
||||
|
It 'Should name the item and the account of an owner object' { |
||||
|
$path = New-TestSandboxItem -Sandbox $sandbox -Name 'OwnerObject' |
||||
|
|
||||
|
$owner = Get-NTFSOwner -Path $path |
||||
|
|
||||
|
$owner.Item.FullName | Should -BeExactly $path |
||||
|
$owner.FullName | Should -BeExactly $path |
||||
|
$owner.Account.Sid | Should -BeExactly $owner.Owner.Sid |
||||
|
} |
||||
|
|
||||
|
It 'Should read the owner of a drive root also for a lowercase drive letter' { |
||||
|
$root = [IO.Path]::GetPathRoot($sandbox) |
||||
|
$expected = (Get-NTFSOwner -Path $root).Owner.Sid |
||||
|
|
||||
|
$owner = Get-NTFSOwner -Path $root.ToLowerInvariant() |
||||
|
|
||||
|
$owner.Owner.Sid | Should -BeExactly $expected |
||||
|
} |
||||
|
|
||||
|
It 'Should name the item of a descriptor and write it to a folder by its path' { |
||||
|
$source = New-TestSandboxItem -Sandbox $sandbox -Name 'DescriptorSource' -Directory |
||||
|
$target = New-TestSandboxItem -Sandbox $sandbox -Name 'DescriptorTarget' -Directory |
||||
|
Add-NTFSAccess -Path $source -Account 'S-1-1-0' -AccessRights ReadData -AppliesTo ThisFolderOnly |
||||
|
$descriptor = Get-NTFSSecurityDescriptor -Path $source |
||||
|
|
||||
|
$descriptor.Name | Should -BeExactly (Split-Path -Path $source -Leaf) |
||||
|
$descriptor.Write([string] $target) |
||||
|
|
||||
|
@((Get-Acl -LiteralPath $target).GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) | |
||||
|
Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-1-0' }) | Should -HaveCount 1 |
||||
|
} |
||||
|
|
||||
|
# The item decides where Write puts the sections that the descriptor was read with, and Name and FullName follow it. |
||||
|
It 'Should write a descriptor to the item that the caller assigns' { |
||||
|
$source = New-TestSandboxItem -Sandbox $sandbox -Name 'RetargetSource' |
||||
|
$target = New-TestSandboxItem -Sandbox $sandbox -Name 'RetargetTarget' |
||||
|
Add-NTFSAccess -Path $source -Account 'S-1-1-0' -AccessRights ReadData |
||||
|
$descriptor = Get-NTFSSecurityDescriptor -Path $source |
||||
|
$descriptor.Item = Get-Item2 -Path $target |
||||
|
|
||||
|
$descriptor.FullName | Should -BeExactly $target |
||||
|
$descriptor.Name | Should -BeExactly (Split-Path -Path $target -Leaf) |
||||
|
$descriptor.Write() |
||||
|
|
||||
|
foreach ($path in $source, $target) { |
||||
|
@((Get-Acl -LiteralPath $path).GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) | |
||||
|
Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-1-0' }) | Should -HaveCount 1 |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
It 'Should name the missing path when it writes a descriptor to an item that does not exist' { |
||||
|
$source = New-TestSandboxItem -Sandbox $sandbox -Name 'DescriptorMissingSource' |
||||
|
$missing = Join-Path -Path $sandbox -ChildPath ('Missing-{0}' -f [guid]::NewGuid().ToString('N')) |
||||
|
Assert-TestSandboxPath -Sandbox $sandbox -Path $missing |
||||
|
$descriptor = Get-NTFSSecurityDescriptor -Path $source |
||||
|
|
||||
|
$failure = { $descriptor.Write($missing) } | Should -Throw -PassThru |
||||
|
|
||||
|
$failure.Exception.GetBaseException() | Should -BeOfType [System.IO.FileNotFoundException] |
||||
|
$failure.Exception.GetBaseException().FileName | Should -BeExactly $missing |
||||
|
} |
||||
|
|
||||
|
It 'Should leave both flags unset for an AppliesTo value that no case names' { |
||||
|
$inheritance = [System.Security.AccessControl.InheritanceFlags]::ContainerInherit |
||||
|
$propagation = [System.Security.AccessControl.PropagationFlags]::InheritOnly |
||||
|
|
||||
|
[Security2.FileSystemSecurity2]::ConvertToFileSystemFlags( |
||||
|
[Enum]::ToObject([Security2.ApplyTo], 99), [ref] $inheritance, [ref] $propagation |
||||
|
) |
||||
|
|
||||
|
$inheritance | Should -Be 'None' |
||||
|
$propagation | Should -Be 'None' |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Describe 'Generic access rights and identity errors' { |
||||
|
It 'Should map the generic mask <Mask> to the file system rights <Expected>' -ForEach @( |
||||
|
@{ Mask = '80000000'; Expected = '00120089' } |
||||
|
@{ Mask = '40000000'; Expected = '00120116' } |
||||
|
@{ Mask = '20000000'; Expected = '001200A0' } |
||||
|
@{ Mask = '10000000'; Expected = '001F01FF' } |
||||
|
@{ Mask = 'C0000000'; Expected = '0012019F' } |
||||
|
@{ Mask = '80010000'; Expected = '00130089' } |
||||
|
@{ Mask = '001F01FF'; Expected = '001F01FF' } |
||||
|
@{ Mask = '00120089'; Expected = '00120089' } |
||||
|
@{ Mask = '02000000'; Expected = '02000000' } |
||||
|
@{ Mask = '82000000'; Expected = '02120089' } |
||||
|
@{ Mask = '00000000'; Expected = '00000000' } |
||||
|
) { |
||||
|
$rights = [Security2.FileSystemSecurity2]::MapGenericRightsToFileSystemRights([Convert]::ToUInt32($Mask, 16)) |
||||
|
|
||||
|
[int] $rights | Should -Be ([Convert]::ToInt32($Expected, 16)) |
||||
|
} |
||||
|
|
||||
|
It 'Should reject <Case> when it creates an identity' -ForEach @( |
||||
|
@{ Case = 'an empty value'; Value = ''; Expected = [System.ArgumentException] } |
||||
|
@{ Case = 'a SID with too many sub authorities'; Value = ('S-1-' + (('1-' * 20) + '1')); Expected = [System.InvalidCastException] } |
||||
|
@{ Case = 'an account that does not exist'; Value = 'NTFSSecurityNoSuchAccount'; Expected = [System.Security.Principal.IdentityNotMappedException] } |
||||
|
) { |
||||
|
$failure = { [Security2.IdentityReference2]::new($Value) } | Should -Throw -PassThru |
||||
|
|
||||
|
# PowerShell wraps the exception of a constructor, which here wraps the cause of an invalid SID in turn. |
||||
|
$failure.Exception.InnerException | Should -BeOfType $Expected |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
# The helpers of the cmdlets are public extension methods, so a script can call them. The cmdlets pass what the guards |
||||
|
# of ForEach refuse and a parent that is a folder, never a file; the generic method is invoked through reflection, |
||||
|
# because Windows PowerShell can't name the type argument of a call. |
||||
|
Describe 'The extension methods of the cmdlets' { |
||||
|
BeforeAll { |
||||
|
$forEachMethod = [NTFSSecurity.Extensions].GetMethod('ForEach').MakeGenericMethod([string]) |
||||
|
|
||||
|
function New-ItemObject { |
||||
|
[Diagnostics.CodeAnalysis.SuppressMessageAttribute( |
||||
|
'PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Test helper that only creates an object.' |
||||
|
)] |
||||
|
param ([string] $Library, [string] $Path) |
||||
|
|
||||
|
if ($Library -eq 'AlphaFS') { |
||||
|
[Alphaleonis.Win32.Filesystem.FileInfo]::new($Path) |
||||
|
} |
||||
|
else { |
||||
|
[System.IO.FileInfo]::new($Path) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
It 'ForEach should reject <Case>' -ForEach @( |
||||
|
@{ Case = 'a source that is null'; NullSource = $true } |
||||
|
@{ Case = 'an action that is null'; NullSource = $false } |
||||
|
) { |
||||
|
$arguments = [object[]]::new(2) |
||||
|
if ($NullSource) { |
||||
|
$arguments[1] = [System.Action[string]] { param ($Element) $null = $Element } |
||||
|
} |
||||
|
else { |
||||
|
$arguments[0] = [string[]] @('One') |
||||
|
} |
||||
|
|
||||
|
$failure = { $forEachMethod.Invoke($null, $arguments) } | Should -Throw -PassThru |
||||
|
|
||||
|
# Reflection wraps the exception of the method, and PowerShell wraps that in turn. |
||||
|
$failure.Exception.GetBaseException() | Should -BeOfType [System.ArgumentException] |
||||
|
} |
||||
|
|
||||
|
It 'ForEach should run the action for each element in order' { |
||||
|
$seen = New-Object -TypeName 'System.Collections.Generic.List[string]' |
||||
|
$arguments = [object[]]::new(2) |
||||
|
$arguments[0] = [string[]] @('One', 'Two', 'Three') |
||||
|
$arguments[1] = [System.Action[string]] { param ($Element) $seen.Add($Element) } |
||||
|
|
||||
|
$forEachMethod.Invoke($null, $arguments) | Out-Null |
||||
|
|
||||
|
$seen -join ',' | Should -BeExactly 'One,Two,Three' |
||||
|
} |
||||
|
|
||||
|
Context 'GetParent' { |
||||
|
BeforeAll { |
||||
|
$folder = New-TestSandboxItem -Sandbox $sandbox -Name 'ParentFolder' -Directory |
||||
|
$file = New-TestSandboxItem -Sandbox $sandbox -Name 'ParentFile' |
||||
|
$inFolder = Join-Path -Path $folder -ChildPath 'Child.txt' |
||||
|
$belowFile = Join-Path -Path $file -ChildPath 'Child.txt' |
||||
|
$belowMissing = Join-Path -Path $sandbox -ChildPath 'Missing\Child.txt' |
||||
|
} |
||||
|
|
||||
|
It 'Should return a folder as a DirectoryInfo of <Library>' -ForEach @( |
||||
|
@{ Library = 'AlphaFS'; TypeName = 'Alphaleonis.Win32.Filesystem.DirectoryInfo' } |
||||
|
@{ Library = 'System.IO'; TypeName = 'System.IO.DirectoryInfo' } |
||||
|
) { |
||||
|
$parent = [NTFSSecurity.Extensions]::GetParent((New-ItemObject -Library $Library -Path $inFolder)) |
||||
|
|
||||
|
$parent.GetType().FullName | Should -BeExactly $TypeName |
||||
|
$parent.FullName | Should -BeExactly $folder |
||||
|
} |
||||
|
|
||||
|
# A FileInfo can name a path below a file, which no file system holds, so the parent path names a file. |
||||
|
It 'Should return a parent path that names a file as a FileInfo of <Library>' -ForEach @( |
||||
|
@{ Library = 'AlphaFS'; TypeName = 'Alphaleonis.Win32.Filesystem.FileInfo' } |
||||
|
@{ Library = 'System.IO'; TypeName = 'System.IO.FileInfo' } |
||||
|
) { |
||||
|
$parent = [NTFSSecurity.Extensions]::GetParent((New-ItemObject -Library $Library -Path $belowFile)) |
||||
|
|
||||
|
$parent.GetType().FullName | Should -BeExactly $TypeName |
||||
|
$parent.FullName | Should -BeExactly $file |
||||
|
} |
||||
|
|
||||
|
It 'Should throw a FileNotFoundException for a parent that does not exist, for <Library>' -ForEach @( |
||||
|
@{ Library = 'AlphaFS' } |
||||
|
@{ Library = 'System.IO' } |
||||
|
) { |
||||
|
$failure = { [NTFSSecurity.Extensions]::GetParent((New-ItemObject -Library $Library -Path $belowMissing)) } | |
||||
|
Should -Throw -PassThru |
||||
|
|
||||
|
$failure.Exception.GetBaseException() | Should -BeOfType [System.IO.FileNotFoundException] |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,638 @@ |
|||||
|
<# |
||||
|
Tests how the cmdlets of the module built in NTFSSecurity\bin\Release behave when a later command in the pipeline |
||||
|
ends it: a break or continue in a script block, Select-Object -First, or a terminating error such as a throw. The |
||||
|
exception that carries it passes through the cmdlet while it writes an object, an error, a verbose message, or a debug |
||||
|
message. A catch-all for the failures of an item must not report it as an error of that item and go on with the next |
||||
|
one: a cmdlet that removes, copies, moves, or changes items would change them all, although the caller ended the |
||||
|
pipeline, and the caller would never see the exception. Every test works on files and folders in a sandbox. |
||||
|
#> |
||||
|
[Diagnostics.CodeAnalysis.SuppressMessageAttribute( |
||||
|
'PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Pester shares variables between blocks.' |
||||
|
)] |
||||
|
param () |
||||
|
|
||||
|
BeforeDiscovery { |
||||
|
Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force |
||||
|
$canReadAudit = Test-PrivilegeHeld -Name 'SeSecurityPrivilege' |
||||
|
|
||||
|
$names = @( |
||||
|
'Get-ChildItem2', 'Get-DiskSpace', 'Get-FileHash2', 'Get-Item2', 'Get-NTFSAccess', 'Get-NTFSEffectiveAccess', |
||||
|
'Get-NTFSHardLink', 'Get-NTFSInheritance', 'Get-NTFSOrphanedAccess', 'Get-NTFSOwner', 'Get-NTFSSecurityDescriptor', |
||||
|
'Get-NTFSSimpleAccess', 'Get-Privileges', 'Test-Path2', 'Add-NTFSAccess', 'Remove-NTFSAccess', |
||||
|
'Disable-NTFSAccessInheritance', 'Enable-NTFSAccessInheritance', 'Set-NTFSInheritance', 'Set-NTFSOwner', |
||||
|
'Set-NTFSSecurityDescriptor', 'Copy-Item2', 'Move-Item2', 'Remove-Item2' |
||||
|
) |
||||
|
$auditNames = @( |
||||
|
'Get-NTFSAudit', 'Get-NTFSOrphanedAudit', 'Add-NTFSAudit', 'Remove-NTFSAudit', 'Disable-NTFSAuditInheritance', |
||||
|
'Enable-NTFSAuditInheritance' |
||||
|
) |
||||
|
$loopCases = foreach ($name in $names) { |
||||
|
foreach ($keyword in 'break', 'continue') { |
||||
|
@{ Name = $name; Keyword = $keyword } |
||||
|
} |
||||
|
} |
||||
|
$stopCases = foreach ($name in $names) { |
||||
|
@{ Name = $name } |
||||
|
} |
||||
|
$auditLoopCases = foreach ($name in $auditNames) { |
||||
|
foreach ($keyword in 'break', 'continue') { |
||||
|
@{ Name = $name; Keyword = $keyword } |
||||
|
} |
||||
|
} |
||||
|
$auditStopCases = foreach ($name in $auditNames) { |
||||
|
@{ Name = $name } |
||||
|
} |
||||
|
$failureCases = foreach ($name in $names) { |
||||
|
foreach ($style in 'throw', 'Write-Error -ErrorAction Stop') { |
||||
|
@{ Name = $name; Style = $style } |
||||
|
} |
||||
|
} |
||||
|
$auditFailureCases = foreach ($name in $auditNames) { |
||||
|
foreach ($style in 'throw', 'Write-Error -ErrorAction Stop') { |
||||
|
@{ Name = $name; Style = $style } |
||||
|
} |
||||
|
} |
||||
|
$streamCases = foreach ($case in @( |
||||
|
@{ Name = 'Get-FileHash2'; Stream = 'verbose' } |
||||
|
@{ Name = 'Set-NTFSSecurityDescriptor'; Stream = 'verbose' } |
||||
|
@{ Name = 'Set-NTFSOwner'; Stream = 'debug' } |
||||
|
)) { |
||||
|
foreach ($style in 'Select-Object -First 1', 'throw') { |
||||
|
@{ Name = $case.Name; Stream = $case.Stream; Style = $style } |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
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 'PipelineControl' |
||||
|
Push-Location -LiteralPath $sandbox |
||||
|
|
||||
|
$sidType = [System.Security.Principal.SecurityIdentifier] |
||||
|
$currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value |
||||
|
$orphan = 'S-1-5-21-1-2-3-1001' |
||||
|
$privateData = (Get-Module -Name NTFSSecurity).PrivateData |
||||
|
|
||||
|
function New-Pair { |
||||
|
[Diagnostics.CodeAnalysis.SuppressMessageAttribute( |
||||
|
'PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Test helper that only writes to the sandbox.' |
||||
|
)] |
||||
|
param ([switch] $Directory) |
||||
|
|
||||
|
@{ |
||||
|
First = New-TestSandboxItem -Sandbox $sandbox -Name 'First' -Directory:$Directory |
||||
|
Second = New-TestSandboxItem -Sandbox $sandbox -Name 'Second' -Directory:$Directory |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function Test-ExplicitEntry { |
||||
|
param ([string] $Path, [string] $Account) |
||||
|
|
||||
|
@((Get-Acl -LiteralPath $Path).GetAccessRules($true, $false, $sidType) | |
||||
|
Where-Object -FilterScript { $_.IdentityReference.Value -eq $Account }).Count -gt 0 |
||||
|
} |
||||
|
|
||||
|
# Each case runs one command over the two items of its context. Untouched tells whether the second item is as it |
||||
|
# was, which it is only when the command stopped after the first one. |
||||
|
$cases = @{ |
||||
|
'Get-ChildItem2' = @{ |
||||
|
# The first file is two levels below the folder, so the exception passes the frames of the recursion. |
||||
|
Prepare = { |
||||
|
$top = New-TestSandboxItem -Sandbox $sandbox -Name 'Tree' -Directory |
||||
|
foreach ($relative in 'A\B\Two.txt', 'C\Three.txt') { |
||||
|
$file = Join-Path -Path $top -ChildPath $relative |
||||
|
Assert-TestSandboxPath -Sandbox $sandbox -Path $file |
||||
|
New-Item -ItemType Directory -Path (Split-Path -Path $file -Parent) -Force | Out-Null |
||||
|
Set-Content -LiteralPath $file -Value 'Tree' |
||||
|
} |
||||
|
@{ Top = $top } |
||||
|
} |
||||
|
Run = { param ($Context) Get-ChildItem2 -Path $Context.Top -Recurse -File -ErrorAction SilentlyContinue } |
||||
|
} |
||||
|
'Get-DiskSpace' = @{ |
||||
|
Prepare = { @{} } |
||||
|
Run = { Get-DiskSpace -ErrorAction SilentlyContinue } |
||||
|
} |
||||
|
'Get-FileHash2' = @{ |
||||
|
Prepare = { New-Pair } |
||||
|
Run = { param ($Context) Get-FileHash2 -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue } |
||||
|
} |
||||
|
'Get-Item2' = @{ |
||||
|
Prepare = { New-Pair } |
||||
|
Run = { param ($Context) Get-Item2 -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue } |
||||
|
} |
||||
|
'Get-NTFSAccess' = @{ |
||||
|
Prepare = { New-Pair } |
||||
|
Run = { param ($Context) Get-NTFSAccess -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue } |
||||
|
} |
||||
|
'Get-NTFSEffectiveAccess' = @{ |
||||
|
Prepare = { New-Pair } |
||||
|
Run = { param ($Context) Get-NTFSEffectiveAccess -Path $Context.First, $Context.Second -WarningAction SilentlyContinue -ErrorAction SilentlyContinue } |
||||
|
} |
||||
|
'Get-NTFSHardLink' = @{ |
||||
|
Prepare = { New-Pair } |
||||
|
Run = { param ($Context) Get-NTFSHardLink -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue } |
||||
|
} |
||||
|
'Get-NTFSInheritance' = @{ |
||||
|
Prepare = { New-Pair } |
||||
|
Run = { param ($Context) Get-NTFSInheritance -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue } |
||||
|
} |
||||
|
'Get-NTFSOrphanedAccess' = @{ |
||||
|
Prepare = { |
||||
|
$context = New-Pair |
||||
|
Add-NTFSAccess -Path $context.First, $context.Second -Account $orphan -AccessRights ReadData -ErrorAction Stop |
||||
|
$context |
||||
|
} |
||||
|
Run = { param ($Context) Get-NTFSOrphanedAccess -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue } |
||||
|
} |
||||
|
'Get-NTFSOwner' = @{ |
||||
|
Prepare = { New-Pair } |
||||
|
Run = { param ($Context) Get-NTFSOwner -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue } |
||||
|
} |
||||
|
'Get-NTFSSecurityDescriptor' = @{ |
||||
|
Prepare = { New-Pair } |
||||
|
Run = { param ($Context) Get-NTFSSecurityDescriptor -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue } |
||||
|
} |
||||
|
'Get-NTFSSimpleAccess' = @{ |
||||
|
Prepare = { New-Pair -Directory } |
||||
|
Run = { param ($Context) Get-NTFSSimpleAccess -Path $Context.First, $Context.Second -IncludeRootFolder:$false -ErrorAction SilentlyContinue } |
||||
|
} |
||||
|
'Get-Privileges' = @{ |
||||
|
Prepare = { @{} } |
||||
|
Run = { Get-Privileges -ErrorAction SilentlyContinue } |
||||
|
} |
||||
|
'Test-Path2' = @{ |
||||
|
Prepare = { New-Pair } |
||||
|
Run = { param ($Context) Test-Path2 -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue } |
||||
|
} |
||||
|
'Add-NTFSAccess' = @{ |
||||
|
Prepare = { New-Pair } |
||||
|
Run = { param ($Context) Add-NTFSAccess -Path $Context.First, $Context.Second -Account 'S-1-1-0' -AccessRights ReadData -PassThru -ErrorAction SilentlyContinue } |
||||
|
Untouched = { param ($Context) -not (Test-ExplicitEntry -Path $Context.Second -Account 'S-1-1-0') } |
||||
|
} |
||||
|
'Remove-NTFSAccess' = @{ |
||||
|
Prepare = { |
||||
|
$context = New-Pair |
||||
|
Add-NTFSAccess -Path $context.First, $context.Second -Account 'S-1-1-0' -AccessRights ReadData -ErrorAction Stop |
||||
|
$context |
||||
|
} |
||||
|
Run = { param ($Context) Remove-NTFSAccess -Path $Context.First, $Context.Second -Account 'S-1-1-0' -AccessRights ReadData -PassThru -ErrorAction SilentlyContinue } |
||||
|
Untouched = { param ($Context) Test-ExplicitEntry -Path $Context.Second -Account 'S-1-1-0' } |
||||
|
} |
||||
|
'Disable-NTFSAccessInheritance' = @{ |
||||
|
Prepare = { New-Pair } |
||||
|
Run = { param ($Context) Disable-NTFSAccessInheritance -Path $Context.First, $Context.Second -PassThru -ErrorAction SilentlyContinue } |
||||
|
Untouched = { param ($Context) -not (Get-Acl -LiteralPath $Context.Second).AreAccessRulesProtected } |
||||
|
} |
||||
|
'Enable-NTFSAccessInheritance' = @{ |
||||
|
Prepare = { |
||||
|
$context = New-Pair |
||||
|
Disable-NTFSAccessInheritance -Path $context.First, $context.Second -ErrorAction Stop |
||||
|
$context |
||||
|
} |
||||
|
Run = { param ($Context) Enable-NTFSAccessInheritance -Path $Context.First, $Context.Second -PassThru -ErrorAction SilentlyContinue } |
||||
|
Untouched = { param ($Context) (Get-Acl -LiteralPath $Context.Second).AreAccessRulesProtected } |
||||
|
} |
||||
|
'Set-NTFSInheritance' = @{ |
||||
|
Prepare = { New-Pair } |
||||
|
Run = { param ($Context) Set-NTFSInheritance -Path $Context.First, $Context.Second -AccessInheritanceEnabled $false -PassThru -ErrorAction SilentlyContinue } |
||||
|
Untouched = { param ($Context) -not (Get-Acl -LiteralPath $Context.Second).AreAccessRulesProtected } |
||||
|
} |
||||
|
'Set-NTFSOwner' = @{ |
||||
|
Prepare = { New-Pair } |
||||
|
Run = { param ($Context) Set-NTFSOwner -Path $Context.First, $Context.Second -Account $currentUser -PassThru -ErrorAction SilentlyContinue } |
||||
|
} |
||||
|
'Set-NTFSSecurityDescriptor' = @{ |
||||
|
Prepare = { |
||||
|
$context = New-Pair |
||||
|
$context.Descriptors = @(Get-NTFSSecurityDescriptor -Path $context.First, $context.Second -ErrorAction Stop) |
||||
|
Add-NTFSAccess -SecurityDescriptor $context.Descriptors -Account 'S-1-1-0' -AccessRights ReadData -ErrorAction Stop |
||||
|
$context |
||||
|
} |
||||
|
Run = { param ($Context) Set-NTFSSecurityDescriptor -SecurityDescriptor $Context.Descriptors -PassThru -ErrorAction SilentlyContinue } |
||||
|
Untouched = { param ($Context) -not (Test-ExplicitEntry -Path $Context.Second -Account 'S-1-1-0') } |
||||
|
} |
||||
|
'Copy-Item2' = @{ |
||||
|
Prepare = { |
||||
|
$context = New-Pair |
||||
|
$context.Destination = New-TestSandboxItem -Sandbox $sandbox -Name 'CopyTo' -Directory |
||||
|
$context |
||||
|
} |
||||
|
Run = { param ($Context) Copy-Item2 -Path $Context.First, $Context.Second -Destination $Context.Destination -PassThru $true -ErrorAction SilentlyContinue } |
||||
|
Untouched = { param ($Context) -not (Test-Path -LiteralPath (Join-Path -Path $Context.Destination -ChildPath (Split-Path -Path $Context.Second -Leaf))) } |
||||
|
} |
||||
|
'Move-Item2' = @{ |
||||
|
Prepare = { |
||||
|
$context = New-Pair |
||||
|
$context.Destination = New-TestSandboxItem -Sandbox $sandbox -Name 'MoveTo' -Directory |
||||
|
$context |
||||
|
} |
||||
|
Run = { param ($Context) Move-Item2 -Path $Context.First, $Context.Second -Destination $Context.Destination -PassThru $true -ErrorAction SilentlyContinue } |
||||
|
Untouched = { param ($Context) Test-Path -LiteralPath $Context.Second } |
||||
|
} |
||||
|
'Remove-Item2' = @{ |
||||
|
Prepare = { New-Pair } |
||||
|
Run = { param ($Context) Remove-Item2 -Path $Context.First, $Context.Second -PassThru -ErrorAction SilentlyContinue } |
||||
|
Untouched = { param ($Context) Test-Path -LiteralPath $Context.Second } |
||||
|
} |
||||
|
'Get-NTFSAudit' = @{ |
||||
|
Prepare = { |
||||
|
$context = New-Pair |
||||
|
Add-NTFSAudit -Path $context.First, $context.Second -Account 'S-1-1-0' -AccessRights Delete -AuditFlags Success -ErrorAction Stop |
||||
|
$context |
||||
|
} |
||||
|
Run = { param ($Context) Get-NTFSAudit -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue } |
||||
|
} |
||||
|
'Get-NTFSOrphanedAudit' = @{ |
||||
|
Prepare = { |
||||
|
$context = New-Pair |
||||
|
Add-NTFSAudit -Path $context.First, $context.Second -Account $orphan -AccessRights Delete -AuditFlags Success -ErrorAction Stop |
||||
|
$context |
||||
|
} |
||||
|
Run = { param ($Context) Get-NTFSOrphanedAudit -Path $Context.First, $Context.Second -ErrorAction SilentlyContinue } |
||||
|
} |
||||
|
'Add-NTFSAudit' = @{ |
||||
|
Prepare = { New-Pair } |
||||
|
Run = { param ($Context) Add-NTFSAudit -Path $Context.First, $Context.Second -Account 'S-1-1-0' -AccessRights Delete -AuditFlags Success -PassThru -ErrorAction SilentlyContinue } |
||||
|
Untouched = { param ($Context) @(Get-NTFSAudit -Path $Context.Second -ErrorAction Stop).Count -eq 0 } |
||||
|
} |
||||
|
'Remove-NTFSAudit' = @{ |
||||
|
# The entry of the orphan goes; the one of Everyone stays, so that there is an object to write. |
||||
|
Prepare = { |
||||
|
$context = New-Pair |
||||
|
foreach ($account in $orphan, 'S-1-1-0') { |
||||
|
Add-NTFSAudit -Path $context.First, $context.Second -Account $account -AccessRights Delete -AuditFlags Success -ErrorAction Stop |
||||
|
} |
||||
|
$context |
||||
|
} |
||||
|
Run = { param ($Context) Remove-NTFSAudit -Path $Context.First, $Context.Second -Account $orphan -AccessRights Delete -AuditFlags Success -PassThru -ErrorAction SilentlyContinue } |
||||
|
Untouched = { param ($Context) @(Get-NTFSAudit -Path $Context.Second -Account $orphan -ErrorAction Stop).Count -gt 0 } |
||||
|
} |
||||
|
'Disable-NTFSAuditInheritance' = @{ |
||||
|
Prepare = { New-Pair } |
||||
|
Run = { param ($Context) Disable-NTFSAuditInheritance -Path $Context.First, $Context.Second -PassThru -ErrorAction SilentlyContinue } |
||||
|
Untouched = { param ($Context) (Get-NTFSInheritance -Path $Context.Second -ErrorAction Stop).AuditInheritanceEnabled } |
||||
|
} |
||||
|
'Enable-NTFSAuditInheritance' = @{ |
||||
|
Prepare = { |
||||
|
$context = New-Pair |
||||
|
Disable-NTFSAuditInheritance -Path $context.First, $context.Second -ErrorAction Stop |
||||
|
$context |
||||
|
} |
||||
|
Run = { param ($Context) Enable-NTFSAuditInheritance -Path $Context.First, $Context.Second -PassThru -ErrorAction SilentlyContinue } |
||||
|
Untouched = { param ($Context) -not (Get-NTFSInheritance -Path $Context.Second -ErrorAction Stop).AuditInheritanceEnabled } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
# The commands that write a verbose or a debug message inside the try of their loop, which the later command takes. |
||||
|
# The first record that reaches Select-Object ends the pipeline there. The preference of the debug stream is set by |
||||
|
# Assert-StreamStop: the Debug switch would ask before every message. The error action is named because the CI runner |
||||
|
# sets $ErrorActionPreference to Stop: a catch that reports the exception of the later command as an error of the |
||||
|
# item would then end the pipeline with it, and the test could not tell that catch from passing the exception on. |
||||
|
$streamRuns = @{ |
||||
|
'Get-FileHash2/verbose' = @{ |
||||
|
# The first path is a folder, which the cmdlet skips with a verbose message. |
||||
|
Prepare = { |
||||
|
$context = New-Pair -Directory |
||||
|
$context.File = New-TestSandboxItem -Sandbox $sandbox -Name 'Hashed' |
||||
|
$context |
||||
|
} |
||||
|
Run = { param ($Context) Get-FileHash2 -Path $Context.First, $Context.File -Verbose -ErrorAction SilentlyContinue 4>&1 } |
||||
|
} |
||||
|
'Set-NTFSSecurityDescriptor/verbose' = @{ |
||||
|
Prepare = $cases['Set-NTFSSecurityDescriptor'].Prepare |
||||
|
Run = { param ($Context) Set-NTFSSecurityDescriptor -SecurityDescriptor $Context.Descriptors -Verbose -ErrorAction SilentlyContinue 4>&1 } |
||||
|
Untouched = $cases['Set-NTFSSecurityDescriptor'].Untouched |
||||
|
} |
||||
|
'Set-NTFSOwner/debug' = @{ |
||||
|
Prepare = { New-Pair } |
||||
|
Run = { param ($Context) Set-NTFSOwner -Path $Context.First, $Context.Second -Account $currentUser -ErrorAction SilentlyContinue 5>&1 } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
# The command writes its first object, and the break or continue of the later command ends the loop around the |
||||
|
# pipeline before the next statement of the loop runs. |
||||
|
function Assert-LoopControl { |
||||
|
param ([string] $Name, [string] $Keyword) |
||||
|
|
||||
|
$case = $cases[$Name] |
||||
|
$context = & $case.Prepare |
||||
|
$emitted = 0 |
||||
|
$reachedEnd = $false |
||||
|
$Error.Clear() |
||||
|
foreach ($round in 1) { |
||||
|
& $case.Run $context | ForEach-Object -Process { |
||||
|
$emitted++ |
||||
|
if ($Keyword -eq 'break') { break } else { continue } |
||||
|
} |
||||
|
$reachedEnd = $true |
||||
|
} |
||||
|
|
||||
|
$emitted | Should -Be 1 |
||||
|
$reachedEnd | Should -BeFalse |
||||
|
$Error.Count | Should -Be 0 |
||||
|
if ($case.Untouched) { |
||||
|
(& $case.Untouched $context) | Should -BeTrue |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function Assert-PipelineStop { |
||||
|
param ([string] $Name) |
||||
|
|
||||
|
$case = $cases[$Name] |
||||
|
$context = & $case.Prepare |
||||
|
$Error.Clear() |
||||
|
|
||||
|
$result = @(& $case.Run $context | Select-Object -First 1) |
||||
|
|
||||
|
$result | Should -HaveCount 1 |
||||
|
$Error.Count | Should -Be 0 |
||||
|
if ($case.Untouched) { |
||||
|
(& $case.Untouched $context) | Should -BeTrue |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
# A later command that fails with a terminating error ends the pipeline for the commands before it. The error is the |
||||
|
# caller's: the cmdlet must neither report it as an error of an item nor go on with the next item. PowerShell wraps |
||||
|
# the exception of a throw, so the cmdlet never sees the type that was thrown. |
||||
|
function Assert-DownstreamFailure { |
||||
|
param ([string] $Name, [string] $Style) |
||||
|
|
||||
|
$case = $cases[$Name] |
||||
|
$context = & $case.Prepare |
||||
|
$emitted = 0 |
||||
|
$caught = $null |
||||
|
$Error.Clear() |
||||
|
try { |
||||
|
& $case.Run $context | ForEach-Object -Process { |
||||
|
$emitted++ |
||||
|
if ($Style -eq 'throw') { throw 'Downstream failure' } |
||||
|
Write-Error -Message 'Downstream failure' -ErrorAction Stop |
||||
|
} |
||||
|
} |
||||
|
catch { |
||||
|
$caught = $_ |
||||
|
} |
||||
|
|
||||
|
$caught.Exception.Message | Should -BeLike '*Downstream failure*' |
||||
|
$emitted | Should -Be 1 |
||||
|
@($Error | Where-Object -FilterScript { $_.Exception.Message -notlike '*Downstream failure*' }) | Should -BeNullOrEmpty |
||||
|
if ($case.Untouched) { |
||||
|
(& $case.Untouched $context) | Should -BeTrue |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
# The first verbose or debug record reaches the later command, which ends the pipeline inside the try of the loop: |
||||
|
# Select-Object raises the end of the pipeline, a throw raises an exception of its own. With the privileges enabled, |
||||
|
# the cmdlet writes a message before that, outside the try, so they stay off here. |
||||
|
function Assert-StreamStop { |
||||
|
param ([string] $Name, [string] $Stream, [string] $Style) |
||||
|
|
||||
|
$case = $streamRuns["$Name/$Stream"] |
||||
|
$recordType = if ($Stream -eq 'debug') { [System.Management.Automation.DebugRecord] } else { [System.Management.Automation.VerboseRecord] } |
||||
|
$context = & $case.Prepare |
||||
|
$saved = $privateData['EnablePrivileges'] |
||||
|
$savedDebugPreference = $DebugPreference |
||||
|
$privateData['EnablePrivileges'] = $false |
||||
|
$DebugPreference = if ($Stream -eq 'debug') { 'Continue' } else { $savedDebugPreference } |
||||
|
$emitted = 0 |
||||
|
$caught = $null |
||||
|
$result = @() |
||||
|
$Error.Clear() |
||||
|
try { |
||||
|
if ($Style -eq 'throw') { |
||||
|
try { |
||||
|
& $case.Run $context | ForEach-Object -Process { |
||||
|
$emitted++ |
||||
|
throw 'Downstream failure' |
||||
|
} |
||||
|
} |
||||
|
catch { |
||||
|
$caught = $_ |
||||
|
} |
||||
|
} |
||||
|
else { |
||||
|
$result = @(& $case.Run $context | Select-Object -First 1) |
||||
|
} |
||||
|
} |
||||
|
finally { |
||||
|
$privateData['EnablePrivileges'] = $saved |
||||
|
$DebugPreference = $savedDebugPreference |
||||
|
} |
||||
|
|
||||
|
if ($Style -eq 'throw') { |
||||
|
$caught.Exception.Message | Should -BeLike '*Downstream failure*' |
||||
|
$emitted | Should -Be 1 |
||||
|
@($Error | Where-Object -FilterScript { $_.Exception.Message -notlike '*Downstream failure*' }) | Should -BeNullOrEmpty |
||||
|
} |
||||
|
else { |
||||
|
$result | Should -HaveCount 1 |
||||
|
$result[0] | Should -BeOfType $recordType |
||||
|
$Error.Count | Should -Be 0 |
||||
|
} |
||||
|
|
||||
|
if ($case.Untouched) { |
||||
|
(& $case.Untouched $context) | Should -BeTrue |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
AfterAll { |
||||
|
Pop-Location |
||||
|
Remove-TestSandbox -Sandbox $sandbox |
||||
|
Remove-Module -Name NTFSSecurity -Force -ErrorAction SilentlyContinue |
||||
|
} |
||||
|
|
||||
|
Describe 'A later command that ends the pipeline' { |
||||
|
It '<Name> should leave the loop for <Keyword> after its first object and change nothing else' -ForEach $loopCases { |
||||
|
Assert-LoopControl -Name $Name -Keyword $Keyword |
||||
|
} |
||||
|
|
||||
|
It '<Name> should stop after the first object for Select-Object -First 1 and change nothing else' -ForEach $stopCases { |
||||
|
Assert-PipelineStop -Name $Name |
||||
|
} |
||||
|
|
||||
|
It '<Name> should leave the loop for <Keyword> after its first object and change nothing else' -Skip:(-not $canReadAudit) -ForEach $auditLoopCases { |
||||
|
Assert-LoopControl -Name $Name -Keyword $Keyword |
||||
|
} |
||||
|
|
||||
|
It '<Name> should stop after the first object for Select-Object -First 1 and change nothing else' -Skip:(-not $canReadAudit) -ForEach $auditStopCases { |
||||
|
Assert-PipelineStop -Name $Name |
||||
|
} |
||||
|
|
||||
|
It '<Name> should stop for a terminating error (<Style>) of the later command and change nothing else' -ForEach $failureCases { |
||||
|
Assert-DownstreamFailure -Name $Name -Style $Style |
||||
|
} |
||||
|
|
||||
|
It '<Name> should stop for a terminating error (<Style>) of the later command and change nothing else' -Skip:(-not $canReadAudit) -ForEach $auditFailureCases { |
||||
|
Assert-DownstreamFailure -Name $Name -Style $Style |
||||
|
} |
||||
|
|
||||
|
It '<Name> should stop at the <Stream> message for <Style> of the later command and change nothing else' -ForEach $streamCases { |
||||
|
Assert-StreamStop -Name $Name -Stream $Stream -Style $Style |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
# The errors that Get-ChildItem2 writes for a folder that it cannot read reach a later command too, for example with 2>&1. |
||||
|
# The folders that cannot be read come first in the order of the file system, so that the folder with the file is reached |
||||
|
# only if the listing goes on after the first error. |
||||
|
Describe 'A later command and the error of a folder that Get-ChildItem2 cannot read' { |
||||
|
BeforeAll { |
||||
|
$errorTree = New-TestSandboxItem -Sandbox $sandbox -Name 'ErrorTree' -Directory |
||||
|
$unreadable = foreach ($name in 'A', 'B') { |
||||
|
$folder = Join-Path -Path $errorTree -ChildPath $name |
||||
|
Assert-TestSandboxPath -Sandbox $sandbox -Path $folder |
||||
|
New-Item -ItemType Directory -Path $folder | Out-Null |
||||
|
$folder |
||||
|
} |
||||
|
$readableFile = Join-Path -Path $errorTree -ChildPath 'C\Three.txt' |
||||
|
Assert-TestSandboxPath -Sandbox $sandbox -Path $readableFile |
||||
|
New-Item -ItemType Directory -Path (Split-Path -Path $readableFile -Parent) | Out-Null |
||||
|
Set-Content -LiteralPath $readableFile -Value 'Three' |
||||
|
foreach ($folder in $unreadable) { |
||||
|
Add-TestDenyRule -Sandbox $sandbox -Path $folder -Rights @{ 'S-1-1-0' = 'ReadData' } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
# Before 5.0.0-rc7, the recursion took what the later command threw for the error of a nested folder as a failure of |
||||
|
# the folder above it, wrote a verbose message, and left the loop over the folders: the listing ended early and the |
||||
|
# caller never saw the exception. The error action is named because the CI runner sets $ErrorActionPreference to Stop, |
||||
|
# which would end the listing at the first error before the later command saw it. |
||||
|
It 'Should pass on what a later command throws when it takes the error of a nested folder' { |
||||
|
$emitted = 0 |
||||
|
$caught = $null |
||||
|
try { |
||||
|
Get-ChildItem2 -Path $errorTree -Recurse -File -ErrorAction Continue 2>&1 | ForEach-Object -Process { |
||||
|
$emitted++ |
||||
|
throw 'Downstream failure' |
||||
|
} |
||||
|
} |
||||
|
catch { |
||||
|
$caught = $_ |
||||
|
} |
||||
|
|
||||
|
$caught.Exception.Message | Should -BeLike '*Downstream failure*' |
||||
|
$emitted | Should -Be 1 |
||||
|
} |
||||
|
|
||||
|
It 'Should leave the loop for a <Keyword> of a later command that takes the error of a nested folder' -ForEach @( |
||||
|
@{ Keyword = 'break' } |
||||
|
@{ Keyword = 'continue' } |
||||
|
) { |
||||
|
$emitted = 0 |
||||
|
$reachedEnd = $false |
||||
|
foreach ($round in 1) { |
||||
|
Get-ChildItem2 -Path $errorTree -Recurse -File -ErrorAction Continue 2>&1 | ForEach-Object -Process { |
||||
|
$emitted++ |
||||
|
if ($Keyword -eq 'break') { break } else { continue } |
||||
|
} |
||||
|
$reachedEnd = $true |
||||
|
} |
||||
|
|
||||
|
$emitted | Should -Be 1 |
||||
|
$reachedEnd | Should -BeFalse |
||||
|
} |
||||
|
|
||||
|
It 'Should stop with the error of the first nested folder that it cannot read for -ErrorAction Stop' { |
||||
|
$listed = New-Object -TypeName 'System.Collections.Generic.List[object]' |
||||
|
$caught = $null |
||||
|
try { |
||||
|
Get-ChildItem2 -Path $errorTree -Recurse -File -ErrorAction Stop | ForEach-Object -Process { $listed.Add($_) } |
||||
|
} |
||||
|
catch { |
||||
|
$caught = $_ |
||||
|
} |
||||
|
|
||||
|
$caught | Should -Not -BeNullOrEmpty |
||||
|
$caught.FullyQualifiedErrorId | Should -BeLike 'DirUnauthorizedAccessError,*' |
||||
|
$caught.TargetObject | Should -Be $unreadable[0] |
||||
|
$listed | Should -BeNullOrEmpty |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
# The catches of Get-ChildItem2 for an UnauthorizedAccessException and of Remove-Item2 for an IOException don't ask where |
||||
|
# the exception comes from: they rely on PowerShell wrapping what a later command throws, so that an exception of these |
||||
|
# types never reaches them as it was thrown. A PowerShell version that hands it on as it is fails these tests. |
||||
|
Describe 'A later command that throws an exception of a type that a cmdlet handles' { |
||||
|
It 'Get-ChildItem2 should pass on a thrown UnauthorizedAccessException' { |
||||
|
$folder = New-TestSandboxItem -Sandbox $sandbox -Name 'ThrownDenied' -Directory |
||||
|
$files = 'One.txt', 'Two.txt' | ForEach-Object -Process { Join-Path -Path $folder -ChildPath $_ } |
||||
|
Assert-TestSandboxPath -Sandbox $sandbox -Path $files |
||||
|
Set-Content -LiteralPath $files -Value 'File' |
||||
|
$emitted = 0 |
||||
|
$caught = $null |
||||
|
$Error.Clear() |
||||
|
try { |
||||
|
Get-ChildItem2 -Path $folder -File -ErrorAction SilentlyContinue | ForEach-Object -Process { |
||||
|
$emitted++ |
||||
|
throw [System.UnauthorizedAccessException]::new('Downstream failure') |
||||
|
} |
||||
|
} |
||||
|
catch { |
||||
|
$caught = $_ |
||||
|
} |
||||
|
|
||||
|
$caught.Exception | Should -BeOfType [System.UnauthorizedAccessException] |
||||
|
$caught.Exception.Message | Should -BeExactly 'Downstream failure' |
||||
|
$emitted | Should -Be 1 |
||||
|
@($Error | Where-Object -FilterScript { $_.FullyQualifiedErrorId -like 'DirUnauthorizedAccessError,*' }) | Should -BeNullOrEmpty |
||||
|
} |
||||
|
|
||||
|
It 'Remove-Item2 should pass on a thrown IOException and leave the next item' { |
||||
|
$pair = New-Pair |
||||
|
$emitted = 0 |
||||
|
$caught = $null |
||||
|
$Error.Clear() |
||||
|
try { |
||||
|
Remove-Item2 -Path $pair.First, $pair.Second -PassThru -ErrorAction SilentlyContinue | ForEach-Object -Process { |
||||
|
$emitted++ |
||||
|
throw [System.IO.IOException]::new('Downstream failure') |
||||
|
} |
||||
|
} |
||||
|
catch { |
||||
|
$caught = $_ |
||||
|
} |
||||
|
|
||||
|
$caught.Exception | Should -BeOfType [System.IO.IOException] |
||||
|
$caught.Exception.Message | Should -BeExactly 'Downstream failure' |
||||
|
$emitted | Should -Be 1 |
||||
|
$pair.Second | Should -Exist |
||||
|
@($Error | Where-Object -FilterScript { $_.FullyQualifiedErrorId -like 'DeleteError,*' }) | Should -BeNullOrEmpty |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
# A cmdlet also meets the end of the pipeline where it did not write: another call of PowerShell can raise it too. The |
||||
|
# check that every catch-all makes recognizes the exceptions by their types. PowerShell keeps the exceptions of break and |
||||
|
# continue internal, so they are recognized by the name of their base type, which a stand-in with that name shows. |
||||
|
Describe 'Recognizing the end of a pipeline by the type of the exception' { |
||||
|
BeforeAll { |
||||
|
if (-not ('NtfsSecurityTests.StandInForBreak' -as [type])) { |
||||
|
# The compiler warns that the stand-in has the name of an imported type, and Add-Type treats a warning as an error. |
||||
|
Add-Type -IgnoreWarnings -TypeDefinition @' |
||||
|
namespace System.Management.Automation { public class FlowControlException : System.Exception { } } |
||||
|
namespace NtfsSecurityTests { public class StandInForBreak : System.Management.Automation.FlowControlException { } } |
||||
|
'@ |
||||
|
} |
||||
|
|
||||
|
$isEnd = [NTFSSecurity.BaseCmdlet].Assembly.GetType('NTFSSecurity.PipelineControl').GetMethod( |
||||
|
'IsEnd', [System.Reflection.BindingFlags] 'NonPublic, Static') |
||||
|
} |
||||
|
|
||||
|
It 'Should recognize a PipelineStoppedException' { |
||||
|
$isEnd.Invoke($null, @([System.Management.Automation.PipelineStoppedException]::new())) | Should -BeTrue |
||||
|
} |
||||
|
|
||||
|
It 'Should recognize an exception whose base type is the flow control exception of PowerShell' { |
||||
|
$isEnd.Invoke($null, @([NtfsSecurityTests.StandInForBreak]::new())) | Should -BeTrue |
||||
|
} |
||||
|
|
||||
|
It 'Should not recognize <Description>' -ForEach @( |
||||
|
@{ Description = 'a failure of an item'; Exception = [System.InvalidOperationException]::new('Failure') } |
||||
|
@{ Description = 'an access denial'; Exception = [System.UnauthorizedAccessException]::new('Denied') } |
||||
|
@{ Description = 'a failure with an inner exception that ends the pipeline'; Exception = [System.InvalidOperationException]::new('Failure', [System.Management.Automation.PipelineStoppedException]::new()) } |
||||
|
) { |
||||
|
$isEnd.Invoke($null, @($Exception)) | Should -BeFalse |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue