From 708afdf070fe6e7d5f0ef665f8f20a1b783b480f Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Fri, 9 Oct 2026 22:27:19 +0000 Subject: [PATCH] docs: correct the paths acceptance record after its review The independent review of the acceptance record found no Blocker or Major issue. This commit corrects what it found: the commit that fixed the break row, what the State test shows, the baseline failures that carry no message, the count of results, the wording about the folders before the first run, the truncated messages in the results file, the README row of case 10, and the review section of the record. The 42 messageless baseline failures are now explained by a diagnostic that runs the bodies of those tests in a TEMP sandbox: on the base, the second item is removed, copied, moved, re-owned, or rewritten after Select-Object -First 1 or a throw; on the candidate it stays. The diagnostic, the check of the result files, and a read-only check of a published version are in Tests/Lab/Acceptance. Decision 22 no longer says that Copy-Item2 is like Copy-Item for a folder: the built-in Copy-Item creates the missing parent folders of a folder copy, Copy-Item2 of rc6 and of the candidate doesn't. The choice stays, the reference point is corrected, and the question is left to the maintainer. The migration hint of item 8 is stated as it is. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- .../0022-phase-2-behavior-changes.md | 42 ++-- ...-2026-10-09-quality-gate-paths-Results.csv | 14 +- ...cceptance-2026-10-09-quality-gate-paths.md | 123 ++++++++-- Tests/Lab/Acceptance/Probe-LaterCommand.ps1 | 224 ++++++++++++++++++ .../Lab/Acceptance/Test-PublishedRelease.ps1 | 111 +++++++++ Tests/Lab/Acceptance/Validate-LabResults.ps1 | 68 ++++++ Tests/Lab/README.md | 2 +- 7 files changed, 538 insertions(+), 46 deletions(-) create mode 100644 Tests/Lab/Acceptance/Probe-LaterCommand.ps1 create mode 100644 Tests/Lab/Acceptance/Test-PublishedRelease.ps1 create mode 100644 Tests/Lab/Acceptance/Validate-LabResults.ps1 diff --git a/.memory-bank/decisions/0022-phase-2-behavior-changes.md b/.memory-bank/decisions/0022-phase-2-behavior-changes.md index f7d528c..d66e59f 100644 --- a/.memory-bank/decisions/0022-phase-2-behavior-changes.md +++ b/.memory-bank/decisions/0022-phase-2-behavior-changes.md @@ -30,7 +30,7 @@ source: agent choices in autopilot on 2026-10-08, for the maintainer's review; c | 7 | The link cmdlets stopped with terminating errors | **Breaking:** a non-terminating error per link, and the next link | | 8 | `-Path` and `-Target` of the link cmdlets were optional | **Breaking:** required. An omitted `-Path` failed with an index error, an omitted `-Target` meant the current location | | 9 | Entries and descriptors are equal only as the same .NET object | Kept the equality of .NET; the FAQ shows `Compare-Object -Property` | -| 10 | `Copy-Item2` doesn't create the missing destination folders (rc6) | Kept, like `Copy-Item` and `Move-Item2` | +| 10 | `Copy-Item2` doesn't create the missing destination folders (rc6) | Kept, like `Move-Item2`, and for a file like `Copy-Item`. Corrected on 2026-10-09: for a folder, `Copy-Item` creates the missing parent folders and `Copy-Item2` doesn't | - Found on the way and fixed: every object piped to the link cmdlets failed with `GetDefaultValueFailed` (item 8). Item 6 is not the cause of @@ -48,7 +48,9 @@ source: agent choices in autopilot on 2026-10-08, for the maintainer's review; c - Rationale: an error instead of a result that looks valid (1, 2, 6); per-item errors, as in the other cmdlets (7); no silent default for a path that creates something (8); no new features before the archive - (3); the conventions of .NET and PowerShell (4, 9, 10). + (3); the conventions of .NET and PowerShell (4, 9); no implicit creation + of folders (10; `Copy-Item` creates them for a folder, see the correction + below). - Open: the maintainer accepts or reverts each choice; then this record becomes `accepted`. @@ -58,11 +60,16 @@ source: agent choices in autopilot on 2026-10-08, for the maintainer's review; c the agent to continue with the next work and, for any decision that comes up, to "do it and report about it later". The handoff for this record asks for one question per item, which nobody could answer overnight. The agent - checked each choice against the source, the tests, the cmdlet pages, and - the changelog, and confirmed all ten. This is the agent's decision under - that delegation, not the maintainer's own, so the status stays `proposed` - until he confirms it or reverts an item. Nothing in the code, the tests, - or the help changed. + compared each choice with the changelog and the cmdlet pages, and checked + item 10 against the source and against the built-in `Copy-Item`; it did + not run the tests of the other items again for this record (they ran with + the suite of rc7 and of the later branches). An independent read-only + review checked the statements of the table below against the same pages + and found them accurate except two, which are corrected here (item 10, and + the migration hint of item 8). The agent confirmed all ten choices. This is + the agent's decision under that delegation, not the maintainer's own, so + the status stays `proposed` until he confirms it or reverts an item. + Nothing in the code, the tests, or the help changed. - Impact for a caller, and where the choice is documented (the changelog under [Unreleased], and the page of each cmdlet in `Docs\Cmdlets`): @@ -77,15 +84,22 @@ source: agent choices in autopilot on 2026-10-08, for the maintainer's review; c | 7 | **Breaking:** the link cmdlets write a non-terminating error per link and go on; a script that relies on the stop needs `-ErrorAction Stop` | both link pages, notes; the changelog, **Breaking** | | 8 | **Breaking:** `-Path` and `-Target` are required; a script that omitted one must pass it | both link pages, notes; the changelog, **Breaking** | | 9 | None: entries and descriptors are equal only as the same .NET object, as in .NET; `Compare-Object -Property` compares values | `Docs\FAQ.md` | -| 10 | Only against the earlier 5.0.0 prereleases: `Copy-Item2` no longer creates the missing folders of the destination of a folder copy, like `Copy-Item` and `Move-Item2` | `Copy-Item2` page; the changelog | +| 10 | Only against the earlier 5.0.0 prereleases, which created the missing parent folders of a folder copy: `Copy-Item2` writes an error that names the missing folder, as `Move-Item2` does. It differs from `Copy-Item`, which creates the missing parents of a folder copy (checked in both editions on 2026-10-09; for a file, `Copy-Item` writes an error as well). A script that relied on the prerelease behavior creates the folder first. Published rc6 and the candidate both write a `CopyError` and create nothing (checked in both editions on 2026-10-09) | `Copy-Item2` page; the changelog | - Why all ten stand: 5.0.0 is a major version, so documented breaking - changes are allowed (7 and 8 have a **Breaking:** entry and a migration - hint); 1, 2, and 6 replace a result that looked valid with an error or a - complete result; 3, 4, 9, and 10 follow the conventions of .NET and - PowerShell and add no feature before the archive; 5 is a clearer message. - Reverting item 8 would bring back the failure for every object piped to - the link cmdlets (found on the way, above). + changes are allowed (7 has a **Breaking:** entry with a migration hint, + `-ErrorAction Stop`; the **Breaking:** entry of 8 names the old behavior, + and the migration is to pass both parameters); 1, 2, and 6 replace a + result that looked valid with an error or a complete result; 3, 4, and 9 + follow the conventions of .NET and PowerShell and add no feature before + the archive; 5 is a clearer message. Item 10 adds no feature either, but + its reference point was wrong: it isn't like `Copy-Item` for a folder. + The stricter behavior is the safer default and matches `Move-Item2`, and + creating missing parents would flip the behavior of rc6 and rc7 again, so + the agent keeps it and leaves the question, whether `Copy-Item2` should + create the missing parents of a folder copy like `Copy-Item`, to the + maintainer. Reverting item 8 would bring back the failure for every object + piped to the link cmdlets (found on the way, above). - To revert an item: revert its commit (the range in Context), regenerate the help from `Docs`, adjust the changelog and the cmdlet page, and run the four test configurations again; a later commit on the same page or diff --git a/Tests/Lab/Acceptance-2026-10-09-quality-gate-paths-Results.csv b/Tests/Lab/Acceptance-2026-10-09-quality-gate-paths-Results.csv index d95cd71..2881427 100644 --- a/Tests/Lab/Acceptance-2026-10-09-quality-gate-paths-Results.csv +++ b/Tests/Lab/Acceptance-2026-10-09-quality-gate-paths-Results.csv @@ -16,7 +16,7 @@ "Core","Admin","A later command that ends the pipeline or throws, for the item cmdlets on a share.With the messages of the verbose and debug streams.Set-NTFSSecurityDescriptor should stop at the verbose message for Select-Object -First 1 and change nothing else","Failed","Passed", "Core","Admin","A later command that ends the pipeline or throws, for the item cmdlets on a share.With the messages of the verbose and debug streams.Set-NTFSSecurityDescriptor should stop at the verbose message for throw and change nothing else","Failed","Passed","Expected like wildcard '*Downstream failure*' to match $null, but it did not match." "Core","Admin","Get-ChildItem2 -Filter on a share folder.Should find a file whose name contains brackets by that name with -Filter","Failed","Passed","Expected a collection with size 1, but got an empty collection." -"Core","Admin","Get-ChildItem2 -Filter on a share folder.Should reject a null -Filter","Failed","Passed","Expected an exception with message like '*'Filter'*' and FullyQualifiedErrorId 'ParameterArgumentValidationError,NTFSSecurity.GetChildItem2' to be thrown, but the message was 'Cannot process argument because the value of" +"Core","Admin","Get-ChildItem2 -Filter on a share folder.Should reject a null -Filter","Failed","Passed","Expected an exception with message like '*'Filter'*' and FullyQualifiedErrorId 'ParameterArgumentValidationError,NTFSSecurity.GetChildItem2' to be thrown, but the message was 'Cannot process argument because the value of argument ""pattern"" is null. Change the value of argument ""pattern"" to a non-null value.' and the FullyQualifiedErrorId was 'ArgumentNull,NTFSSecurity.GetChildItem2'. from C:\NTFSSecurityLab\Tests\NTFSSecurity.Live.Tests.ps1:1333 char:11" "Core","Admin","Get-ChildItem2 -Filter on a share folder.Should return every item for -Filter *.*, also the ones without a dot in their names","Failed","Passed","Expected strings to be the same, but they were different." "Core","Admin","InheritedFrom of access entries that Windows cannot resolve on a share.Should name an unknown parent for an inherited entry and no source for an explicit entry when the file is gone","Failed","Passed","Expected strings to be the same, but they were different." "Core","Admin","InheritedFrom of audit entries that Windows cannot resolve on a share.Should name an unknown parent for an inherited audit entry and no source for an explicit entry when the file is gone","Failed","Passed","Expected strings to be the same, but they were different." @@ -45,7 +45,7 @@ "Core","Delegate","An item that the account owns and whose owner may not change its permissions on a share.Clear-NTFSAccess -DisableInheritance should take ownership, clear and protect the DACL, and report no RestoreOwnerError","Failed","Passed","Expected $null or empty, but got 'RestoreOwnerError,NTFSSecurity.ClearAccess: (5) Access is denied: [\\F1AFile2.a.forest1.net\NTFSSecurityLive\local-Core-20261009213136\Case10\Delegate\Owner\Clear.txt]'." "Core","Delegate","An item that the account owns and whose owner may not change its permissions on a share.Set-NTFSSecurityDescriptor should write the cleared DACL and report no RestoreOwnerError","Failed","Passed","Expected $null or empty, but got 'RestoreOwnerError,NTFSSecurity.SetSecurityDescriptor: (5) Access is denied: [\\F1AFile2.a.forest1.net\NTFSSecurityLive\local-Core-20261009213136\Case10\Delegate\Owner\Descriptor.txt]'." "Core","Delegate","Get-ChildItem2 -Filter on a share folder.Should find a file whose name contains brackets by that name with -Filter","Failed","Passed","Expected a collection with size 1, but got an empty collection." -"Core","Delegate","Get-ChildItem2 -Filter on a share folder.Should reject a null -Filter","Failed","Passed","Expected an exception with message like '*'Filter'*' and FullyQualifiedErrorId 'ParameterArgumentValidationError,NTFSSecurity.GetChildItem2' to be thrown, but the message was 'Cannot process argument because the value of" +"Core","Delegate","Get-ChildItem2 -Filter on a share folder.Should reject a null -Filter","Failed","Passed","Expected an exception with message like '*'Filter'*' and FullyQualifiedErrorId 'ParameterArgumentValidationError,NTFSSecurity.GetChildItem2' to be thrown, but the message was 'Cannot process argument because the value of argument ""pattern"" is null. Change the value of argument ""pattern"" to a non-null value.' and the FullyQualifiedErrorId was 'ArgumentNull,NTFSSecurity.GetChildItem2'. from C:\NTFSSecurityLab\Tests\NTFSSecurity.Live.Tests.ps1:1333 char:11" "Core","Delegate","Get-ChildItem2 -Filter on a share folder.Should return every item for -Filter *.*, also the ones without a dot in their names","Failed","Passed","Expected strings to be the same, but they were different." "Core","Delegate","InheritedFrom of access entries that Windows cannot resolve on a share.Should name an unknown parent for an inherited entry and no source for an explicit entry when the file is gone","Failed","Passed","Expected strings to be the same, but they were different." "Core","Delegate","InheritedFrom of an item below a folder on a share whose permissions the account cannot read.Get-NTFSAccess should name an unknown parent for an inherited entry and no source for an explicit entry","Failed","Passed","Expected strings to be the same, but they were different." @@ -73,7 +73,7 @@ "Core","ServerAdmin","A later command that ends the pipeline or throws, for the item cmdlets on a share.With the messages of the verbose and debug streams.Set-NTFSSecurityDescriptor should stop at the verbose message for Select-Object -First 1 and change nothing else","Failed","Passed", "Core","ServerAdmin","A later command that ends the pipeline or throws, for the item cmdlets on a share.With the messages of the verbose and debug streams.Set-NTFSSecurityDescriptor should stop at the verbose message for throw and change nothing else","Failed","Passed","Expected like wildcard '*Downstream failure*' to match $null, but it did not match." "Core","ServerAdmin","Get-ChildItem2 -Filter on a share folder.Should find a file whose name contains brackets by that name with -Filter","Failed","Passed","Expected a collection with size 1, but got an empty collection." -"Core","ServerAdmin","Get-ChildItem2 -Filter on a share folder.Should reject a null -Filter","Failed","Passed","Expected an exception with message like '*'Filter'*' and FullyQualifiedErrorId 'ParameterArgumentValidationError,NTFSSecurity.GetChildItem2' to be thrown, but the message was 'Cannot process argument because the value of" +"Core","ServerAdmin","Get-ChildItem2 -Filter on a share folder.Should reject a null -Filter","Failed","Passed","Expected an exception with message like '*'Filter'*' and FullyQualifiedErrorId 'ParameterArgumentValidationError,NTFSSecurity.GetChildItem2' to be thrown, but the message was 'Cannot process argument because the value of argument ""pattern"" is null. Change the value of argument ""pattern"" to a non-null value.' and the FullyQualifiedErrorId was 'ArgumentNull,NTFSSecurity.GetChildItem2'. from C:\NTFSSecurityLab\Tests\NTFSSecurity.Live.Tests.ps1:1333 char:11" "Core","ServerAdmin","Get-ChildItem2 -Filter on a share folder.Should return every item for -Filter *.*, also the ones without a dot in their names","Failed","Passed","Expected strings to be the same, but they were different." "Core","ServerAdmin","InheritedFrom of access entries that Windows cannot resolve on a share.Should name an unknown parent for an inherited entry and no source for an explicit entry when the file is gone","Failed","Passed","Expected strings to be the same, but they were different." "Core","ServerAdmin","InheritedFrom of audit entries that Windows cannot resolve on a share.Should name an unknown parent for an inherited audit entry and no source for an explicit entry when the file is gone","Failed","Passed","Expected strings to be the same, but they were different." @@ -94,7 +94,7 @@ "Desktop","Admin","A later command that ends the pipeline or throws, for the item cmdlets on a share.With the messages of the verbose and debug streams.Set-NTFSSecurityDescriptor should stop at the verbose message for Select-Object -First 1 and change nothing else","Failed","Passed", "Desktop","Admin","A later command that ends the pipeline or throws, for the item cmdlets on a share.With the messages of the verbose and debug streams.Set-NTFSSecurityDescriptor should stop at the verbose message for throw and change nothing else","Failed","Passed","Expected like wildcard '*Downstream failure*' to match $null, but it did not match." "Desktop","Admin","Get-ChildItem2 -Filter on a share folder.Should find a file whose name contains brackets by that name with -Filter","Failed","Passed","Expected a collection with size 1, but got an empty collection." -"Desktop","Admin","Get-ChildItem2 -Filter on a share folder.Should reject a null -Filter","Failed","Passed","Expected an exception with message like '*'Filter'*' and FullyQualifiedErrorId 'ParameterArgumentValidationError,NTFSSecurity.GetChildItem2' to be thrown, but the message was 'Cannot process argument because the value of" +"Desktop","Admin","Get-ChildItem2 -Filter on a share folder.Should reject a null -Filter","Failed","Passed","Expected an exception with message like '*'Filter'*' and FullyQualifiedErrorId 'ParameterArgumentValidationError,NTFSSecurity.GetChildItem2' to be thrown, but the message was 'Cannot process argument because the value of argument ""pattern"" is null. Change the value of argument ""pattern"" to a non-null value.' and the FullyQualifiedErrorId was 'ArgumentNull,NTFSSecurity.GetChildItem2'. from C:\NTFSSecurityLab\Tests\NTFSSecurity.Live.Tests.ps1:1333 char:11" "Desktop","Admin","Get-ChildItem2 -Filter on a share folder.Should return every item for -Filter *.*, also the ones without a dot in their names","Failed","Passed","Expected strings to be the same, but they were different." "Desktop","Admin","InheritedFrom of access entries that Windows cannot resolve on a share.Should name an unknown parent for an inherited entry and no source for an explicit entry when the file is gone","Failed","Passed","Expected strings to be the same, but they were different." "Desktop","Admin","InheritedFrom of audit entries that Windows cannot resolve on a share.Should name an unknown parent for an inherited audit entry and no source for an explicit entry when the file is gone","Failed","Passed","Expected strings to be the same, but they were different." @@ -121,9 +121,9 @@ "Desktop","Delegate","A later command that ends the pipeline or throws, for the item cmdlets on a share.With the messages of the verbose and debug streams.Set-NTFSSecurityDescriptor should stop at the verbose message for Select-Object -First 1 and change nothing else","Failed","Passed", "Desktop","Delegate","A later command that ends the pipeline or throws, for the item cmdlets on a share.With the messages of the verbose and debug streams.Set-NTFSSecurityDescriptor should stop at the verbose message for throw and change nothing else","Failed","Passed","Expected like wildcard '*Downstream failure*' to match $null, but it did not match." "Desktop","Delegate","An item that the account owns and whose owner may not change its permissions on a share.Clear-NTFSAccess -DisableInheritance should take ownership, clear and protect the DACL, and report no RestoreOwnerError","Failed","Passed","Expected $null or empty, but got 'RestoreOwnerError,NTFSSecurity.ClearAccess: (5) Access is denied: [\\F1AFile2.a.forest1.net\NTFSSecurityLive\local-Desktop-20261009212312\Case10\Delegate\Owner\Clear.txt]'." -"Desktop","Delegate","An item that the account owns and whose owner may not change its permissions on a share.Set-NTFSSecurityDescriptor should write the cleared DACL and report no RestoreOwnerError","Failed","Passed","Expected $null or empty, but got 'RestoreOwnerError,NTFSSecurity.SetSecurityDescriptor: (5) Access is denied: [\\F1AFile2.a.forest1.net\NTFSSecurityLive\local-Desktop-20261009212312\Case10\Delegate\Owner\Descriptor.txt]'" +"Desktop","Delegate","An item that the account owns and whose owner may not change its permissions on a share.Set-NTFSSecurityDescriptor should write the cleared DACL and report no RestoreOwnerError","Failed","Passed","Expected $null or empty, but got 'RestoreOwnerError,NTFSSecurity.SetSecurityDescriptor: (5) Access is denied: [\\F1AFile2.a.forest1.net\NTFSSecurityLive\local-Desktop-20261009212312\Case10\Delegate\Owner\Descriptor.txt]'." "Desktop","Delegate","Get-ChildItem2 -Filter on a share folder.Should find a file whose name contains brackets by that name with -Filter","Failed","Passed","Expected a collection with size 1, but got an empty collection." -"Desktop","Delegate","Get-ChildItem2 -Filter on a share folder.Should reject a null -Filter","Failed","Passed","Expected an exception with message like '*'Filter'*' and FullyQualifiedErrorId 'ParameterArgumentValidationError,NTFSSecurity.GetChildItem2' to be thrown, but the message was 'Cannot process argument because the value of" +"Desktop","Delegate","Get-ChildItem2 -Filter on a share folder.Should reject a null -Filter","Failed","Passed","Expected an exception with message like '*'Filter'*' and FullyQualifiedErrorId 'ParameterArgumentValidationError,NTFSSecurity.GetChildItem2' to be thrown, but the message was 'Cannot process argument because the value of argument ""pattern"" is null. Change the value of argument ""pattern"" to a non-null value.' and the FullyQualifiedErrorId was 'ArgumentNull,NTFSSecurity.GetChildItem2'. from C:\NTFSSecurityLab\Tests\NTFSSecurity.Live.Tests.ps1:1333 char:11" "Desktop","Delegate","Get-ChildItem2 -Filter on a share folder.Should return every item for -Filter *.*, also the ones without a dot in their names","Failed","Passed","Expected strings to be the same, but they were different." "Desktop","Delegate","InheritedFrom of access entries that Windows cannot resolve on a share.Should name an unknown parent for an inherited entry and no source for an explicit entry when the file is gone","Failed","Passed","Expected strings to be the same, but they were different." "Desktop","Delegate","InheritedFrom of an item below a folder on a share whose permissions the account cannot read.Get-NTFSAccess should name an unknown parent for an inherited entry and no source for an explicit entry","Failed","Passed","Expected strings to be the same, but they were different." @@ -151,7 +151,7 @@ "Desktop","ServerAdmin","A later command that ends the pipeline or throws, for the item cmdlets on a share.With the messages of the verbose and debug streams.Set-NTFSSecurityDescriptor should stop at the verbose message for Select-Object -First 1 and change nothing else","Failed","Passed", "Desktop","ServerAdmin","A later command that ends the pipeline or throws, for the item cmdlets on a share.With the messages of the verbose and debug streams.Set-NTFSSecurityDescriptor should stop at the verbose message for throw and change nothing else","Failed","Passed","Expected like wildcard '*Downstream failure*' to match $null, but it did not match." "Desktop","ServerAdmin","Get-ChildItem2 -Filter on a share folder.Should find a file whose name contains brackets by that name with -Filter","Failed","Passed","Expected a collection with size 1, but got an empty collection." -"Desktop","ServerAdmin","Get-ChildItem2 -Filter on a share folder.Should reject a null -Filter","Failed","Passed","Expected an exception with message like '*'Filter'*' and FullyQualifiedErrorId 'ParameterArgumentValidationError,NTFSSecurity.GetChildItem2' to be thrown, but the message was 'Cannot process argument because the value of" +"Desktop","ServerAdmin","Get-ChildItem2 -Filter on a share folder.Should reject a null -Filter","Failed","Passed","Expected an exception with message like '*'Filter'*' and FullyQualifiedErrorId 'ParameterArgumentValidationError,NTFSSecurity.GetChildItem2' to be thrown, but the message was 'Cannot process argument because the value of argument ""pattern"" is null. Change the value of argument ""pattern"" to a non-null value.' and the FullyQualifiedErrorId was 'ArgumentNull,NTFSSecurity.GetChildItem2'. from C:\NTFSSecurityLab\Tests\NTFSSecurity.Live.Tests.ps1:1333 char:11" "Desktop","ServerAdmin","Get-ChildItem2 -Filter on a share folder.Should return every item for -Filter *.*, also the ones without a dot in their names","Failed","Passed","Expected strings to be the same, but they were different." "Desktop","ServerAdmin","InheritedFrom of access entries that Windows cannot resolve on a share.Should name an unknown parent for an inherited entry and no source for an explicit entry when the file is gone","Failed","Passed","Expected strings to be the same, but they were different." "Desktop","ServerAdmin","InheritedFrom of audit entries that Windows cannot resolve on a share.Should name an unknown parent for an inherited audit entry and no source for an explicit entry when the file is gone","Failed","Passed","Expected strings to be the same, but they were different." diff --git a/Tests/Lab/Acceptance-2026-10-09-quality-gate-paths.md b/Tests/Lab/Acceptance-2026-10-09-quality-gate-paths.md index aa15f61..67a225e 100644 --- a/Tests/Lab/Acceptance-2026-10-09-quality-gate-paths.md +++ b/Tests/Lab/Acceptance-2026-10-09-quality-gate-paths.md @@ -31,10 +31,16 @@ is a control. of its commit, packaged by `.github/scripts/New-ModulePackage.ps1`. Both carry the label `5.0.0-rc7` and one assembly version, so every run used a new process. All 11 files of each tested module folder equal the extracted - `NTFSSecurity.zip` byte for byte (SHA-256). + `NTFSSecurity.zip` byte for byte (SHA-256). The first packaging attempt, at + 20:41 UTC, stopped in both builds at the check of the build script that + compares the package folder with the extracted zip ("The extracted ZIP + differs from the module folder"); the logs of that attempt are kept. I + changed the script (20:43) and built both again; the files that the lab + tested are those of the second attempt, and the cause of the first + mismatch wasn't recorded. - Test source, identical in both runs (last written 20:56 and 20:54 UTC, - before the first run started): `NTFSSecurity.Live.Tests.ps1` (Git blob - `67b85efeb45af67070538f241c203c4afa38b6f4`) and + before the candidate run started at 21:02): `NTFSSecurity.Live.Tests.ps1` + (Git blob `67b85efeb45af67070538f241c203c4afa38b6f4`) and `Invoke-NTFSSecurityLabTest.ps1` (blob `0b46427bc32b0b15449e283a2a6cf67879937541`). Both are in the commit that adds this record. @@ -54,7 +60,7 @@ Administrators own for the cases of `Set-NTFSOwner`. | InheritedFrom of audit entries that Windows cannot resolve (ServerAdmin, Admin) | 2 | 2 | 0 | `2909a1c` | | InheritedFrom of an item below a folder whose permissions the account cannot read (Delegate) | 2 | 1 | 0 | `2909a1c` | | A later command that ends the pipeline or throws, for the item cmdlets (3 roles; 16 each) | 48 | 48 | 0 | `c77ecbf`, `40bf6a8` | -| A later command and the error of a folder that Get-ChildItem2 cannot read (Delegate) | 3 | 2 | 0 | `d44a200` | +| A later command and the error of a folder that Get-ChildItem2 cannot read (Delegate) | 3 | 2 | 0 | `c77ecbf` (break), `d44a200` (throw) | | Get-ChildItem2 -Filter (3 roles; brackets, `*.*`, null) | 9 | 9 | 0 | `ee7c105`, `40bf6a8`, `ae3078f` | | Privileges when a later command takes the debug messages (Delegate, Admin) | 6 | 4 | 0 | `d44a200` | | State of the file server: only the first item changed (Server; one per role) | 3 | 3 | 0 | `c77ecbf`, `40bf6a8` | @@ -85,15 +91,18 @@ check covers. `WindowsAccessControlLab`: F1ADC1, F1BDC1, F2DC1, F3DC1, F1AFile1 (client), and F1AFile2 (file server), all Windows Server 2025 (10.0.26100). At 20:41 UTC, authenticated WinRM, LDAP RootDSE, Kerberos tickets, member secure -channels, and clocks (skew at most 7 s) passed on all six machines. No -`NTFSSecurityLive` OU or `NtfsLive*` account existed before the run. No VM, -operating system, or network changed, and no other session or controller -process used the lab. +channels, and clocks (skew at most 7 s) passed on all six machines. At 20:43 +UTC, before the first test run, no `NTFSSecurityLive` OU or `NtfsLive*` +account existed. The runs changed no VM, operating system, or network +setting. A process listing at the start showed no other controller of these +tests on the host; it wasn't kept as a log. Six checkpoints named `ntfs-qg-paths-83149ee-before-acceptance` were taken -at 20:45 to 20:46 UTC, one per machine. The policy of each machine is -Production, but Hyper-V reports the type Standard. As before, Production -classification is unverified, and no checkpoint was restored. +at 20:45 to 20:46 UTC, one per machine; the Hyper-V listing that shows the +names is kept with the evidence. The policy of each machine is Production, +but Hyper-V reports the type Standard. As before, Production classification +is unverified, and no checkpoint was restored or deleted. Every machine now +carries seven checkpoints of the acceptances since 2026-10-08, F1AFile1 eight. ## Live results @@ -117,27 +126,61 @@ the test that needs the module in the Server role, which doesn't import it. Baseline, both editions: 338 passed, 148 failed, two skipped. Every role exited 0 on the candidate. A joined verification of the result files (not of the counts) found the same 488 tests in both builds, no duplicate, and every -one of the 148 baseline failures passed on the candidate. All 148 failures are -among the 156 tests of case 10 and the state test, which -[the results file](Acceptance-2026-10-09-quality-gate-paths-Results.csv) -lists with both results and the first line of the baseline message. +one of the 148 baseline failures passed on the candidate. The 148 failures +are 74 tests in each edition, all among the 78 new tests of each edition +(case 10 and the state test); the four that pass on both builds are +preconditions. [The results file](Acceptance-2026-10-09-quality-gate-paths-Results.csv) +lists the 156 results (78 tests in two editions) with both outcomes and the +first line of the baseline message. What the baseline shows, from its messages: - Owner: `RestoreOwnerError ... (5) Access is denied` for the unchanged owner. - `InheritedFrom`: a text of 13 characters instead of the 14 of `unknown parent`. -- Later command: the second item changed after `Select-Object -First 1`; the - `Downstream failure` of a `throw` never reached the caller; and a `break` - of a later command didn't leave the caller's loop. +- Later command: the `Downstream failure` of a `throw` never reached the + caller (the messages read `Expected like wildcard '*Downstream failure*' to + match $null`), and a `break` of a later command didn't leave the caller's + loop. For `Select-Object -First 1`, see the next section. - `-Filter`: no result for a name with brackets; `*.*` returned only the three names with a dot and dropped `NoExtension` and `NoExtensionFolder`; `$null` gave `ArgumentNull` instead of the parameter validation error. - Privileges: `TakeOwnership` still enabled after the pipeline stopped. -The 21 `Select-Object -First 1` tests per edition carry no message on the -baseline and no line in the Pester log. The State test shows independently -that the baseline changed the second item for each role. +### Baseline failures without a message + +Seven tests of each role, 21 per edition and 42 in all, fail on the baseline +with an empty message, and Pester prints no line for them: `Select-Object +-First 1` for the five item cmdlets, the verbose stop of +`Set-NTFSSecurityDescriptor`, and the debug stop of `Set-NTFSOwner`. This lab +run doesn't show what the baseline did in them. The State test of the Server +role shows it only for `Remove-Item2`: in each role, the second item was +removed after `Select-Object -First 1`. That test stops at its first failed +assertion, so it says nothing about the other cmdlets, and its assertions for +the debug and verbose stops check only that the second item is as it was, +which is also true when the client test never ran. + +To close the gap, the bodies of these tests ran afterwards on this host, in a +sandbox below TEMP, with the settings of the runner (Pester 5.7.1, +`ErrorActionPreference` Stop), one build in one edition per process +(`Acceptance\Probe-LaterCommand.ps1`; it isn't part of the acceptance, and +it didn't run on a share). The result is the same in Windows PowerShell 5.1 +and PowerShell 7: + +| Cmdlet | Baseline `f11ff41`, after `Select-Object -First 1` and after `throw` | Candidate `83149ee` | +| --- | --- | --- | +| `Remove-Item2` | both items removed | the second item stays | +| `Copy-Item2` | both items copied | only the first is copied | +| `Move-Item2` | both items moved | the second item stays | +| `Set-NTFSOwner` | both owners changed, also at the debug stop | the second owner stays Administrators | +| `Set-NTFSSecurityDescriptor` | both descriptors written | only the first is written | + +All 12 tests of the probe (seven stop rows, five `throw` rows) fail on the +baseline, the seven stop rows with no error record, as in the lab, and the +`throw` rows with the message of the lab; all 12 pass on the candidate. At the +verbose stop of `Set-NTFSSecurityDescriptor`, neither build writes a +descriptor, because the stop comes before the first write, so that failure on +the baseline isn't a change of state. ## Cleanup and review @@ -152,6 +195,26 @@ F1AFile2 no share, no `C:\NTFSSecurityLive` or `C:\NTFSSecurityLab`, no Assistance Operators, or Remote Management Users, and no profile of the ten SIDs. No checkpoint was restored. +One independent, read-only, static review of the finished change (tests, +fixture, README, this record and its results file, and Decision 22) ran +before the first commit. The custom `security-reviewer` can't start because +its configured model is unavailable, so the built-in code-review agent did +it. Verdict: approve with Minor; no Blocker and no Major. It confirmed that +the new tests can't pass vacuously (every precondition is asserted, the data +rows are not empty, nothing is shared between rows), that the fixture stays +below the guarded folders and throws when Administrators don't own the +files, and that the counts, the hashes, the 156 results, and the cleanup +facts of this record match the evidence. Its findings, all corrected in the +commit that follows the first: the fix that this record credited for the +`break` row, the claims about the State test and the 42 messageless +failures (now the section above, with the diagnostic), this heading, the +count of results, the wording about the folders before the run, the +truncated messages in the results file, the README row of case 10, and the +migration hint of item 8 and the comparison with `Copy-Item` in Decision 22. +It could not run anything, so the run state and the lab-wide claims rest on +the logs; the diagnostic above and the checkpoint listing close two of its +open points. + ## Limits - `Get-NTFSAudit` below an unreadable parent folder can't be built here: an @@ -166,11 +229,23 @@ SIDs. No checkpoint was restored. other gates. The stable version remains 4.2.6. - The candidate and the baseline differ only by the 28 commits; the test and controller files are the same. +- What the baseline did in the 42 failures without a message is shown by a + local diagnostic, not by this lab run. A State test split per cmdlet and + stop style would show it on the share too, and would need both lab runs + again. ## Evidence The result files, logs, hashes, readiness, checkpoint, snapshot, and cleanup logs of both runs are in the session artifact -`4b12e2f4-d4c7-4a5d-883a-ddb7421c4848\files\lab-qg-paths` (local, not in Git). -The per-test results of case 10 are in -[the results file](Acceptance-2026-10-09-quality-gate-paths-Results.csv). +`4b12e2f4-d4c7-4a5d-883a-ddb7421c4848\files\lab-qg-paths` (local, not in Git); +so are the Hyper-V listing of the checkpoint names +(`checkpoints-83149ee-names.csv`) and the outputs of the diagnostic +(`runs\diagnostic-mute`, and `runs\diagnostic-mute-first-run` from before the +probe listed owners and entries). The per-test results of case 10 are in +[the results file](Acceptance-2026-10-09-quality-gate-paths-Results.csv). The +scripts that build, package, run, and clean up in this acceptance contain +paths of the session folder and stay in the session artifact; the generic +ones that it used, `Validate-LabResults.ps1` (the check of the result files) +and `Probe-LaterCommand.ps1` (the diagnostic), are in the folder +[Acceptance](Acceptance). diff --git a/Tests/Lab/Acceptance/Probe-LaterCommand.ps1 b/Tests/Lab/Acceptance/Probe-LaterCommand.ps1 new file mode 100644 index 0000000..780e481 --- /dev/null +++ b/Tests/Lab/Acceptance/Probe-LaterCommand.ps1 @@ -0,0 +1,224 @@ +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'Pester passes the data to the blocks of the container.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'The tests read the variables that BeforeAll sets.')] +[CmdletBinding()] +param ( + [Parameter(Mandatory)] [string] $ModulePath, + [Parameter(Mandatory)] [string] $OutFile, + [string] $PesterPath = 'V:\Git\WindowsAccessControl\output\RequiredModules\Pester\5.7.1' +) + +# Diagnostic of the acceptance in Acceptance-2026-10-09-quality-gate-paths.md, not part of it: why do the Select-Object rows of case 10 +# fail on the base of the branch without a message? It runs the bodies of those tests (Assert-LabPipelineStop and +# Assert-LabDownstreamFailure of NTFSSecurity.Live.Tests.ps1) against files in a new folder below TEMP, with the settings of the +# runner (Pester 5.7.1, ErrorActionPreference Stop, detailed plain text), and lists for each test its result and error records, and +# the state of the items afterwards. One module build in one edition per process, never imported into another session; the script +# removes its own folder at the end after it has checked the path. Windows only. For example: +# powershell.exe -NoProfile -File Probe-LaterCommand.ps1 -ModulePath -OutFile +$ErrorActionPreference = 'Stop' +Import-Module -Name (Join-Path -Path $PesterPath -ChildPath 'Pester.psd1') -Force +$root = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath ('mute-probe-' + [guid]::NewGuid().ToString('N')) +$null = New-Item -ItemType Directory -Path $root +$account = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value +$lines = New-Object -TypeName 'System.Collections.Generic.List[string]' +try { + $container = New-PesterContainer -ScriptBlock { + param ($ModulePath, $Root, $Account) + BeforeAll { + Import-Module -Name (Join-Path -Path $ModulePath -ChildPath 'NTFSSecurity.psd1') -Force -ErrorAction Stop + $everyone = 'S-1-1-0' + $administrators = 'S-1-5-32-544' + $privateData = (Get-Module -Name NTFSSecurity).PrivateData + $privateData['EnablePrivileges'] = $false + $account = $Account + + function Get-ProbeOwner { + param ([string] $Path) + (Get-Acl -LiteralPath $Path).GetOwner([System.Security.Principal.SecurityIdentifier]).Value + } + + function New-ProbeFolder { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Helper that writes only to the folder of this run.' + )] + param ([string] $Name) + $path = Join-Path -Path $Root -ChildPath $Name + $null = New-Item -ItemType Directory -Path $path -Force + $path + } + + function New-ProbePair { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Helper that writes only to the folder of this run.' + )] + param ([string] $Name) + $directory = New-ProbeFolder -Name $Name + foreach ($item in 'First', 'Second') { + Set-Content -LiteralPath (Join-Path -Path $directory -ChildPath "$item.txt") -Value $item -NoNewline + } + + @{ Directory = $directory; First = (Join-Path -Path $directory -ChildPath 'First.txt'); Second = (Join-Path -Path $directory -ChildPath 'Second.txt') } + } + + $cases = @{ + 'Remove-Item2' = @{ + Prepare = { param ($Slug) New-ProbePair -Name "RemoveItem2-$Slug" } + Run = { param ($Context) Remove-Item2 -Path $Context.First, $Context.Second -PassThru -ErrorAction SilentlyContinue } + Untouched = { param ($Context) Test-Path -LiteralPath $Context.Second } + } + 'Copy-Item2' = @{ + Prepare = { param ($Slug) $c = New-ProbePair -Name "CopyItem2-$Slug"; $c.Destination = New-ProbeFolder -Name "CopyItem2-$Slug-To"; $c } + 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 'Second.txt')) } + } + 'Move-Item2' = @{ + Prepare = { param ($Slug) $c = New-ProbePair -Name "MoveItem2-$Slug"; $c.Destination = New-ProbeFolder -Name "MoveItem2-$Slug-To"; $c } + 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 } + } + 'Set-NTFSOwner' = @{ + Prepare = { param ($Slug) New-ProbePair -Name "SetOwner-$Slug" } + Run = { param ($Context) Set-NTFSOwner -Path $Context.First, $Context.Second -Account $account -PassThru -ErrorAction SilentlyContinue } + Untouched = { param ($Context) (Get-ProbeOwner -Path $Context.Second) -eq $administrators } + } + 'Set-NTFSSecurityDescriptor' = @{ + Prepare = { + param ($Slug) + $c = New-ProbePair -Name "SetDescriptor-$Slug" + $c.Descriptors = @(Get-NTFSSecurityDescriptor -Path $c.First, $c.Second -ErrorAction Stop) + Add-NTFSAccess -SecurityDescriptor $c.Descriptors -Account $everyone -AccessRights ReadData -ErrorAction Stop + $c + } + Run = { param ($Context) Set-NTFSSecurityDescriptor -SecurityDescriptor $Context.Descriptors -PassThru -ErrorAction SilentlyContinue } + Untouched = { param ($Context) -not (@((Get-Acl -LiteralPath $Context.Second).GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) | Where-Object -FilterScript { $_.IdentityReference.Value -eq $everyone }).Count) } + } + } + $streamCases = @{ + '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 + RecordType = [System.Management.Automation.VerboseRecord] + } + 'Set-NTFSOwner/debug' = @{ + Prepare = $cases['Set-NTFSOwner'].Prepare + Run = { param ($Context) Set-NTFSOwner -Path $Context.First, $Context.Second -Account $account -ErrorAction SilentlyContinue 5>&1 } + Untouched = $cases['Set-NTFSOwner'].Untouched + RecordType = [System.Management.Automation.DebugRecord] + } + } + + function Assert-ProbePipelineStop { + param ([hashtable] $Case, [string] $Slug, [string] $Stream) + + if ($Stream -eq 'debug') { $DebugPreference = 'Continue' } + $context = & $Case.Prepare $Slug + $Error.Clear() + + $result = @(& $Case.Run $context | Select-Object -First 1) + + $result | Should -HaveCount 1 + if ($Case.RecordType) { + $result[0] | Should -BeOfType $Case.RecordType + } + + $Error.Count | Should -Be 0 + if ($Case.Untouched) { + (& $Case.Untouched $context) | Should -BeTrue + } + } + + function Assert-ProbeDownstreamFailure { + param ([hashtable] $Case, [string] $Slug, [string] $Stream) + + if ($Stream -eq 'debug') { $DebugPreference = 'Continue' } + $context = & $Case.Prepare $Slug + $emitted = 0 + $caught = $null + $Error.Clear() + try { + & $Case.Run $context | ForEach-Object -Process { + $emitted++ + throw 'Downstream failure' + } + } + 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 + } + } + } + + Describe 'Mirror of the later-command tests' { + It ' should stop after the first object for Select-Object -First 1' -ForEach @( + @{ Name = 'Remove-Item2' }, @{ Name = 'Copy-Item2' }, @{ Name = 'Move-Item2' }, @{ Name = 'Set-NTFSOwner' }, @{ Name = 'Set-NTFSSecurityDescriptor' } + ) { + Assert-ProbePipelineStop -Case $cases[$Name] -Slug 'Select' + } + + It ' should stop after the first object for throw' -ForEach @( + @{ Name = 'Remove-Item2' }, @{ Name = 'Copy-Item2' }, @{ Name = 'Move-Item2' }, @{ Name = 'Set-NTFSOwner' }, @{ Name = 'Set-NTFSSecurityDescriptor' } + ) { + Assert-ProbeDownstreamFailure -Case $cases[$Name] -Slug 'Throw' + } + + It ' should stop at the message for Select-Object -First 1' -ForEach @( + @{ Key = 'Set-NTFSSecurityDescriptor/verbose'; Stream = 'verbose' }, @{ Key = 'Set-NTFSOwner/debug'; Stream = 'debug' } + ) { + Assert-ProbePipelineStop -Case $streamCases[$Key] -Slug ('{0}Select' -f $Stream) -Stream $Stream + } + } + } -Data @{ ModulePath = $ModulePath; Root = $root; Account = $account } + + $configuration = New-PesterConfiguration + $configuration.Run.Container = $container + $configuration.Run.PassThru = $true + $configuration.Output.Verbosity = 'Detailed' + $configuration.Output.RenderMode = 'Plaintext' + $lines.Add(('Edition {0} {1}; module {2}' -f $PSVersionTable.PSEdition, $PSVersionTable.PSVersion, $ModulePath)) + $lines.Add('--- Pester output') + $output = & { Invoke-Pester -Configuration $configuration } *>&1 + $result = @($output | Where-Object -FilterScript { $_ -is [Pester.Run] }) | Select-Object -First 1 + foreach ($entry in @($output | Where-Object -FilterScript { $_ -isnot [Pester.Run] })) { $lines.Add('{0}' -f $entry) } + $lines.Add('--- Results') + foreach ($test in $result.Tests) { + $messages = @(@($test.ErrorRecord) | Where-Object -FilterScript { $_ } | ForEach-Object -Process { ($_.ToString() -split '\r?\n')[0] }) + $lines.Add(('{0} | {1} | error records: {2} | {3}' -f $test.Result, $test.ExpandedName, @($test.ErrorRecord).Count, ($messages -join ' // '))) + } + + $lines.Add(('Totals: passed {0}, failed {1}, not run {2}; result {3}' -f $result.PassedCount, $result.FailedCount, $result.NotRunCount, $result.Result)) + $lines.Add('--- State of the items after the run') + foreach ($folder in Get-ChildItem -LiteralPath $root -Directory | Sort-Object -Property Name) { + $files = @(Get-ChildItem -LiteralPath $folder.FullName -File | ForEach-Object -Process { $_.Name }) + $lines.Add(('{0}: {1}' -f $folder.Name, ($files -join ', '))) + } + + $lines.Add('--- Owner (SetOwner folders) and explicit entry for Everyone (SetDescriptor folders) after the run') + foreach ($folder in Get-ChildItem -LiteralPath $root -Directory | Where-Object -FilterScript { $_.Name -like 'SetOwner-*' -or $_.Name -like 'SetDescriptor-*' } | Sort-Object -Property Name) { + foreach ($name in 'First.txt', 'Second.txt') { + $path = Join-Path -Path $folder.FullName -ChildPath $name + $acl = Get-Acl -LiteralPath $path + if ($folder.Name -like 'SetOwner-*') { + $owner = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value + $lines.Add(('{0}\{1}: owner {2}' -f $folder.Name, $name, $(if ($owner -eq 'S-1-5-32-544') { 'Administrators (as created)' } elseif ($owner -eq $account) { 'the account of the run (changed)' } else { $owner }))) + } + else { + $entries = @($acl.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) | Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-1-0' }) + $lines.Add(('{0}\{1}: explicit entry for Everyone: {2}' -f $folder.Name, $name, $(if ($entries.Count) { 'yes (changed)' } else { 'no (as created)' }))) + } + } + } +} +finally { + $full = [System.IO.Path]::GetFullPath($root) + if ($full.StartsWith([System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()), [System.StringComparison]::OrdinalIgnoreCase) -and (Split-Path -Path $full -Leaf) -like 'mute-probe-*') { + Remove-Item -LiteralPath $full -Recurse -Force -ErrorAction SilentlyContinue + } + + Set-Content -LiteralPath $OutFile -Value $lines -Encoding utf8 +} diff --git a/Tests/Lab/Acceptance/Test-PublishedRelease.ps1 b/Tests/Lab/Acceptance/Test-PublishedRelease.ps1 new file mode 100644 index 0000000..a63334e --- /dev/null +++ b/Tests/Lab/Acceptance/Test-PublishedRelease.ps1 @@ -0,0 +1,111 @@ +[CmdletBinding()] +param ( + [Parameter(Mandatory)] [ValidatePattern('^\d+\.\d+\.\d+(-[0-9A-Za-z]+)?$')] [string] $Version, + [Parameter(Mandatory)] [string] $OutputPath, + [string] $Repository = 'raandree/NTFSSecurity' +) + +# Read-only identity check of a published NTFSSecurity version (acceptance of a published candidate): the tag, its commit on master, the CI run of the +# tag, the GitHub release asset, and the PowerShell Gallery package. It downloads the nupkg and the zip into OutputPath, checks the +# SHA-512 that the Gallery publishes (ordinal, case-sensitive base64), extracts both with System.IO.Compression, and compares the +# module files byte for byte. It writes Identity.json and prints a table; it changes nothing on GitHub or in the Gallery, and +# it never imports the module. Exit code 1 for any mismatch. +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 +Add-Type -AssemblyName System.IO.Compression.FileSystem +New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null +$headers = @{ 'User-Agent' = 'ntfssecurity-published-identity-check'; Accept = 'application/vnd.github+json' } +$api = "https://api.github.com/repos/$Repository" +$problems = New-Object -TypeName 'System.Collections.Generic.List[string]' +$result = [ordered]@{ Version = $Version; CheckedUtc = [DateTime]::UtcNow.ToString('o') } + +# 1. The tag and its commit +$ref = Invoke-RestMethod -Uri "$api/git/ref/tags/$Version" -Headers $headers +$sha = $ref.object.sha +if ($ref.object.type -eq 'tag') { $sha = (Invoke-RestMethod -Uri "$api/git/tags/$sha" -Headers $headers).object.sha } +$result.TagCommit = $sha +$compare = Invoke-RestMethod -Uri "$api/compare/master...$sha" -Headers $headers +$result.CommitOnMaster = ($compare.status -in 'identical', 'behind') +$result.CompareStatus = $compare.status +if (-not $result.CommitOnMaster) { $problems.Add("The commit $sha of the tag isn't on master (compare status: $($compare.status)).") } + +# 2. The CI run of the tag: the tag push has the tag as its branch name +$runs = @((Invoke-RestMethod -Uri "$api/actions/runs?head_sha=$sha&per_page=30" -Headers $headers).workflow_runs | Where-Object -FilterScript { $_.event -eq 'push' -and $_.head_branch -eq $Version }) +if ($runs.Count -eq 0) { $problems.Add("No CI run of the tag push for $Version.") } +$jobs = @() +foreach ($run in ($runs | Sort-Object -Property run_number)) { + $jobs += @((Invoke-RestMethod -Uri "$api/actions/runs/$($run.id)/jobs?per_page=50" -Headers $headers).jobs | ForEach-Object -Process { + [pscustomobject]@{ Run = $run.id; Attempt = $run.run_attempt; Job = $_.name; Status = $_.status; Conclusion = $_.conclusion } + }) +} +$result.CiJobs = $jobs +$latestRelease = @($jobs | Where-Object -FilterScript { $_.Job -match 'Release' } | Sort-Object -Property Attempt | Select-Object -Last 1) +if ($latestRelease.Count -eq 0 -or $latestRelease[0].Conclusion -ne 'success') { $problems.Add('The latest Release job of the tag did not succeed.') } + +# 3. The GitHub release and its zip +$release = Invoke-RestMethod -Uri "$api/releases/tags/$Version" -Headers $headers +$asset = @($release.assets | Where-Object -FilterScript { $_.name -eq 'NTFSSecurity.zip' }) | Select-Object -First 1 +if (-not $asset) { throw "The release $Version has no NTFSSecurity.zip." } +$zipPath = Join-Path -Path $OutputPath -ChildPath "NTFSSecurity-$Version.zip" +Invoke-WebRequest -Uri $asset.browser_download_url -OutFile $zipPath -UseBasicParsing +$zipSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $zipPath).Hash +$result.Release = [ordered]@{ Prerelease = $release.prerelease; Published = $release.published_at; AssetSize = $asset.size; AssetDigest = $asset.digest; ZipSha256 = $zipSha256 } +if ($asset.digest -and $asset.digest -like 'sha256:*' -and ($asset.digest.Substring(7) -ne $zipSha256.ToLowerInvariant())) { $problems.Add('The SHA-256 of the downloaded zip differs from the digest of the release asset.') } + +# 4. The PowerShell Gallery package; the published hash is base64 of SHA-512 +$entry = Invoke-RestMethod -Uri ("https://www.powershellgallery.com/api/v2/Packages(Id='NTFSSecurity',Version='{0}')" -f $Version) +$published = $entry.entry.properties.PackageHash.'#text' +if (-not $published) { $published = [string] $entry.entry.properties.PackageHash } +$algorithm = $entry.entry.properties.PackageHashAlgorithm +if (-not $published -or $algorithm -ne 'SHA512') { throw "The Gallery has no SHA512 hash for NTFSSecurity $Version (algorithm '$algorithm')." } +$nupkgPath = Join-Path -Path $OutputPath -ChildPath "NTFSSecurity.$Version.nupkg" +Invoke-WebRequest -Uri "https://www.powershellgallery.com/api/v2/package/NTFSSecurity/$Version" -OutFile $nupkgPath -UseBasicParsing +$sha512 = [System.Security.Cryptography.SHA512]::Create() +$stream = [System.IO.File]::OpenRead($nupkgPath) +try { $actual = [Convert]::ToBase64String($sha512.ComputeHash($stream)) } finally { $stream.Dispose(); $sha512.Dispose() } +$hashMatches = [string]::Equals($actual, $published, [StringComparison]::Ordinal) +$result.Gallery = [ordered]@{ Published = $entry.entry.properties.Published.'#text'; IsPrerelease = $entry.entry.properties.IsPrerelease.'#text'; PackageHashAlgorithm = $algorithm; PackageHash = $published; DownloadedSha512 = $actual; HashMatches = $hashMatches; NupkgSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $nupkgPath).Hash } +if (-not $hashMatches) { $problems.Add('The downloaded nupkg does not have the SHA-512 that the Gallery publishes.') } + +# 5. The module files of both packages +$nupkgFolder = Join-Path -Path $OutputPath -ChildPath "nupkg-$Version" +$zipFolder = Join-Path -Path $OutputPath -ChildPath "zip-$Version" +foreach ($folder in $nupkgFolder, $zipFolder) { if (Test-Path -LiteralPath $folder) { Remove-Item -LiteralPath $folder -Recurse -Force } } +[System.IO.Compression.ZipFile]::ExtractToDirectory($nupkgPath, $nupkgFolder) +[System.IO.Compression.ZipFile]::ExtractToDirectory($zipPath, $zipFolder) +function Get-ModuleRoot { param ([string] $Folder) (Get-ChildItem -LiteralPath $Folder -Filter 'NTFSSecurity.psd1' -Recurse -File | Select-Object -First 1).DirectoryName } +$nupkgRoot = Get-ModuleRoot -Folder $nupkgFolder +$zipRoot = Get-ModuleRoot -Folder $zipFolder +$files = foreach ($file in Get-ChildItem -LiteralPath $zipRoot -Recurse -File) { + $relative = $file.FullName.Substring($zipRoot.Length).TrimStart('\') + $other = Join-Path -Path $nupkgRoot -ChildPath $relative + $zipHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $file.FullName).Hash + $nupkgHash = if (Test-Path -LiteralPath $other) { (Get-FileHash -Algorithm SHA256 -LiteralPath $other).Hash } else { '' } + [pscustomobject]@{ File = $relative; ZipSha256 = $zipHash; NupkgSha256 = $nupkgHash; Equal = ($zipHash -eq $nupkgHash) } +} + +$files | Export-Csv -LiteralPath (Join-Path -Path $OutputPath -ChildPath "ModuleFiles-$Version.csv") -NoTypeInformation -Encoding utf8 +$result.ModuleFiles = @($files).Count +$result.ModuleFilesEqual = (@($files | Where-Object -FilterScript { -not $_.Equal }).Count -eq 0) +$result.ModuleDllSha256 = ($files | Where-Object -FilterScript { $_.File -eq 'NTFSSecurity.dll' }).ZipSha256 +if (-not $result.ModuleFilesEqual) { $problems.Add('The module files of the nupkg and of the zip differ.') } + +# 6. The identity that the manifest claims +$manifest = Import-PowerShellDataFile -LiteralPath (Join-Path -Path $zipRoot -ChildPath 'NTFSSecurity.psd1') +$label = $manifest.PrivateData.PSData.Prerelease +$claimed = if ($label) { '{0}-{1}' -f $manifest.ModuleVersion, $label } else { [string] $manifest.ModuleVersion } +$result.ManifestVersion = $claimed +if ($claimed -ne $Version) { $problems.Add("The manifest says $claimed, not $Version.") } + +$result.Problems = @($problems) +$result.Verified = ($problems.Count -eq 0) +$result | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path -Path $OutputPath -ChildPath "Identity-$Version.json") -Encoding utf8 +'Version {0}: tag commit {1}; on master: {2} ({3})' -f $Version, $sha, $result.CommitOnMaster, $compare.status +$jobs | Format-Table -AutoSize | Out-String -Width 200 +'GitHub zip SHA-256 {0}' -f $zipSha256 +'Gallery SHA-512 matches: {0}; nupkg SHA-256 {1}' -f $hashMatches, $result.Gallery.NupkgSha256 +'Module files: {0}; equal in nupkg and zip: {1}; NTFSSecurity.dll SHA-256 {2}' -f $result.ModuleFiles, $result.ModuleFilesEqual, $result.ModuleDllSha256 +'Manifest identity: {0}' -f $claimed +if ($problems.Count -gt 0) { $problems | ForEach-Object -Process { 'PROBLEM: ' + $_ }; 'PUBLISHED_IDENTITY_NOT_VERIFIED'; exit 1 } +'PUBLISHED_IDENTITY_VERIFIED' diff --git a/Tests/Lab/Acceptance/Validate-LabResults.ps1 b/Tests/Lab/Acceptance/Validate-LabResults.ps1 new file mode 100644 index 0000000..1701819 --- /dev/null +++ b/Tests/Lab/Acceptance/Validate-LabResults.ps1 @@ -0,0 +1,68 @@ +[CmdletBinding()] +param ( + [Parameter(Mandatory)] [string] $ResultsFolder, + [Parameter(Mandatory)] [string] $OutputPrefix, + [string[]] $Edition = @('Desktop', 'Core'), + [ValidateSet('Candidate', 'Baseline')] [string] $Expect = 'Candidate' +) + +# Validates one controller result folder: every edition and role has exactly one result, and for a candidate no test failed +# and every exit code is 0. It writes the counts per role, every test with its result (from the result files of the roles, +# not from the counts), and the failures with their full names and messages. A Desktop ConvertFrom-Json wraps an array in +# one object, so each JSON array is enumerated explicitly. -File passes an array as one string. +$ErrorActionPreference = 'Stop' +$Edition = @($Edition | ForEach-Object -Process { $_ -split ',' } | Where-Object -FilterScript { $_ }) +$summary = @(Get-Content -LiteralPath (Join-Path -Path $ResultsFolder -ChildPath 'Summary.json') -Raw | ConvertFrom-Json | ForEach-Object -Process { $_ }) +$roles = 'Delegate', 'ServerAdmin', 'Admin', 'Server' +$expected = @(foreach ($name in $Edition) { foreach ($role in $roles) { '{0}:{1}' -f $name, $role } }) +$actual = @($summary | ForEach-Object -Process { '{0}:{1}' -f $_.Edition, $_.Role }) +$problems = New-Object -TypeName 'System.Collections.Generic.List[string]' +foreach ($identity in $expected) { + $count = @($actual | Where-Object -FilterScript { $_ -eq $identity }).Count + if ($count -ne 1) { $problems.Add("$identity has $count results instead of 1") } +} + +if ($summary.Count -ne $expected.Count) { $problems.Add("The summary has $($summary.Count) results instead of $($expected.Count)") } +$counts = foreach ($entry in $summary) { + [pscustomobject]@{ + Version = $entry.Version; Edition = $entry.Edition; Role = $entry.Role; Account = $entry.Account; ExitCode = $entry.ExitCode + Passed = $entry.Passed; Failed = $entry.Failed; Skipped = $entry.Skipped + } +} + +$tests = New-Object -TypeName 'System.Collections.Generic.List[object]' +foreach ($file in Get-ChildItem -LiteralPath $ResultsFolder -Filter '*.result.json') { + $baseName = $file.Name -replace '\.result\.json$', '' + $role = ($baseName -split '-')[-1] + $resultEdition = if ($baseName -match '-(Desktop|Core)-') { $Matches[1] } else { '' } + foreach ($case in @(Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json | ForEach-Object -Process { $_ })) { + $tests.Add([pscustomobject]@{ Edition = $resultEdition; Role = $role; Test = $case.Name; Result = $case.Result; Message = (($case.Message -split '\r?\n')[0]) }) + } +} + +$failures = @($tests | Where-Object -FilterScript { $_.Result -eq 'Failed' }) +if ($Expect -eq 'Candidate') { + foreach ($row in $counts) { + if ($row.ExitCode -ne 0 -or $row.Failed -ne 0 -or $row.Passed -eq 0) { $problems.Add("$($row.Edition) $($row.Role): exit code $($row.ExitCode), $($row.Passed) passed, $($row.Failed) failed") } + } + + if ($failures.Count -gt 0) { $problems.Add("$($failures.Count) failed tests in the result files") } +} + +$counts | Export-Csv -LiteralPath ($OutputPrefix + '-counts.csv') -NoTypeInformation -Encoding utf8 +$tests | Export-Csv -LiteralPath ($OutputPrefix + '-tests.csv') -NoTypeInformation -Encoding utf8 +$failures | Export-Csv -LiteralPath ($OutputPrefix + '-failures.csv') -NoTypeInformation -Encoding utf8 +foreach ($editionName in $Edition) { + $selected = @($counts | Where-Object -FilterScript { $_.Edition -eq $editionName }) + '{0}: passed={1}, failed={2}, skipped={3}' -f $editionName, ($selected.Passed | Measure-Object -Sum).Sum, ($selected.Failed | Measure-Object -Sum).Sum, ($selected.Skipped | Measure-Object -Sum).Sum +} + +$counts | Format-Table -AutoSize | Out-String -Width 200 +'tests in the result files: {0}; failed: {1}; skipped: {2}' -f $tests.Count, $failures.Count, @($tests | Where-Object -FilterScript { $_.Result -eq 'Skipped' }).Count +if ($problems.Count -gt 0) { + $problems | ForEach-Object -Process { 'PROBLEM: ' + $_ } + 'LIVE_RESULT_NOT_ACCEPTED' + exit 1 +} + +'LIVE_RESULT_VERIFIED ({0})' -f $Expect diff --git a/Tests/Lab/README.md b/Tests/Lab/README.md index c48cfa3..9778d6d 100644 --- a/Tests/Lab/README.md +++ b/Tests/Lab/README.md @@ -22,7 +22,7 @@ without a lab they skip every test. | 9 | Delegate, Admin | `Get-NTFSSimpleAccess` compares a share folder with its parent. For the accounts of another domain and of other forests, `Get-NTFSAccess` returns their names, `Get-NTFSOrphanedAccess` doesn't report them, `Add-NTFSAccess` and `Remove-NTFSAccess` find them by name, and `Get-NTFSEffectiveAccess -ServerName` returns the rights that the file server's own token of each account gets. | | Long paths | Admin | `Get-ChildItem2` and `Get-NTFSAccess` with a share path longer than 260 characters. | | [#108][issue-108] | Admin | `Copy-Item2` and `Move-Item2` with `-WhatIf` onto an existing file on the share write no error. | -| 10 | Delegate, ServerAdmin, Admin | The behavior that the quality-gate fixes before 5.0.0 changed, and that the lab can observe. The delegated account, which owns the items it creates, clears and protects the DACL of an item whose OWNER RIGHTS entry denies it the right to change the DACL, and the cmdlets report no `RestoreOwnerError` for the unchanged owner. `InheritedFrom` names an `unknown parent` for entries that Windows can't resolve, for a deleted file and below a folder whose permissions the account can't read, and no source for an explicit entry. A later command that stops the pipeline with `Select-Object -First 1` or throws leaves the second item of `Remove-Item2`, `Copy-Item2`, `Move-Item2`, `Set-NTFSOwner`, and `Set-NTFSSecurityDescriptor` as it was, also at a verbose message, and `Get-ChildItem2` passes on what a later command throws for the error of a folder it can't read. `Get-ChildItem2 -Filter` finds a name with brackets and returns every item for `*.*`. The privileges that the cmdlets enable are disabled again when a later command stops the pipeline or throws at a debug message. | +| 10 | Delegate, ServerAdmin, Admin | The behavior that the quality-gate fixes before 5.0.0 changed, and that the lab can observe. The delegated account, which owns the items it creates, clears and protects the DACL of an item whose OWNER RIGHTS entry denies it the right to change the DACL, and the cmdlets report no `RestoreOwnerError` for the unchanged owner. `InheritedFrom` names an `unknown parent` for entries that Windows can't resolve, for a deleted file and below a folder whose permissions the account can't read, and no source for an explicit entry. A later command that stops the pipeline with `Select-Object -First 1` or throws leaves the second item of `Remove-Item2`, `Copy-Item2`, `Move-Item2`, `Set-NTFSOwner`, and `Set-NTFSSecurityDescriptor` as it was, also when it takes the verbose messages of `Set-NTFSSecurityDescriptor` or the debug messages of `Set-NTFSOwner`, and `Get-FileHash2` writes no error when it takes its verbose messages. `Get-ChildItem2` passes on what a later command throws for the error of a folder it can't read, and a `break` of that command leaves the caller's loop. `Get-ChildItem2 -Filter` finds a name with brackets, returns every item for `*.*`, and rejects `$null`. The privileges that the cmdlets enable are disabled again when a later command stops the pipeline or throws at a debug message. | | State | Server | After the runs on the client, the file server checks the owners, the audit entries, the items, the links, the entries of the foreign accounts, and which items a later command changed, itself, without the module. | Case 1 uses two kinds of folders. Before 5.0.0-rc3, the cmdlets wrote back the