diff --git a/Tests/Coverage/Quality-Gate-Test-Round-2026-10-20-Evidence.csv b/Tests/Coverage/Quality-Gate-Test-Round-2026-10-20-Evidence.csv new file mode 100644 index 0000000..4d76506 --- /dev/null +++ b/Tests/Coverage/Quality-Gate-Test-Round-2026-10-20-Evidence.csv @@ -0,0 +1,77 @@ +"Path","Bytes","Sha256" +"round1\access\commit-diff.txt","34024","A3C896BBCBEE3A647621641160901BD8D5D7A99B51C828DAB7BA362474634348" +"round1\access\Run-Configuration.ps1","790","0C7E8394C041DCFA184ED73E43F75EB507BF6349D1DC2F6F55BF65BB4C59EF80" +"round1\access\source-pin.txt","42","770F2639B486ACC92430CE8C57A94A7E32706A050B0FBEAF369A73AB9B02F1FA" +"round1\audit\Run-Configuration.ps1","790","0C7E8394C041DCFA184ED73E43F75EB507BF6349D1DC2F6F55BF65BB4C59EF80" +"round1\audit\source-pin.txt","42","770F2639B486ACC92430CE8C57A94A7E32706A050B0FBEAF369A73AB9B02F1FA" +"round1\IMPLEMENTER-BRIEF.md","9366","55F78935F9E86BF47382574D25A144A791828E376C0C372B5775442E979D5066" +"round1\inheritance\commit-diff.txt","19909","B3A692CC065DB21264F03D15E7235F16E31F8AAC9E77BEBAA52A7176053DD851" +"round1\inheritance\Run-Configuration.ps1","790","0C7E8394C041DCFA184ED73E43F75EB507BF6349D1DC2F6F55BF65BB4C59EF80" +"round1\inheritance\source-pin.txt","42","770F2639B486ACC92430CE8C57A94A7E32706A050B0FBEAF369A73AB9B02F1FA" +"round1\items\commit-diff.txt","44797","F6917B0C3EF401E912E839200E00469591ADED382864AB18AE6EC625DF91FFE5" +"round1\items\Run-Configuration.ps1","790","0C7E8394C041DCFA184ED73E43F75EB507BF6349D1DC2F6F55BF65BB4C59EF80" +"round1\items\source-pin.txt","42","770F2639B486ACC92430CE8C57A94A7E32706A050B0FBEAF369A73AB9B02F1FA" +"round1\reports\report-access.md","6276","846716AE313AFC1E7FDED5C18DB0E2F80696EDFBBD8CED610AB82E9C125B3D13" +"round1\reports\report-audit.md","3947","65A985538611285DD1E2F1BDF746202B0179C605E784E0DA4DB3A28D9A1785B4" +"round1\reports\report-inheritance.md","3844","2A7F0957303F85FCC98FB88EC018A27B3225A7E68671B54554DE831DC3874176" +"round1\reports\report-items.md","6340","BE637FF086957BD39F64A255A10EF7C348631C22748F11A72AAB98B2A16D5B34" +"round1\suite\delta-four-vs-baseline.txt","23778","F61CE9884D911EDEED17AD9D167FA9FF2A006DCE092CF0AB5DE8C4B740321E34" +"round1\suite\rc8suite-20261020-093109\basic-Core-summary.md","150","937D42060174BCAF155579CF13C4A54962BE5CCA247585807470041D6FBEEF33" +"round1\suite\rc8suite-20261020-093109\basic-Core.log","206918","631DE1955CF31FC21F4C4DDFC9F6DBCFDA4F38630EEB97D4DE0A0F5F72A7DBB8" +"round1\suite\rc8suite-20261020-093109\basic-Core.xml","687469","DDF641D20A51C4CC799B58077E09B1C43A8570AF19C9A50E450335495127D17A" +"round1\suite\rc8suite-20261020-093109\basic-Desktop-summary.md","153","035C555E8D942092D7E5CAC4E62C0409A09D79903E6C14D45DCC7F2EC24955EE" +"round1\suite\rc8suite-20261020-093109\basic-Desktop.log","202259","9B71B28B124FB19C1C40287B54E4362550C0595638AFA7529CEB584000A527D5" +"round1\suite\rc8suite-20261020-093109\basic-Desktop.xml","687457","F6B826458A38E632912EC0F1AD1C23D215868E733850E109FFC5BFFD12E629AA" +"round1\suite\rc8suite-20261020-093109\driver.log","807","5429FCF0357252AE5AA69E710C9AE84BFC4C6299A11DFB87F98C1503E6D8BA6A" +"round1\suite\rc8suite-20261020-093109\elevated-Core-summary.md","152","673EFCBF03E5999462903567AD47AF4B26BA6062C3BD45A47CD18BB1CDF1844C" +"round1\suite\rc8suite-20261020-093109\elevated-Core.log","210466","A73178F81F7F42468C8BD6BA27EFB7CC6350604C9C01EFF2EE721BCD494EF627" +"round1\suite\rc8suite-20261020-093109\elevated-Core.xml","674273","04B0D4A7DB2D1251900D26E0853130CC586A00D5EFCF2E4B523C508E2AFA396B" +"round1\suite\rc8suite-20261020-093109\elevated-Desktop-summary.md","155","0BFBB63374A3B3E34AC5A460BBA47538FB30B391631B7CB221BE4A0EC09663AE" +"round1\suite\rc8suite-20261020-093109\elevated-Desktop.log","205681","DCF221A80D16FB8F9DB32E9CD50148E26CB4E6A880AA2399B2E53FA5EA5A8371" +"round1\suite\rc8suite-20261020-093109\elevated-Desktop.xml","674209","F877226807362A96058478928DBAE8EF42788347A1E89CA7A8DE683EEC5EA1BE" +"round1\suite\round1-four-20261020-142909\basic-Core-summary.md","150","829CED285CFB1647BCD4C062CD8A382C2D4FC4F0FB26D387F15FA0AB8D451E80" +"round1\suite\round1-four-20261020-142909\basic-Core.log","228078","11D26E7B9BBE3916BE1DD36F89ED61612EBAA9A59EBE0562246CC3D54BF0FF6F" +"round1\suite\round1-four-20261020-142909\basic-Core.xml","757177","B163017A9E2698BCE44BAA7AAA6342289DC357EBF81DFD9D7C89FCF98FB6BA81" +"round1\suite\round1-four-20261020-142909\basic-Desktop-summary.md","153","BA62973E006C65CA33B6C06CC7959EB987E56E70716E64CF46CEAB7B4235BEBD" +"round1\suite\round1-four-20261020-142909\basic-Desktop.log","223489","3A9702207D84749428AB3EF73DC534D9A97AFA5B53DF774ECAA0B65A44D40ABF" +"round1\suite\round1-four-20261020-142909\basic-Desktop.xml","757159","40D4DDA8902C47356CF79C20A113ED9A10159906DC97F357012656B0CE7176CC" +"round1\suite\round1-four-20261020-142909\driver.log","813","E38C08218416F558878F334C3783E7F21E11FDF079F3925211558232E5EA1487" +"round1\suite\round1-four-20261020-142909\elevated-Core-summary.md","152","DA662B842A82A70C3208D9B84007450C04EC79CB1FBCCC45B858BD32A8633AE8" +"round1\suite\round1-four-20261020-142909\elevated-Core.log","231606","844FBE7F8F525B642BB53D904E0FEF837D86BB00CE264C0262C9FBEDC2909433" +"round1\suite\round1-four-20261020-142909\elevated-Core.xml","743554","CB186B76C368DFE6406464418D8D96A5FD7431FB76900989EED9296C8EF6DDD8" +"round1\suite\round1-four-20261020-142909\elevated-Desktop-summary.md","155","91CBE39CDBCEB88C46D3F520951C146BE88B42471D2EC4FFC0E7D50735E2E575" +"round1\suite\round1-four-20261020-142909\elevated-Desktop.log","226871","E10752CDF65F39FB788F3628715B82060A90E8703BE76D0719B79D4BE334F990" +"round1\suite\round1-four-20261020-142909\elevated-Desktop.xml","743500","C2D2C2F0E41EECDC99B23A7C08EF5BA839A57704D21374732A4A4F4213C68017" +"round1\suite\round1-three-20261020-141224\basic-Core-summary.md","150","17E28A3615C92DF659CE87818D685E62427523D214B825C115CD26B480C1FB3A" +"round1\suite\round1-three-20261020-141224\basic-Core.log","223236","D17FA2C0736B858A5091DDEE20030780E90761769902F0A5E05FE28D07540A69" +"round1\suite\round1-three-20261020-141224\basic-Core.xml","740324","0F184A9E7558675A4C6BCA53BB47E0C107C90F153440D3E9129744AD924B8011" +"round1\suite\round1-three-20261020-141224\basic-Desktop-summary.md","153","AD762C7E8820A7BE7D63A5E8E946F369E9CDCBC89AF65936C0C619A7BD7E01F1" +"round1\suite\round1-three-20261020-141224\basic-Desktop.log","218670","33DFF2FCD2E39764BEA73D28749AAD411974766460625AAF4CCAAF64BFD2B3C0" +"round1\suite\round1-three-20261020-141224\basic-Desktop.xml","740333","68555C14A0C8750EDF40548808C857F9904CB96D2D190125CE24B2FB78C8A557" +"round1\suite\round1-three-20261020-141224\driver.log","815","963EE2B5522054245B524D911887FB61A6923E0078CD3C6F64C3FFD30A5AF3FB" +"round1\suite\round1-three-20261020-141224\elevated-Core-summary.md","152","74AFDE9B42E11C15290750FB5AF2F3EA2E14C07899334B5B033EFFA073201484" +"round1\suite\round1-three-20261020-141224\elevated-Core.log","226943","5ECEA749651F57FC35B1BBFA4F39941B255DC32417EE1868FBAECCA3FEF5DEB9" +"round1\suite\round1-three-20261020-141224\elevated-Core.xml","727012","1EE6DB5535A0AB681C5C5FC576FC79283C36FF9F5C22A78EBF38785B83D76FE0" +"round1\suite\round1-three-20261020-141224\elevated-Desktop-summary.md","155","3304B0A67528DEB418424848E623401B0B69E35FD289CF0A9763FA048B552A21" +"round1\suite\round1-three-20261020-141224\elevated-Desktop.log","222407","F171888AEE1BCA0719987878B730A6CAAE658BA69B0FCBC0C4CC6D0AEF0F30B5" +"round1\suite\round1-three-20261020-141224\elevated-Desktop.xml","726954","46DC96B84FD78F89313E62BC4701E644396384D203EA9E93DDC66CDAEEBD57C9" +"round1\suite\runner-39551726767\build-and-test-job.log","1600975","AE4120B3F41A4F4AA92FF5CE69A913B8CD980207A4B8B6D95547E099698A7311" +"round1\suite\runner-39551726767\delta-runner-vs-baseline.txt","21762","D6B1BD04B235E4A18004091792B5B826A64B8B367DA593EE04B1F0AE721DECCA" +"round1\suite\runner-39551726767\PowerShell7-BasicUser.xml","757904","1099C8001C48BDBD75E3D14BB4920068A7E02878F94F3C66F4BD8210FDA00D49" +"round1\suite\runner-39551726767\PowerShell7.xml","744278","027F1186B07A8ECE493971D1BC618F855FA897A7AFC52C60834B624A000C869B" +"round1\suite\runner-39551726767\WindowsPowerShell-BasicUser.xml","757780","0F1BFAFA8E792A5A9B685A630961EE9FA08224FD3E450241E797A0F14CF7E0D5" +"round1\suite\runner-39551726767\WindowsPowerShell.xml","744212","C5A91240315624288AAE9B084D7E898F2B9274E393DEBEC7005E186741966555" +"round1\tables-draft\Quality-Gate-Test-Round-2026-10-20-ParameterSets.csv","21329","2F08E25C342A84B59F48073A3B319231C9EE3D4C8534859A69F0D675E58EFE6F" +"round1\worklist-access.md","17983","4EDD989E5BF768D55EEE0E4516D474B18BB88AE2815D8D4DBEA62C50EDF323C4" +"round1\worklist-audit.md","10359","89EC64925D8967155702BA8142893DB358238CA37D612E34D70CD468C3DFF995" +"round1\worklist-inheritance.md","21757","6E63991DBB5EBD23D224BE49527934D6100C637AA47779D1011519C3A2AEB28B" +"round1\worklist-items.md","21105","F75D51717D2E58BCD6AE1D6CB8E016B911F656900704EDE491719C1506F77D42" +"scripts\Build-RoundTables.ps1","13770","74B872588366710E4B19A5E75C8495EE769935BD59CE334FFBE7AA6D431F53A8" +"scripts\Build-Worklists.ps1","3205","F431154F04CE7E914261F9EC407A7E8A2E98B2C96FDD16782EA4FD9195636B2E" +"scripts\Invoke-Build.ps1","1405","80EFE654633A72259F9F16105679B5C1E16937AA2E56B7C9A0FDE08E22FDDDC3" +"scripts\Invoke-RoundJob.ps1","4294","B6BDB86E2700CC5BE227B2810DD788AFBA8D16E944CD2600F5D9C16E873F6C0A" +"scripts\Measure-RoundSuiteDelta.ps1","3646","4728FD11D2FBBE483E5116BDBFF194638F0B4C9BB7110423F6EB4CB0BCBF90E9" +"scripts\Run-CiConfiguration.ps1","886","76D0EADA53B289D8E2A144748467EC8025C0A0A9F14AC8F133219AE823A3ACB4" +"scripts\Run-FocusedPester.ps1","2742","123E771081872881ECF2043E67B71896F518C2DDEBB15C5A1099912E0BE2685C" +"scripts\Run-FullSuite.ps1","4203","79F7E8484130A59CA9BEA87AAD39585E317A0276F35E791E90F25C2D226F27E9" +"scripts\Start-FocusedRuns.ps1","4735","0F9FB2247940BB94AB03BCB536CA4E747A73716DD96DB9FB34BD34FF2DA30620" diff --git a/Tests/Coverage/Quality-Gate-Test-Round-2026-10-20-ParameterSets.csv b/Tests/Coverage/Quality-Gate-Test-Round-2026-10-20-ParameterSets.csv new file mode 100644 index 0000000..d56a8e9 --- /dev/null +++ b/Tests/Coverage/Quality-Gate-Test-Round-2026-10-20-ParameterSets.csv @@ -0,0 +1,65 @@ +"Group","Cmdlet","ParameterSet","StatusBefore","StatusAfter","NewTestCount","NewTests","LeftKind","Left" +"access","Add-NTFSAccess","PathComplex","Partly","Closed","2","ParameterSets.Access.Tests.ps1:288 Should add an explicit allow entry for each of several accounts bound positionally, with the default scope | ParameterSets.Access.Tests.ps1:306 Should write a Deny entry through -Path with exactly the requested rights and no Synchronize","","" +"access","Add-NTFSAccess","PathSimple","Partly","Closed","3","ParameterSets.Access.Tests.ps1:329 Should report AddAceError by category and target for a denied write scoped by -AppliesTo, and leave the item unchanged | ParameterSets.Access.Tests.ps1:345 Should write an AddAceError for a Deny entry without rights through the PathSimple set, and change nothing | ParameterSets.Access.Tests.ps1:358 Should write a Deny entry scoped by -AppliesTo with exactly the requested rights and no Synchronize","","" +"access","Add-NTFSAccess","SDSimple","MaintainerDecision","MaintainerDecision","1","ParameterSets.Access.Tests.ps1:374 Should add an entry with the scope to each of several descriptors bound through the pipeline, read back with .NET, and leave the items unchanged on disk","MaintainerDecision","MaintainerDecision: the terminating error of a failing change (a deny entry without rights) is not pinned" +"access","Add-NTFSAccess","SDComplex","MaintainerDecision","MaintainerDecision","1","ParameterSets.Access.Tests.ps1:396 Should add the entry to each of several descriptors bound through the pipeline, read back with .NET, and leave the items unchanged on disk","MaintainerDecision","MaintainerDecision: the terminating error of a failing descriptor is not pinned" +"access","Remove-NTFSAccess","PathComplex","Partly","Closed","2","ParameterSets.Access.Tests.ps1:539 Should return exactly the surviving entries with -PassThru for each of several paths bound through the pipeline in one call | ParameterSets.Access.Tests.ps1:567 Should remove the entries of several accounts bound positionally, leaving another account untouched","","" +"access","Remove-NTFSAccess","PathSimple","Partly","Closed","2","ParameterSets.Access.Tests.ps1:592 Should take only the requested rights from a Deny entry scoped by -AppliesTo without -RemoveSpecific, and keep its flags | ParameterSets.Access.Tests.ps1:608 Should report a missing path's and a denied path's errors by category and target, and still scope the surviving paths' entries, all bound through the pipeline","","" +"access","Remove-NTFSAccess","SDSimple","MaintainerDecision","MaintainerDecision","2","ParameterSets.Access.Tests.ps1:644 Should take only the requested rights from an entry scoped by -AppliesTo on a descriptor without -RemoveSpecific, keep its flags, and leave the item on disk unchanged until Set-NTFSSecurityDescriptor | ParameterSets.Access.Tests.ps1:669 Should remove the matching entry from each of several descriptors bound through the pipeline for the Simple parameter set, leaving the items unchanged on disk","MaintainerDecision","MaintainerDecision: the terminating error of a failing removal is not pinned" +"access","Remove-NTFSAccess","SDComplex","MaintainerDecision","MaintainerDecision","2","ParameterSets.Access.Tests.ps1:693 Should remove an on-disk entry from a descriptor in memory only, and write the change through only when Set-NTFSSecurityDescriptor runs | ParameterSets.Access.Tests.ps1:711 Should remove the matching entry from each of several descriptors bound through the pipeline for the Complex parameter set, leaving the items unchanged on disk","MaintainerDecision","MaintainerDecision: the terminating error of a failing removal is not pinned" +"access","Clear-NTFSAccess","Path","Closed","Closed","0","","","" +"access","Clear-NTFSAccess","SD","Closed","Closed","0","","","" +"access","Get-NTFSAccess","Path","Partly","Partly","1","ParameterSets.Access.Tests.ps1:865 Should return the entries of the current location as actual Security2.FileSystemAccessRule2 objects when -Path is omitted","would-pin","would-pin: the category of the ReadSecurityError after a failed ownership retry (WriteError there, OpenError from the generic catch) is not asserted, so as not to cement the inconsistency" +"access","Get-NTFSAccess","SD","Partly","Partly","0","","Limit","no failing descriptor is tested for this set (no per-item handler)" +"access","Get-NTFSOrphanedAccess","Path","Partly","Partly","1","ParameterSets.Access.Tests.ps1:977 Should write a ReadSecurityError categorized as ReadError after a failed ownership retry, and still return the next path's orphaned entry","would-pin","would-pin: the choice of a warning, not an error, for a read failure other than access denied" +"access","Get-NTFSOrphanedAccess","SD","Partly","Closed","1","ParameterSets.Access.Tests.ps1:1021 Should return the explicit entry with -ExcludeInherited and still report an entry added to the descriptor only in memory","","" +"access","Get-NTFSSimpleAccess","Path","Partly","Closed","1","ParameterSets.Access.Tests.ps1:1077 Should report a missing path's error by category and target and still report the next folder's entries","","" +"access","Get-NTFSSimpleAccess","SD","Partly","Partly","0","","Limit","no failing descriptor is tested for this set (no per-item handler)" +"access","Get-NTFSEffectiveAccess","Path","Partly","Partly","1","ParameterSets.Access.Tests.ps1:1149 Should write nothing for a missing path and only the succeeding path's result in the same call","Limit","Limit: the ReadEffectivePermissionError handler is unreachable (the library records the failure); remote and group-based results need the lab (the live tests cover them)" +"access","Get-NTFSEffectiveAccess","SecurityDescriptor","Partly","Partly","0","","Limit","a mix of a failing and a succeeding descriptor in one call" +"audit","Add-NTFSAudit","PathComplex","Partly","Partly","1","ParameterSets.Audit.Tests.ps1:163 Should add one audit entry for each account that the piped object names, with the AuditFlags it also names","Limit","Limit: the owner retry after access denied needs a lab or a remote server" +"audit","Add-NTFSAudit","PathSimple","Closed","Closed","0","","","" +"audit","Add-NTFSAudit","SDSimple","Partly","Partly","0","","MaintainerDecision","exceptions from the change itself (open item 1)" +"audit","Add-NTFSAudit","SDComplex","Partly","Partly","0","","MaintainerDecision","exceptions from the change itself (open item 1)" +"audit","Remove-NTFSAudit","PathComplex","Partly","Partly","2","ParameterSets.Audit.Tests.ps1:501 Should remove the audit entry of each account that -Account names in one call, and leave another account alone | ParameterSets.Audit.Tests.ps1:524 Should leave the empty Audit SDDL and the DACL unchanged","Limit","Limit: the owner retry after access denied needs a lab or a remote server" +"audit","Remove-NTFSAudit","PathSimple","Closed","Closed","0","","","" +"audit","Remove-NTFSAudit","SDSimple","Partly","Closed","1","ParameterSets.Audit.Tests.ps1:577 Should remove the entry from the descriptor only in memory, until Set-NTFSSecurityDescriptor writes it, and return audit rule objects with -PassThru","","" +"audit","Remove-NTFSAudit","SDComplex","Partly","MaintainerDecision","1","ParameterSets.Audit.Tests.ps1:577 Should remove the entry from the descriptor only in memory, until Set-NTFSSecurityDescriptor writes it, and return audit rule objects with -PassThru","MaintainerDecision","MaintainerDecision: exceptions from the change itself (open item 1 of the report of Handoff 5)" +"audit","Clear-NTFSAudit","Path","Partly","Partly","1","ParameterSets.Audit.Tests.ps1:626 Should clear the audit entries of the path that follows a missing one, and write only a ReadFileError for the missing one","Limit","Limit: the owner retry after access denied needs a lab or a remote server" +"audit","Clear-NTFSAudit","SD","Closed","Closed","0","","","" +"audit","Get-NTFSAudit","Path","Partly","Partly","0","","Limit","the PermissionDenied category branch of ReadSecurityError" +"audit","Get-NTFSAudit","SD","Partly","Closed","1","ParameterSets.Audit.Tests.ps1:777 Should return the entries of a security descriptor with the expected type, account, rights, flags, and inherited state","","" +"audit","Get-NTFSOrphanedAudit","Path","Partly","Closed","2","ParameterSets.Audit.Tests.ps1:823 Should write a ReadSecurityError with category OpenError and the path as target | ParameterSets.Audit.Tests.ps1:837 Should return only the inherited orphaned entry with -ExcludeExplicit","","" +"audit","Get-NTFSOrphanedAudit","SD","Partly","Closed","1","ParameterSets.Audit.Tests.ps1:895 Should filter the orphaned entries of a piped descriptor by -Account, -ExcludeExplicit, and -ExcludeInherited","","" +"inheritance","Disable-NTFSAccessInheritance","Path","Partly","Closed","2","ParameterSets.Inheritance.Tests.ps1:804 should do nothing without -Path, not even to the current location | ParameterSets.Inheritance.Tests.ps1:880 should report ReadFileError with category OpenError for a path that does not exist, and continue with the next path","","" +"inheritance","Disable-NTFSAccessInheritance","SecurityDescriptor","Partly","Closed","1","ParameterSets.Inheritance.Tests.ps1:897 Disable-NTFSAccessInheritance -SecurityDescriptor should leave the owner unchanged once Set-NTFSSecurityDescriptor writes the protected DACL","","" +"inheritance","Enable-NTFSAccessInheritance","Path","Partly","Closed","2","ParameterSets.Inheritance.Tests.ps1:804 should do nothing without -Path, not even to the current location | ParameterSets.Inheritance.Tests.ps1:880 should report ReadFileError with category OpenError for a path that does not exist, and continue with the next path","","" +"inheritance","Enable-NTFSAccessInheritance","SecurityDescriptor","Closed","Closed","0","","","" +"inheritance","Disable-NTFSAuditInheritance","Path","Partly","Partly","1","ParameterSets.Inheritance.Tests.ps1:804 should do nothing without -Path, not even to the current location","Limit","Limit: a successful ownership retry or RestoreOwnerError of a SACL write needs a lab or a remote server" +"inheritance","Disable-NTFSAuditInheritance","SecurityDescriptor","Partly","Closed","1","ParameterSets.Inheritance.Tests.ps1:940 -SecurityDescriptor should change the in-memory audit entries per -:, applied to the item only after Set-NTFSSecurityDescriptor writes it, leaving the owner and DACL unchanged","","" +"inheritance","Enable-NTFSAuditInheritance","Path","Partly","Partly","1","ParameterSets.Inheritance.Tests.ps1:804 should do nothing without -Path, not even to the current location","Limit","Limit: a successful ownership retry or RestoreOwnerError of a SACL write needs a lab or a remote server" +"inheritance","Enable-NTFSAuditInheritance","SecurityDescriptor","Partly","Closed","1","ParameterSets.Inheritance.Tests.ps1:940 -SecurityDescriptor should change the in-memory audit entries per -:, applied to the item only after Set-NTFSSecurityDescriptor writes it, leaving the owner and DACL unchanged","","" +"inheritance","Get-NTFSInheritance","Path","Partly","Partly","2","ParameterSets.Inheritance.Tests.ps1:860 should report the current location when no -Path is given, not an empty result | ParameterSets.Inheritance.Tests.ps1:982 Get-NTFSInheritance should name the item in the Name property and report a DACL protected without the module","Limit","Limit: a successful ownership-retry read needs a lab or a remote server" +"inheritance","Get-NTFSInheritance","SecurityDescriptor","Partly","Closed","1","ParameterSets.Inheritance.Tests.ps1:993 Get-NTFSInheritance -SecurityDescriptor should return a typed object for a descriptor protected without the module","","" +"inheritance","Set-NTFSInheritance","Path","Partly","Closed","3","ParameterSets.Inheritance.Tests.ps1:804 should do nothing without -Path, not even to the current location | ParameterSets.Inheritance.Tests.ps1:880 should report ReadFileError with category OpenError for a path that does not exist, and continue with the next path | ParameterSets.Inheritance.Tests.ps1:914 Set-NTFSInheritance should set the SACL protected flag and audit-rule counts for enabled=, read independently with Get-Acl -Audit","","" +"inheritance","Set-NTFSInheritance","SecurityDescriptor","Closed","Closed","0","","","" +"inheritance","Get-NTFSOwner","Path","Partly","Closed","1","ParameterSets.Inheritance.Tests.ps1:846 Get-NTFSOwner should return nothing without -Path, not even for the current location","","" +"inheritance","Get-NTFSOwner","SecurityDescriptor","Partly","Closed","1","ParameterSets.Inheritance.Tests.ps1:1004 Get-NTFSOwner -SecurityDescriptor should return the real owner of a descriptor that was read without its Owner section","","" +"inheritance","Set-NTFSOwner","Path","Partly","Closed","2","ParameterSets.Inheritance.Tests.ps1:829 Set-NTFSOwner should do nothing without -Path, not even to the current location | ParameterSets.Inheritance.Tests.ps1:1019 Set-NTFSOwner should leave the SACL unchanged after it changes the owner","","" +"inheritance","Set-NTFSOwner","SecurityDescriptor","Partly","Closed","1","ParameterSets.Inheritance.Tests.ps1:1035 Set-NTFSOwner -SecurityDescriptor should change only the owner on disk once Set-NTFSSecurityDescriptor writes it, leaving the DACL unchanged, and -PassThru should return a FileSystemOwner object","","" +"inheritance","Get-NTFSSecurityDescriptor","__AllParameterSets","Partly","Partly","1","ParameterSets.Inheritance.Tests.ps1:860 should report the current location when no -Path is given, not an empty result","Limit","Limit: a successful ownership-retry read needs a lab or a remote server" +"inheritance","Set-NTFSSecurityDescriptor","__AllParameterSets","None","Partly","2","ParameterSets.Inheritance.Tests.ps1:1061 Should take a descriptor by property name through the pipeline, not only by value | ParameterSets.Inheritance.Tests.ps1:1082 Should report the PassThru ReadSecurityError with category ReadError and the item as target","would-pin","would-pin: positional binding at Position 2 (the sibling cmdlets use Position 1), an undecided inconsistency" +"items","Copy-Item2","__AllParameterSets","Partly","Closed","6","ParameterSets.Items.Tests.ps1:604 should treat a path with brackets in the name as a literal file name | ParameterSets.Items.Tests.ps1:747 Copy-Item2 should copy the file to a short destination and leave the source in place | ParameterSets.Items.Tests.ps1:811 Copy-Item2 should copy a file to the root of the mapped drive | ParameterSets.Items.Tests.ps1:963 Should replace an existing file directly with -Force, with PassThru=<_> | ParameterSets.Items.Tests.ps1:987 Should write DestinationFileAlreadyExists with its category and target for a file that exists at the destination | ParameterSets.Items.Tests.ps1:1003 -PassThru should return the output type for a file and for a folder, and write nothing without -PassThru","","" +"items","Get-ChildItem2","__AllParameterSets","Partly","Closed","9","ParameterSets.Items.Tests.ps1:728 Get-ChildItem2 should list the content of the folder | ParameterSets.Items.Tests.ps1:801 Get-ChildItem2 should list the content at the root of the mapped drive | ParameterSets.Items.Tests.ps1:857 Get-ChildItem2 should list the content of the current location | ParameterSets.Items.Tests.ps1:895 Should resolve a relative -Path against the current location | ParameterSets.Items.Tests.ps1:908 Should return only the folder when -Directory and -File are given together | ParameterSets.Items.Tests.ps1:919 Should return the same matches for -Attributes regardless of -Force, -Hidden, -System, and -ReadOnly () | ParameterSets.Items.Tests.ps1:932 Should have no effect without -Recurse | ParameterSets.Items.Tests.ps1:940 Should list the same items through the dir2 alias | ParameterSets.Items.Tests.ps1:946 Should write AttributesEmpty with its category and target for an empty -Attributes value","","" +"items","Get-Item2","__AllParameterSets","Partly","Closed","4","ParameterSets.Items.Tests.ps1:604 should treat a path with brackets in the name as a literal file name | ParameterSets.Items.Tests.ps1:717 Get-Item2 should return the file and its folder | ParameterSets.Items.Tests.ps1:794 Get-Item2 should return a DirectoryInfo object for the root of the mapped drive | ParameterSets.Items.Tests.ps1:844 Get-Item2 should return the folder of the current location","","" +"items","Move-Item2","__AllParameterSets","Partly","Closed","7","ParameterSets.Items.Tests.ps1:604 should treat a path with brackets in the name as a literal file name | ParameterSets.Items.Tests.ps1:657 should not expand an asterisk in a path as a wildcard and should report FileNotFound | ParameterSets.Items.Tests.ps1:759 Move-Item2 should move the file to a short destination and remove the source | ParameterSets.Items.Tests.ps1:823 Move-Item2 should move a file to the root of the mapped drive | ParameterSets.Items.Tests.ps1:1021 Should move a non-empty folder tree to a new destination | ParameterSets.Items.Tests.ps1:1041 Should rename an item in place on the same volume | ParameterSets.Items.Tests.ps1:1060 Should replace an existing folder outright with -Force, not merge it, and write no error","","" +"items","Remove-Item2","__AllParameterSets","Partly","Closed","7","ParameterSets.Items.Tests.ps1:604 should treat a path with brackets in the name as a literal file name | ParameterSets.Items.Tests.ps1:657 should not expand an asterisk in a path as a wildcard and should report FileNotFound | ParameterSets.Items.Tests.ps1:1085 Should do nothing when -Path is omitted | ParameterSets.Items.Tests.ps1:1105 Should remove the file through the alias | ParameterSets.Items.Tests.ps1:1116 Should write FileNotFound with its category and target for a path that does not exist | ParameterSets.Items.Tests.ps1:1127 Should write nothing for a successful removal without -PassThru | ParameterSets.Items.Tests.ps1:1143 Should write DeleteError with category NotSpecified when a Deny ACE refuses the delete","","" +"items","Test-Path2","__AllParameterSets","Open","Closed","4","ParameterSets.Items.Tests.ps1:807 Test-Path2 should find the root of the mapped drive as a container | ParameterSets.Items.Tests.ps1:1163 Should accept several piped objects and bind -PathType by property name for each | ParameterSets.Items.Tests.ps1:1177 Should find an item whose name contains brackets | ParameterSets.Items.Tests.ps1:1185 Should find an item through a UNC path of the administrative share","","" +"items","Get-FileHash2","__AllParameterSets","Partly","Partly","2","ParameterSets.Items.Tests.ps1:737 Get-FileHash2 should hash the file and match an independent SHA256 read through AlphaFS | ParameterSets.Items.Tests.ps1:1194 Should hash with the algorithm bound by property name from the pipeline","Limit","Limit: the success path of the ownership retry (elevated only; the set-up of a DACL and an owner that gives it is not buildable in a sandbox)" +"items","Get-DiskSpace","__AllParameterSets","Partly","Closed","2","ParameterSets.Items.Tests.ps1:1210 Should report free-space values close to an independent .NET read for the system drive | ParameterSets.Items.Tests.ps1:1228 Should write the validation error for a bad -DriveLetter with its category, and no target object","","" +"items","New-NTFSHardLink","__AllParameterSets","Partly","Closed","3","ParameterSets.Items.Tests.ps1:1245 Should bind -Path and -Target positionally | ParameterSets.Items.Tests.ps1:1261 Should accept a FileInfo object piped by value for -Target | ParameterSets.Items.Tests.ps1:1275 -PassThru should write GetHardLinkError with category ReadError and the link path as target on a network share","","" +"items","Get-NTFSHardLink","__AllParameterSets","Partly","Partly","1","ParameterSets.Items.Tests.ps1:1295 Should write GetHardLinkError with category ReadError for a file on a network share","Limit","Limit: the UnauthorizedAccessException handler is structurally unreachable (Windows lists hard-link names without opening the file)" +"items","New-NTFSSymbolicLink","__AllParameterSets","Partly","Closed","2","ParameterSets.Items.Tests.ps1:1309 Should bind -Path and -Target positionally | ParameterSets.Items.Tests.ps1:1323 Should set LinkType and the resolved target for a link to a folder","","" +"items","Enable-Privileges","__AllParameterSets","Partly","Closed","1","ParameterSets.Items.Tests.ps1:1344 Should report Enabled for TakeOwnership, Restore, Backup, and Security in -PassThru, matching whoami /priv","","" +"items","Disable-Privileges","__AllParameterSets","Partly","Partly","1","ParameterSets.Items.Tests.ps1:1364 Should report Disabled for TakeOwnership, Restore, and Backup in -PassThru after a successful disable","Limit","Limit: the warning when a privilege cannot be disabled needs a race with another command" +"items","Get-Privileges","__AllParameterSets","Partly","Partly","0","","Limit","Limit: the Removed value of PrivilegeState is unreachable: a removed privilege leaves the list of the token instead of showing the value (a probe before and after confirmed it)" diff --git a/Tests/Coverage/Quality-Gate-Test-Round-2026-10-20.md b/Tests/Coverage/Quality-Gate-Test-Round-2026-10-20.md new file mode 100644 index 0000000..571032e --- /dev/null +++ b/Tests/Coverage/Quality-Gate-Test-Round-2026-10-20.md @@ -0,0 +1,238 @@ +# Quality gate, the tests-only round, 2026-10-20 + +The maintainer decided on 2026-10-19 (Decision 21, "Closing the gate") that the +plain test gaps of the quality gate are closed in a round of tests only, until +2026-11-02, and that this report lists what is left. The gaps are the items that +the ParameterSets table of the +[report of Handoff 5](Quality-Gate-Unknowns-2026-10-10.md) calls `budget`: plain +test work that needs no decision and no lab, 68 mentions in 51 of its 64 +parameter sets. Nothing in the shipped module, the cmdlet pages, or the CI +changed; rc8 stays the candidate of the stable build. The round closes the plain +gaps; it does not close the gate (#34 and the check of the Gallery API key are +open), and a closed row says that its plain gaps are tested, not that the +cmdlet is. + +## Result in short + +- **The tests.** 89 new `It` blocks, which run as 121 cases per configuration, + in the four `ParameterSets.*.Tests.ps1` files. The suite goes from 1,494 to + 1,615 cases per configuration, with no failure in Windows PowerShell 5.1 and + PowerShell 7, elevated and as a basic user, on the host and on the CI runner. +- **The parameter sets.** 38 of 64 are closed (7 before). 31 sets changed to + closed, among them the one that was open (`Test-Path2`). The one set that had + no judgement, `Set-NTFSSecurityDescriptor`, is now partly closed. 26 sets have + something left, and every leftover is named in the table below: 16 are limits + (no test can reach it, or it needs a lab or a remote server), 7 wait for the + maintainer, and 3 would pin an inconsistency. +- **No defect.** None of the 121 new cases found a behavior of a cmdlet that + contradicts its page or common sense. Four expectations of the implementers + were wrong at first and were corrected in the tests, and my own helper script + had a defect (see "What the tests showed"). +- **No product change, so no new prerelease.** The package of rc8 is the one + that the labs accepted on 2026-10-20. A defect that a test had found would + have needed the maintainer's decision and rc9. + +## The tests + +Four implementer agents each worked in a worktree of `master` at `715c254` and +changed one test file, so that the batches could not conflict. Each had the +same brief: pin observable behavior through the public surface, read state back +with an oracle that is independent of the cmdlet under test (.NET `Get-Acl` and +`FileSecurity`, SDDL, `[IO.File]`, `Get-Item`, `icacls`), assert concrete +values, build every fixture in the sandbox, make sure that at least one of the +four configurations runs each test, and leave out what the reason column of its +worklist calls `would-pin`, `Limit`, `MaintainerDecision`, or `lab`. A defect +was to be reported, not fixed. + +| Group | Test file | New `It` | New cases | Sets closed | Negative check | +| --- | --- | ---: | ---: | ---: | --- | +| access | `ParameterSets.Access.Tests.ps1` | 20 | 32 | 6 | 20 flipped, the 32 cases failed | +| audit | `ParameterSets.Audit.Tests.ps1` | 9 | 10 | 4 | 9 flipped, the 10 cases failed | +| inheritance | `ParameterSets.Inheritance.Tests.ps1` | 15 | 25 | 11 | 15 flipped, 25 failed elevated, 16 as a basic user (9 skip) | +| items | `ParameterSets.Items.Tests.ps1` | 45 | 54 | 10 | 45 flipped, 51 failed elevated, 39 as a basic user (12 skip); the other 3 flipped later | +| Total | | 89 | 121 | 31 | | + +The negative check is a copy of the file in which each new `It` has one +expectation flipped (a value, a count, a category, a type). Every flipped case +failed in each configuration where it runs, and no other case failed. In the +items batch, 3 of the 54 cases had not been flipped: the Move-Item2, Get-Item2, +and Remove-Item2 rows of the test for a path with brackets, which share one +`It` with the Copy-Item2 row that was flipped. I flipped them afterwards in a +copy, and all three failed in all four configurations while the Copy-Item2 row +stayed green. So each of the 121 new cases is known to fail when what it pins +changes. + +Each batch also ran three times in all four configurations with identical +results, and `Invoke-ScriptAnalyzer` reports no finding on any of the four +files. The four diffs add 1,781 lines and delete none: no existing test was +changed, renamed, or moved. + +A new case skips where the token lacks what it needs, and runs in the other +configurations. These are the 30 new cases that skip as a basic user, in both +editions: + +| Needs | Cases | Where | +| --- | ---: | --- | +| the Security privilege (the SACL, audit entries) | 17 | audit 9, inheritance 8 | +| a drive mapping | 5 | items | +| the administrative share | 3 | items | +| the symbolic-link privilege | 2 | items | +| an elevated token, for the privilege states | 2 | items | +| an owner other than the user (a basic user owns the new items) | 1 | inheritance | + +One new case skips when elevated: the `Get-NTFSOrphanedAudit` error without the +Security privilege runs only as a basic user. So each new case runs in at least +two configurations, and none skips in all four. The skipped counts moved +accordingly: elevated 26 to 27 (Windows PowerShell) and 57 to 58 (PowerShell +7); as a basic user 291 to 321 and 322 to 352. + +## The suite + +The suite is the CI test step, run in four configurations on the host (Windows +Server 2025), against a Release build of the branch. The counts are those of the +NUnit result files of the run, and the new cases per file are the differences of +the counts per test file between this run and the last run before the round +(`Measure-RoundSuiteDelta.ps1`, in the evidence). + +| Configuration | Executed | Skipped | Failed | Total | +| --- | ---: | ---: | ---: | ---: | +| Windows PowerShell 5.1, elevated | 1,588 | 27 | 0 | 1,615 | +| Windows PowerShell 5.1, basic user | 1,294 | 321 | 0 | 1,615 | +| PowerShell 7, elevated | 1,557 | 58 | 0 | 1,615 | +| PowerShell 7, basic user | 1,263 | 352 | 0 | 1,615 | + +`master` has 1,494 cases per configuration, with 26, 291, 57, and 322 skipped. +An earlier run with the audit, access, and items batches (1,590 cases) was also +green in all four configurations. + +The CI of the pull request (#129) ran the same steps on a GitHub runner +(`windows-2025`, run 39551726767, 14 minutes). Its four configurations gave the +counts of the table above with no failure, and each of the 29 test files has +the same number of executed and skipped cases as in the local run. So the new +cases that need a drive mapping, the administrative share, or a symbolic link +ran there too: the runner user is a local administrator. + +## What is left + +Every parameter set that is not closed, with the kind of what is left. The +table of all 64 sets, with the status before and after, the new tests of each +(file, line, and name), and what is left, is +[the CSV of this round](Quality-Gate-Test-Round-2026-10-20-ParameterSets.csv). + +| Kind | Cmdlet / parameter set | What is left | +| --- | --- | --- | +| MaintainerDecision | `Add-NTFSAccess` / SDSimple | the terminating error of a failing change (a deny entry without rights) is not pinned | +| MaintainerDecision | `Add-NTFSAccess` / SDComplex | the terminating error of a failing descriptor is not pinned | +| MaintainerDecision | `Remove-NTFSAccess` / SDSimple | the terminating error of a failing removal is not pinned | +| MaintainerDecision | `Remove-NTFSAccess` / SDComplex | the terminating error of a failing removal is not pinned | +| MaintainerDecision | `Add-NTFSAudit` / SDSimple | exceptions from the change itself (open item 1 of Handoff 5) | +| MaintainerDecision | `Add-NTFSAudit` / SDComplex | exceptions from the change itself (open item 1 of Handoff 5) | +| MaintainerDecision | `Remove-NTFSAudit` / SDComplex | exceptions from the change itself (open item 1 of Handoff 5) | +| would-pin | `Get-NTFSAccess` / Path | the category of the `ReadSecurityError` after a failed ownership retry (WriteError there, OpenError from the generic catch) is not asserted, so as not to cement the inconsistency | +| would-pin | `Get-NTFSOrphanedAccess` / Path | the choice of a warning, not an error, for a read failure other than access denied | +| would-pin | `Set-NTFSSecurityDescriptor` / all sets | positional binding at Position 2 (the sibling cmdlets use Position 1), an undecided inconsistency | +| Limit | `Get-NTFSAccess` / SD | no failing descriptor is tested for this set (no per-item handler) | +| Limit | `Get-NTFSSimpleAccess` / SD | no failing descriptor is tested for this set (no per-item handler) | +| Limit | `Get-NTFSEffectiveAccess` / Path | the `ReadEffectivePermissionError` handler is unreachable (the library records the failure); remote and group-based results need the lab (the live tests cover them) | +| Limit | `Get-NTFSEffectiveAccess` / SecurityDescriptor | a mix of a failing and a succeeding descriptor in one call | +| Limit | `Add-NTFSAudit` / PathComplex | the owner retry after access denied needs a lab or a remote server | +| Limit | `Remove-NTFSAudit` / PathComplex | the owner retry after access denied needs a lab or a remote server | +| Limit | `Clear-NTFSAudit` / Path | the owner retry after access denied needs a lab or a remote server | +| Limit | `Get-NTFSAudit` / Path | the PermissionDenied category branch of `ReadSecurityError` | +| Limit | `Disable-NTFSAuditInheritance` / Path | a successful ownership retry or `RestoreOwnerError` of a SACL write needs a lab or a remote server | +| Limit | `Enable-NTFSAuditInheritance` / Path | a successful ownership retry or `RestoreOwnerError` of a SACL write needs a lab or a remote server | +| Limit | `Get-NTFSInheritance` / Path | a successful ownership-retry read needs a lab or a remote server | +| Limit | `Get-NTFSSecurityDescriptor` / all sets | a successful ownership-retry read needs a lab or a remote server | +| Limit | `Get-FileHash2` / all sets | the success path of the ownership retry (elevated only; the DACL and owner that give it can't be built in a sandbox) | +| Limit | `Get-NTFSHardLink` / all sets | the `UnauthorizedAccessException` handler is structurally unreachable (Windows lists hard-link names without opening the file) | +| Limit | `Disable-Privileges` / all sets | the warning when a privilege cannot be disabled needs a race with another command | +| Limit | `Get-Privileges` / all sets | the `Removed` value of `PrivilegeState` is unreachable: a removed privilege leaves the list of the token instead of showing the value (a probe before and after confirmed it) | + +The 7 decisions and the 3 pins are the maintainer's, as in the report of +Handoff 5; nothing here decides them. The dot patterns of +`Get-ChildItem2 -Filter` are also still his (the row is closed because its +plain gaps are tested, not because the patterns are decided). The "ownership +retry" limits are the paths that run when a cmdlet has to take an owner to read +or write a security descriptor. The report of Handoff 5 explains why no unit +test reaches them (and lists `AUDIT-OWNER-RETRY` as open), so they stay +explained, not tested. + +## What the tests showed + +- **No defect.** The four implementers found no behavior that contradicts the + cmdlet page or common sense, and their `defects` folders are empty. +- **`Get-Privileges` and `Removed`.** The `Removed` value of `PrivilegeState` + can't appear in the output: after a privilege is removed from a token, the + token's list no longer has it. A probe before and after showed it. The row + is a limit, not a test gap. +- **`Move-Item2 -Force` onto a non-empty folder replaces it**, with no merge and + no error. `Docs/Cmdlets/Move-Item2.md` documents the replace, and the new test + pins it. +- **Four expectations were wrong at first and were corrected in the tests**, + none of them a defect: + - `Get-NTFSOrphanedAudit` without the Security privilege reports the category + `OpenError`, as `Get-NTFSAudit` does, not `PermissionDenied` (that category + is the limit above). + - After `Disable-NTFSAuditInheritance` without `-RemoveInheritedAuditRules` + and a plain enable, an item has two audit entries, not one: the disable + copies the inherited entry into the SACL as an explicit one, and the cmdlet + page says so. + - `-PassThru` of `Copy-Item2` and `Move-Item2` is a `Boolean` that needs a + value, not a switch, as their pages say. + - The access-denied test of `Remove-Item2` needs a deny entry on the parent + folder (`DeleteSubdirectoriesAndFiles`) and one on the file (`Delete`), + because NTFS grants a delete through either. +- **A defect of my helper script, not of the module.** The inheritance + implementer reported that its basic-user runs stalled for about 30 minutes, + twice, and took it for an effect of the load of four agents at once; another + group's job stuck the same way. The cause was the helper that runs the + configurations of a batch: a test-name filter with spaces did not survive the + nested quotes of `runas`, the child never started, and the helper waited for + its whole timeout. The filter now travels as base64, and the helper gives up + after a minute without a runner. The suite numbers above come from the run of + the four CI configurations, which takes no name filter and did not stall. + +## Checks not run, and limits + +- The new cases ran on Windows Server 2025 only, on the host and on the runner. + The suite of the rc8 acceptance ran on six machine classes, but before these + cases existed, so they have not run on Windows Server 2019 or 2022 or on + Windows 11. +- No lab run: the product bytes did not change. The acceptance of rc8 of + 2026-10-20 stands for them. +- No independent review agent ran on the four batches. I read the diffs of all + four commits (the added lines, the oracles, and the places where a test + touches the system). A review of the 1,781 added lines is still possible + before the merge. +- The mutated copies of the negative checks were deleted. The per-batch results + rest on the reports of the implementers (in the evidence); only the three + bracket rows of the items batch were flipped and run again by me. +- The negative checks flip one expectation of a test; they are not a mutation + test of the product. The mutations of the product were not run again; the + last ones are in `Quality-Gate-Unknowns-2026-10-10-Mutations.csv`. +- The code coverage was not measured again; the numbers of Handoff 5 remain the + last measurement. +- The leftovers marked `Limit` are explained from source and from a probe; an + explanation is not a test. + +## Evidence outside git + +The raw evidence is in the session folder +`C:\Users\install\.copilot\session-state\816cf47d-2f80-4eba-8ac9-41702c49560b\files\qg-unknowns` +(`E`). [The Evidence CSV](Quality-Gate-Test-Round-2026-10-20-Evidence.csv) +lists its files with size and SHA-256; the four worktrees of the implementers +are not in it. The commits of the batches are `7c3de81` (audit), `472003e` +(access), `e32e36a` and `9a2a809` (items), and `404ceca` (inheritance) of the +branch `ai/tests-round-1`. + +| Path in `E` | Content | +| --- | --- | +| `round1\IMPLEMENTER-BRIEF.md`, `round1\worklist-*.md` | the brief of the implementers and the four worklists (the rows with their remaining items, reasons, and key tests) | +| `round1\reports\report-*.md` | the final reports of the four implementers, verbatim, with the counts, the negative checks, and the follow-up on the three unflipped rows of the items batch | +| `round1\\commit-diff.txt`, `source-pin.txt` | the diff of the access, inheritance, and items batches (the audit batch has none) and, for each group, the commit that its worktree started from (`715c254`) | +| `round1\suite\rc8suite-20261020-093109` | the last run of the four CI configurations before the round (1,494 cases per configuration) | +| `round1\suite\round1-three-20261020-141224`, `round1\suite\round1-four-20261020-142909` | the runs with three and with all four batches (1,590 and 1,615 cases), each with the NUnit file, the log, and the summary of the four configurations | +| `round1\suite\delta-four-vs-baseline.txt` | the output of `Measure-RoundSuiteDelta.ps1`: the new cases per file and configuration, and the 30 and 1 that skip | +| `round1\suite\runner-39551726767` | the CI run of #129 on the GitHub runner: the log of the job `Build and test`, the four NUnit files of the artifact `test-results`, and the output of `Measure-RoundSuiteDelta.ps1` for them (GitHub deletes logs and artifacts after its retention period) | +| `round1\tables-draft` | the draft of the ParameterSets CSV | +| `scripts` | `Build-Worklists.ps1`, `Build-RoundTables.ps1` (the CSV of this round), `Invoke-RoundJob.ps1`, `Invoke-Build.ps1`, `Run-FocusedPester.ps1`, `Start-FocusedRuns.ps1` (the helper with the quoting defect, fixed), `Run-FullSuite.ps1`, `Run-CiConfiguration.ps1`, `Measure-RoundSuiteDelta.ps1` | diff --git a/Tests/ParameterSets.Access.Tests.ps1 b/Tests/ParameterSets.Access.Tests.ps1 index 23f2e17..551cc3b 100644 --- a/Tests/ParameterSets.Access.Tests.ps1 +++ b/Tests/ParameterSets.Access.Tests.ps1 @@ -25,6 +25,24 @@ BeforeDiscovery { @{ Name = 'Get-NTFSEffectiveAccess'; Types = @('Security2.FileSystemAccessRule2') } @{ Name = 'Get-NTFSSimpleAccess'; Types = @('Security2.SimpleFileSystemAccessRule') } ) + + # The 13 named -AppliesTo scopes, each as the ACE flags that PermissionScopes.Tests.ps1 also uses, so that an + # entry a security descriptor set added can be read back with .NET independently of the cmdlet. + $sdScopeCases = @( + @{ Name = 'ThisFolderOnly'; Inheritance = 'None'; Propagation = 'None' } + @{ Name = 'ThisFolderSubfoldersAndFiles'; Inheritance = 'ContainerInherit, ObjectInherit'; Propagation = 'None' } + @{ Name = 'ThisFolderAndSubfolders'; Inheritance = 'ContainerInherit'; Propagation = 'None' } + @{ Name = 'ThisFolderAndFiles'; Inheritance = 'ObjectInherit'; Propagation = 'None' } + @{ Name = 'SubfoldersAndFilesOnly'; Inheritance = 'ContainerInherit, ObjectInherit'; Propagation = 'InheritOnly' } + @{ Name = 'SubfoldersOnly'; Inheritance = 'ContainerInherit'; Propagation = 'InheritOnly' } + @{ Name = 'FilesOnly'; Inheritance = 'ObjectInherit'; Propagation = 'InheritOnly' } + @{ Name = 'ThisFolderSubfoldersAndFilesOneLevel'; Inheritance = 'ContainerInherit, ObjectInherit'; Propagation = 'NoPropagateInherit' } + @{ Name = 'ThisFolderAndSubfoldersOneLevel'; Inheritance = 'ContainerInherit'; Propagation = 'NoPropagateInherit' } + @{ Name = 'ThisFolderAndFilesOneLevel'; Inheritance = 'ObjectInherit'; Propagation = 'NoPropagateInherit' } + @{ Name = 'SubfoldersAndFilesOnlyOneLevel'; Inheritance = 'ContainerInherit, ObjectInherit'; Propagation = 'InheritOnly, NoPropagateInherit' } + @{ Name = 'SubfoldersOnlyOneLevel'; Inheritance = 'ContainerInherit'; Propagation = 'InheritOnly, NoPropagateInherit' } + @{ Name = 'FilesOnlyOneLevel'; Inheritance = 'ObjectInherit'; Propagation = 'InheritOnly, NoPropagateInherit' } + ) } BeforeAll { @@ -265,6 +283,135 @@ Describe 'Add-NTFSAccess' { $guestsRule[0].FileSystemRights.HasFlag([System.Security.AccessControl.FileSystemRights]::ReadData) | Should -BeTrue } } + + Context 'PathComplex parameter set, additional state assertions' { + It 'Should add an explicit allow entry for each of several accounts bound positionally, with the default scope' { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'AddPositionalSeveral' -Directory + + Add-NTFSAccess $folder @('S-1-1-0', 'S-1-5-32-546') 'ReadData' -ErrorAction Stop + + foreach ($account in 'S-1-1-0', 'S-1-5-32-546') { + $rule = @((Get-Acl -LiteralPath $folder).GetAccessRules($true, $false, $sidType) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq $account }) + $rule | Should -HaveCount 1 + $rule[0].AccessControlType | Should -Be ([System.Security.AccessControl.AccessControlType]::Allow) + $rule[0].FileSystemRights.HasFlag([System.Security.AccessControl.FileSystemRights]::ReadData) | Should -BeTrue + $rule[0].InheritanceFlags | Should -Be ([System.Security.AccessControl.InheritanceFlags] 'ContainerInherit, ObjectInherit') + $rule[0].PropagationFlags | Should -Be ([System.Security.AccessControl.PropagationFlags]::None) + } + } + + # Only an Allow entry gets Synchronize added (the page); a Deny entry written through -Path must carry + # exactly the requested rights. + It 'Should write a Deny entry through -Path with exactly the requested rights and no Synchronize' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'AddDenySynchronize' + + Add-NTFSAccess -Path $file -Account 'S-1-1-0' -AccessRights ReadData -AccessType Deny -ErrorAction Stop + + $rule = @((Get-Acl -LiteralPath $file).GetAccessRules($true, $false, $sidType) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-1-0' }) + $rule | Should -HaveCount 1 + $rule[0].AccessControlType | Should -Be ([System.Security.AccessControl.AccessControlType]::Deny) + $rule[0].FileSystemRights | Should -Be ([System.Security.AccessControl.FileSystemRights]::ReadData) + } + } + + Context 'PathSimple parameter set, Deny entries and a denied write' { + BeforeAll { + # So that an elevated session meets the same denial as a basic user (see PathErrors.Tests.ps1). + $privateData['EnablePrivileges'] = $false + } + + AfterAll { + $privateData['EnablePrivileges'] = $enablePrivileges + } + + It 'Should report AddAceError by category and target for a denied write scoped by -AppliesTo, and leave the item unchanged' { + $blocked = New-TestSandboxItem -Sandbox $sandbox -Name 'AddSimpleBlocked' + Block-TestWritePermission -Sandbox $sandbox -Path $blocked + $before = (Get-Acl -LiteralPath $blocked).Sddl + + Add-NTFSAccess -Path $blocked -Account 'S-1-1-0' -AccessRights ReadData -AppliesTo ThisFolderOnly -ErrorVariable addErrors -ErrorAction SilentlyContinue + + $addErrors | Should -HaveCount 1 + $addErrors[0].FullyQualifiedErrorId | Should -BeLike 'AddAceError,*' + $addErrors[0].CategoryInfo.Category | Should -Be 'WriteError' + $addErrors[0].TargetObject | Should -Be $blocked + (Get-Acl -LiteralPath $blocked).Sddl | Should -BeExactly $before + } + + # .NET refuses to build a deny entry without rights for this parameter set too; the write-denial code path + # above is shared with PathComplex (Access.Tests.ps1:740), so only the categorization needs its own case here. + It 'Should write an AddAceError for a Deny entry without rights through the PathSimple set, and change nothing' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'AddSimpleDenyNone' + $before = (Get-Acl -LiteralPath $file).Sddl + + $result = @(Add-NTFSAccess -Path $file -Account 'S-1-1-0' -AccessRights None -AccessType Deny -AppliesTo ThisFolderOnly -PassThru -ErrorVariable addErrors -ErrorAction SilentlyContinue) + + $result | Should -BeNullOrEmpty + $addErrors | Should -HaveCount 1 + $addErrors[0].FullyQualifiedErrorId | Should -BeLike 'AddAceError,*' + $addErrors[0].CategoryInfo.Category | Should -Be 'WriteError' + (Get-Acl -LiteralPath $file).Sddl | Should -BeExactly $before + } + + It 'Should write a Deny entry scoped by -AppliesTo with exactly the requested rights and no Synchronize' { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'AddSimpleDenyScoped' -Directory + + Add-NTFSAccess -Path $folder -Account 'S-1-1-0' -AccessRights ReadData -AccessType Deny -AppliesTo ThisFolderAndSubfolders -ErrorAction Stop + + $rule = @((Get-Acl -LiteralPath $folder).GetAccessRules($true, $false, $sidType) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-1-0' }) + $rule | Should -HaveCount 1 + $rule[0].AccessControlType | Should -Be ([System.Security.AccessControl.AccessControlType]::Deny) + $rule[0].FileSystemRights | Should -Be ([System.Security.AccessControl.FileSystemRights]::ReadData) + $rule[0].InheritanceFlags | Should -Be ([System.Security.AccessControl.InheritanceFlags]::ContainerInherit) + $rule[0].PropagationFlags | Should -Be ([System.Security.AccessControl.PropagationFlags]::None) + } + } + + Context 'SDSimple parameter set' { + It 'Should add an entry with the scope to each of several descriptors bound through the pipeline, read back with .NET, and leave the items unchanged on disk' -ForEach $sdScopeCases { + $first = New-TestSandboxItem -Sandbox $sandbox -Name 'AddSdScopeFirst' -Directory + $second = New-TestSandboxItem -Sandbox $sandbox -Name 'AddSdScopeSecond' -Directory + $before = foreach ($folder in $first, $second) { (Get-Acl -LiteralPath $folder).GetSecurityDescriptorSddlForm('Access') } + $descriptors = @(Get-NTFSSecurityDescriptor -Path $first, $second -ErrorAction Stop) + + $descriptors | Add-NTFSAccess -Account 'S-1-5-32-546' -AccessRights ReadData -AppliesTo $Name -ErrorAction Stop + + for ($index = 0; $index -lt 2; $index++) { + $rule = @($descriptors[$index].SecurityDescriptor.GetAccessRules($true, $false, $sidType) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-5-32-546' }) + $rule | Should -HaveCount 1 + $rule[0].AccessControlType | Should -Be ([System.Security.AccessControl.AccessControlType]::Allow) + $rule[0].InheritanceFlags | Should -Be ([System.Security.AccessControl.InheritanceFlags] $Inheritance) + $rule[0].PropagationFlags | Should -Be ([System.Security.AccessControl.PropagationFlags] $Propagation) + } + (Get-Acl -LiteralPath $first).GetSecurityDescriptorSddlForm('Access') | Should -BeExactly $before[0] + (Get-Acl -LiteralPath $second).GetSecurityDescriptorSddlForm('Access') | Should -BeExactly $before[1] + } + } + + Context 'SDComplex parameter set with several descriptors' { + It 'Should add the entry to each of several descriptors bound through the pipeline, read back with .NET, and leave the items unchanged on disk' { + $first = New-TestSandboxItem -Sandbox $sandbox -Name 'AddSdComplexPipeFirst' + $second = New-TestSandboxItem -Sandbox $sandbox -Name 'AddSdComplexPipeSecond' + $before = foreach ($file in $first, $second) { (Get-Acl -LiteralPath $file).Sddl } + $descriptors = @(Get-NTFSSecurityDescriptor -Path $first, $second -ErrorAction Stop) + + $descriptors | Add-NTFSAccess -Account 'S-1-5-21-1-2-3-9501' -AccessRights ReadData, WriteData -InheritanceFlags None -PropagationFlags None -ErrorAction Stop + + for ($index = 0; $index -lt 2; $index++) { + $rule = @($descriptors[$index].SecurityDescriptor.GetAccessRules($true, $false, $sidType) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-5-21-1-2-3-9501' }) + $rule | Should -HaveCount 1 + $rule[0].FileSystemRights.HasFlag([System.Security.AccessControl.FileSystemRights]::ReadData) | Should -BeTrue + $rule[0].FileSystemRights.HasFlag([System.Security.AccessControl.FileSystemRights]::WriteData) | Should -BeTrue + } + (Get-Acl -LiteralPath $first).Sddl | Should -BeExactly $before[0] + (Get-Acl -LiteralPath $second).Sddl | Should -BeExactly $before[1] + } + } } Describe 'Remove-NTFSAccess' { @@ -387,6 +534,202 @@ Describe 'Remove-NTFSAccess' { $guestsRule[0].FileSystemRights.HasFlag([System.Security.AccessControl.FileSystemRights]::Delete) | Should -BeTrue } } + + Context 'PathComplex parameter set, additional state assertions' { + It 'Should return exactly the surviving entries with -PassThru for each of several paths bound through the pipeline in one call' { + $keeper = 'S-1-1-0' + $target = 'S-1-5-32-546' + $folders = @( + New-TestSandboxItem -Sandbox $sandbox -Name 'RemovePassThruCountFirst' -Directory + New-TestSandboxItem -Sandbox $sandbox -Name 'RemovePassThruCountSecond' -Directory + ) + foreach ($folder in $folders) { + Add-NTFSAccess -Path $folder -Account $keeper -AccessRights ReadData -ErrorAction Stop + Add-NTFSAccess -Path $folder -Account $target -AccessRights ReadData -ErrorAction Stop + } + # The independent oracle: every rule of the item that isn't the one being removed. + $expected = foreach ($folder in $folders) { + @((Get-Acl -LiteralPath $folder).GetAccessRules($true, $true, $sidType) | + Where-Object -FilterScript { $_.IdentityReference.Value -ne $target }).Count + } + + $result = @(Get-Item2 -Path $folders | Remove-NTFSAccess -Account $target -AccessRights ReadData -PassThru -ErrorAction Stop) + + for ($index = 0; $index -lt $folders.Count; $index++) { + $folderResult = @($result | Where-Object -FilterScript { $_.FullName -eq $folders[$index] }) + $folderResult | Should -HaveCount $expected[$index] + $folderResult | ForEach-Object -Process { $_ | Should -BeOfType ([Security2.FileSystemAccessRule2]) } + @($folderResult | Where-Object -FilterScript { $_.Account.Sid -eq $target }) | Should -BeNullOrEmpty + @($folderResult | Where-Object -FilterScript { $_.Account.Sid -eq $keeper }) | Should -HaveCount 1 + } + } + + It 'Should remove the entries of several accounts bound positionally, leaving another account untouched' { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'RemovePositionalSeveral' -Directory + Add-NTFSAccess -Path $folder -Account 'S-1-1-0' -AccessRights ReadData -ErrorAction Stop + Add-NTFSAccess -Path $folder -Account 'S-1-5-32-546' -AccessRights ReadData -ErrorAction Stop + Add-NTFSAccess -Path $folder -Account 'S-1-5-21-1-2-3-9701' -AccessRights ReadData -ErrorAction Stop + + Remove-NTFSAccess $folder @('S-1-1-0', 'S-1-5-32-546') 'ReadData' -ErrorAction Stop + + $rules = (Get-Acl -LiteralPath $folder).GetAccessRules($true, $false, $sidType) + @($rules | Where-Object -FilterScript { $_.IdentityReference.Value -in 'S-1-1-0', 'S-1-5-32-546' }) | Should -BeNullOrEmpty + @($rules | Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-5-21-1-2-3-9701' }) | Should -HaveCount 1 + } + } + + Context 'PathSimple parameter set, a Deny entry and pipeline errors' { + BeforeAll { + # So that an elevated session meets the same denial as a basic user (see PathErrors.Tests.ps1). Harmless + # for the Deny-reduction test below, which never hits a permission denial. + $privateData['EnablePrivileges'] = $false + } + + AfterAll { + $privateData['EnablePrivileges'] = $enablePrivileges + } + + It 'Should take only the requested rights from a Deny entry scoped by -AppliesTo without -RemoveSpecific, and keep its flags' { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveSimpleDenyReduce' -Directory + Add-NTFSAccess -Path $folder -Account 'S-1-5-21-1-2-3-9702' -AccessRights Modify -AccessType Deny -AppliesTo ThisFolderAndSubfolders -ErrorAction Stop + + Remove-NTFSAccess -Path $folder -Account 'S-1-5-21-1-2-3-9702' -AccessRights ReadData -AccessType Deny -AppliesTo ThisFolderAndSubfolders -ErrorAction Stop + + $rule = @((Get-Acl -LiteralPath $folder).GetAccessRules($true, $false, $sidType) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-5-21-1-2-3-9702' }) + $rule | Should -HaveCount 1 + $rule[0].AccessControlType | Should -Be ([System.Security.AccessControl.AccessControlType]::Deny) + $rule[0].FileSystemRights.HasFlag([System.Security.AccessControl.FileSystemRights]::ReadData) | Should -BeFalse + $rule[0].FileSystemRights.HasFlag([System.Security.AccessControl.FileSystemRights]::WriteData) | Should -BeTrue + $rule[0].InheritanceFlags | Should -Be ([System.Security.AccessControl.InheritanceFlags]::ContainerInherit) + $rule[0].PropagationFlags | Should -Be ([System.Security.AccessControl.PropagationFlags]::None) + } + + It 'Should report a missing path''s and a denied path''s errors by category and target, and still scope the surviving paths'' entries, all bound through the pipeline' { + $missing = Join-Path -Path $sandbox -ChildPath ('RemoveSimpleMissing-{0}' -f [guid]::NewGuid().ToString('N').Substring(0, 8)) + Assert-TestSandboxPath -Sandbox $sandbox -Path $missing + $blocked = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveSimpleBlocked' -Directory + $survivor1 = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveSimpleSurvivor1' -Directory + $survivor2 = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveSimpleSurvivor2' -Directory + foreach ($folder in $blocked, $survivor1, $survivor2) { + Add-NTFSAccess -Path $folder -Account 'S-1-1-0' -AccessRights ReadData -AppliesTo ThisFolderOnly -ErrorAction Stop + } + Block-TestWritePermission -Sandbox $sandbox -Path $blocked + $before = (Get-Acl -LiteralPath $blocked).Sddl + # Property-name binding of -Path, like the piped objects of the Add-NTFSAccess pipeline tests; -Account, + # -AccessRights and -AppliesTo come from the call itself and apply to every piped object alike. + $paths = foreach ($value in $missing, $blocked, $survivor1, $survivor2) { [pscustomobject]@{ Path = $value } } + + $result = @($paths | Remove-NTFSAccess -Account 'S-1-1-0' -AccessRights ReadData -AppliesTo ThisFolderOnly -PassThru -ErrorVariable removeErrors -ErrorAction SilentlyContinue) + + $removeErrors | Should -HaveCount 2 + $missingError = @($removeErrors | Where-Object -FilterScript { $_.TargetObject -eq $missing }) + $missingError | Should -HaveCount 1 + $missingError[0].FullyQualifiedErrorId | Should -BeLike 'ReadFileError,*' + $missingError[0].CategoryInfo.Category | Should -Be 'OpenError' + $blockedError = @($removeErrors | Where-Object -FilterScript { $_.TargetObject -eq $blocked }) + $blockedError | Should -HaveCount 1 + $blockedError[0].FullyQualifiedErrorId | Should -BeLike 'RemoveAceError,*' + $blockedError[0].CategoryInfo.Category | Should -Be 'WriteError' + (Get-Acl -LiteralPath $blocked).Sddl | Should -BeExactly $before + foreach ($survivor in $survivor1, $survivor2) { + @((Get-Acl -LiteralPath $survivor).GetAccessRules($true, $false, $sidType) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-1-0' }) | Should -BeNullOrEmpty + @($result | Where-Object -FilterScript { $_.FullName -eq $survivor }) | Should -Not -BeNullOrEmpty + } + } + } + + Context 'SDSimple parameter set' { + It 'Should take only the requested rights from an entry scoped by -AppliesTo on a descriptor without -RemoveSpecific, keep its flags, and leave the item on disk unchanged until Set-NTFSSecurityDescriptor' { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveSdSimpleReduce' -Directory + Add-NTFSAccess -Path $folder -Account 'S-1-5-21-1-2-3-9602' -AccessRights Modify -AppliesTo ThisFolderAndSubfolders -ErrorAction Stop + $before = (Get-Acl -LiteralPath $folder).Sddl + $sd = Get-NTFSSecurityDescriptor -Path $folder -ErrorAction Stop + + Remove-NTFSAccess -SecurityDescriptor $sd -Account 'S-1-5-21-1-2-3-9602' -AccessRights ReadData -AppliesTo ThisFolderAndSubfolders -ErrorAction Stop + + $rule = @($sd.SecurityDescriptor.GetAccessRules($true, $false, $sidType) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-5-21-1-2-3-9602' }) + $rule | Should -HaveCount 1 + $rule[0].FileSystemRights.HasFlag([System.Security.AccessControl.FileSystemRights]::ReadData) | Should -BeFalse + $rule[0].FileSystemRights.HasFlag([System.Security.AccessControl.FileSystemRights]::WriteData) | Should -BeTrue + $rule[0].InheritanceFlags | Should -Be ([System.Security.AccessControl.InheritanceFlags]::ContainerInherit) + $rule[0].PropagationFlags | Should -Be ([System.Security.AccessControl.PropagationFlags]::None) + (Get-Acl -LiteralPath $folder).Sddl | Should -BeExactly $before + + Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -ErrorAction Stop + + $onDisk = @((Get-Acl -LiteralPath $folder).GetAccessRules($true, $false, $sidType) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-5-21-1-2-3-9602' }) + $onDisk | Should -HaveCount 1 + $onDisk[0].FileSystemRights.HasFlag([System.Security.AccessControl.FileSystemRights]::ReadData) | Should -BeFalse + } + + It 'Should remove the matching entry from each of several descriptors bound through the pipeline for the Simple parameter set, leaving the items unchanged on disk' { + $folders = @( + New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveSdSimplePipeFirst' -Directory + New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveSdSimplePipeSecond' -Directory + ) + foreach ($folder in $folders) { + Add-NTFSAccess -Path $folder -Account 'S-1-5-21-1-2-3-9603' -AccessRights ReadData -AppliesTo ThisFolderOnly -ErrorAction Stop + } + $descriptors = @(Get-NTFSSecurityDescriptor -Path $folders -ErrorAction Stop) + + $descriptors | Remove-NTFSAccess -Account 'S-1-5-21-1-2-3-9603' -AccessRights ReadData -AppliesTo ThisFolderOnly -RemoveSpecific -ErrorAction Stop + + foreach ($sd in $descriptors) { + @($sd.SecurityDescriptor.GetAccessRules($true, $false, $sidType) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-5-21-1-2-3-9603' }) | Should -BeNullOrEmpty + } + foreach ($folder in $folders) { + @((Get-Acl -LiteralPath $folder).GetAccessRules($true, $false, $sidType) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-5-21-1-2-3-9603' }) | Should -HaveCount 1 + } + } + } + + Context 'SDComplex parameter set' { + It 'Should remove an on-disk entry from a descriptor in memory only, and write the change through only when Set-NTFSSecurityDescriptor runs' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveSdWriteThrough' + Add-NTFSAccess -Path $file -Account 'S-1-5-21-1-2-3-9601' -AccessRights ReadData -ErrorAction Stop + $sd = Get-NTFSSecurityDescriptor -Path $file -ErrorAction Stop + + Remove-NTFSAccess -SecurityDescriptor $sd -Account 'S-1-5-21-1-2-3-9601' -AccessRights ReadData -ErrorAction Stop + + @($sd.SecurityDescriptor.GetAccessRules($true, $false, $sidType) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-5-21-1-2-3-9601' }) | Should -BeNullOrEmpty + @((Get-Acl -LiteralPath $file).GetAccessRules($true, $false, $sidType) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-5-21-1-2-3-9601' }) | Should -HaveCount 1 + + Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -ErrorAction Stop + + @((Get-Acl -LiteralPath $file).GetAccessRules($true, $false, $sidType) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-5-21-1-2-3-9601' }) | Should -BeNullOrEmpty + } + + It 'Should remove the matching entry from each of several descriptors bound through the pipeline for the Complex parameter set, leaving the items unchanged on disk' { + $files = @( + New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveSdComplexPipeFirst' + New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveSdComplexPipeSecond' + ) + foreach ($file in $files) { + Add-NTFSAccess -Path $file -Account 'S-1-5-21-1-2-3-9604' -AccessRights ReadData -ErrorAction Stop + } + $descriptors = @(Get-NTFSSecurityDescriptor -Path $files -ErrorAction Stop) + + $descriptors | Remove-NTFSAccess -Account 'S-1-5-21-1-2-3-9604' -AccessRights ReadData -ErrorAction Stop + + foreach ($sd in $descriptors) { + @($sd.SecurityDescriptor.GetAccessRules($true, $false, $sidType) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-5-21-1-2-3-9604' }) | Should -BeNullOrEmpty + } + foreach ($file in $files) { + @((Get-Acl -LiteralPath $file).GetAccessRules($true, $false, $sidType) | + Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-5-21-1-2-3-9604' }) | Should -HaveCount 1 + } + } + } } Describe 'Clear-NTFSAccess' { @@ -518,6 +861,29 @@ Describe 'Get-NTFSAccess' { } } + Context 'Path parameter set without -Path' { + It 'Should return the entries of the current location as actual Security2.FileSystemAccessRule2 objects when -Path is omitted' { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'GetAccessNoPath' -Directory + Add-NTFSAccess -Path $folder -Account 'S-1-1-0' -AccessRights ReadData -ErrorAction Stop + $expected = @(Get-NTFSAccess -Path $folder -ErrorAction Stop) + + Push-Location -LiteralPath $folder + try { + $result = @(Get-NTFSAccess -ErrorAction Stop) + } + finally { + Pop-Location + } + + $result | Should -HaveCount $expected.Count + $result | ForEach-Object -Process { + $_ | Should -BeOfType ([Security2.FileSystemAccessRule2]) + $_.FullName | Should -Be $folder + } + @($result | Where-Object -FilterScript { $_.Account.Sid -eq 'S-1-1-0' -and -not $_.IsInherited }) | Should -HaveCount 1 + } + } + Context 'SD parameter set' { It 'Should filter an entry added to a descriptor in memory by -Account, partition it with -ExcludeInherited and -ExcludeExplicit, and read two descriptors bound through the pipeline' { $file = New-TestSandboxItem -Sandbox $sandbox -Name 'GetAccessSdFilter' @@ -595,6 +961,36 @@ Describe 'Get-NTFSOrphanedAccess' { } } + Context 'Path parameter set, unreadable folder' { + BeforeAll { + # Forcing EnablePrivileges off reproduces the same denial as a basic user for an elevated session, as + # PathErrors.Tests.ps1 does for the same scenario. + $privateData['EnablePrivileges'] = $false + } + + AfterAll { + $privateData['EnablePrivileges'] = $enablePrivileges + } + + # Unlike Get-NTFSAccess, ReadSecurityError of this cmdlet has one consistent category after a failed + # ownership retry: ReadError (GetOrphanedAccess.cs), so it is safe to pin. + It 'Should write a ReadSecurityError categorized as ReadError after a failed ownership retry, and still return the next path''s orphaned entry' { + $blocked = New-TestSandboxItem -Sandbox $sandbox -Name 'OrphanedUnreadable' + Block-TestReadPermission -Sandbox $sandbox -Path $blocked + $next = New-TestSandboxItem -Sandbox $sandbox -Name 'OrphanedUnreadableNext' + Add-NTFSAccess -Path $next -Account 'S-1-5-21-1-2-3-9301' -AccessRights ReadData -ErrorAction Stop + + $result = @(Get-NTFSOrphanedAccess -Path $blocked, $next -ErrorVariable orphanErrors -ErrorAction SilentlyContinue) + + $orphanErrors | Should -HaveCount 1 + $orphanErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadSecurityError,*' + $orphanErrors[0].CategoryInfo.Category | Should -Be 'ReadError' + $orphanErrors[0].TargetObject | Should -Be $blocked + $result | Should -HaveCount 1 + "$($result[0].Account.Sid)" | Should -Be 'S-1-5-21-1-2-3-9301' + } + } + Context 'SD parameter set' { It 'Should filter a descriptor''s orphaned entries by -Account, report both orphaned Sids by default, exclude them all with -ExcludeExplicit, and read two descriptors bound through the pipeline' { $file = New-TestSandboxItem -Sandbox $sandbox -Name 'OrphanedSdFile' @@ -620,6 +1016,28 @@ Describe 'Get-NTFSOrphanedAccess' { @($piped | Where-Object -FilterScript { $_.FullName -eq $file2 }) | Should -HaveCount 1 } } + + Context 'SD parameter set, -ExcludeInherited and an in-memory change' { + It 'Should return the explicit entry with -ExcludeInherited and still report an entry added to the descriptor only in memory' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'OrphanedSdMemory' + Add-NTFSAccess -Path $file -Account 'S-1-5-21-1-2-3-9401' -AccessRights ReadData -ErrorAction Stop + $sd = Get-NTFSSecurityDescriptor -Path $file -ErrorAction Stop + + $explicitOnly = @(Get-NTFSOrphanedAccess -SecurityDescriptor $sd -ExcludeInherited -ErrorAction Stop) + + $explicitOnly | Should -HaveCount 1 + "$($explicitOnly[0].Account.Sid)" | Should -Be 'S-1-5-21-1-2-3-9401' + + Add-NTFSAccess -SecurityDescriptor $sd -Account 'S-1-5-21-1-2-3-9402' -AccessRights ReadData -ErrorAction Stop + + $afterMemoryChange = @(Get-NTFSOrphanedAccess -SecurityDescriptor $sd -ErrorAction Stop) + + ($afterMemoryChange | ForEach-Object -Process { "$($_.Account.Sid)" } | Sort-Object) -join ',' | + Should -Be (('S-1-5-21-1-2-3-9401', 'S-1-5-21-1-2-3-9402' | Sort-Object) -join ',') + # The disk copy stays as it was: only the descriptor in memory saw the second account. + @(Get-NTFSOrphanedAccess -Path $file -ErrorAction Stop) | Should -HaveCount 1 + } + } } Describe 'Get-NTFSSimpleAccess' { @@ -653,6 +1071,25 @@ Describe 'Get-NTFSSimpleAccess' { } } + Context 'Path parameter set, missing path' { + # Unlike the unreadable-folder case above, a missing path needs no retry and no privilege change: this cmdlet + # does not retry as the owner, so the ReadError of a missing path was pinned by ErrorId only until now. + It 'Should report a missing path''s error by category and target and still report the next folder''s entries' { + $missing = Join-Path -Path $sandbox -ChildPath ('SimpleMissing-{0}' -f [guid]::NewGuid().ToString('N').Substring(0, 8)) + Assert-TestSandboxPath -Sandbox $sandbox -Path $missing + $next = New-TestSandboxItem -Sandbox $sandbox -Name 'SimpleMissingNext' -Directory + Add-NTFSAccess -Path $next -Account 'S-1-1-0' -AccessRights ReadData -ErrorAction Stop + + $result = @(Get-NTFSSimpleAccess -Path $missing, $next -IncludeRootFolder:$false -ErrorVariable simpleErrors -ErrorAction SilentlyContinue) + + $simpleErrors | Should -HaveCount 1 + $simpleErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadError,*' + $simpleErrors[0].CategoryInfo.Category | Should -Be 'OpenError' + $simpleErrors[0].TargetObject | Should -Be $missing + @($result | Where-Object -FilterScript { $_.FullName -eq $next }) | Should -Not -BeNullOrEmpty + } + } + Context 'SD parameter set' { It 'Should filter and partition a descriptor''s entries like the equivalent -Path call, and read two descriptors bound through the pipeline' { $parent = New-TestSandboxItem -Sandbox $sandbox -Name 'SimpleSdParent' -Directory @@ -708,6 +1145,21 @@ Describe 'Get-NTFSEffectiveAccess' { $result[0].FullName | Should -Be $first $result[1].FullName | Should -Be $second } + + It 'Should write nothing for a missing path and only the succeeding path''s result in the same call' { + $missing = Join-Path -Path $sandbox -ChildPath ('EffectiveMissing-{0}' -f [guid]::NewGuid().ToString('N').Substring(0, 8)) + Assert-TestSandboxPath -Sandbox $sandbox -Path $missing + $next = New-TestSandboxItem -Sandbox $sandbox -Name 'EffectiveMissingNext' + + $result = @(Get-NTFSEffectiveAccess -Path $missing, $next -Account 'S-1-1-0' -WarningAction SilentlyContinue -ErrorVariable accessErrors -ErrorAction SilentlyContinue) + + $accessErrors | Should -HaveCount 1 + $accessErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadFileError,*' + $accessErrors[0].CategoryInfo.Category | Should -Be 'OpenError' + $accessErrors[0].TargetObject | Should -Be $missing + $result | Should -HaveCount 1 + $result[0].FullName | Should -Be $next + } } Context 'SecurityDescriptor parameter set' { diff --git a/Tests/ParameterSets.Audit.Tests.ps1 b/Tests/ParameterSets.Audit.Tests.ps1 index c715f1e..a22289a 100644 --- a/Tests/ParameterSets.Audit.Tests.ps1 +++ b/Tests/ParameterSets.Audit.Tests.ps1 @@ -157,6 +157,24 @@ Describe 'Add-NTFSAudit' { } } + Context 'Binding -Account and -AuditFlags from the pipeline by property name' { + # judged-audit.csv (Add-NTFSAudit PathComplex): no existing call binds -AuditFlags by property name, and + # none adds for several accounts in one call; this piped object binds both at once. + It 'Should add one audit entry for each account that the piped object names, with the AuditFlags it also names' -Skip:(-not $canReadAudit) { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'AddPipedAuditSeveralAccounts' -Directory + + [pscustomobject]@{ FullName = $folder; Account = @($probe, $keeper); AccessRights = 'ReadData'; AuditFlags = 'Failure'; InheritanceFlags = 'None'; PropagationFlags = 'None' } | Add-NTFSAudit -ErrorAction Stop + + $acl = Get-Acl -LiteralPath $folder -Audit + foreach ($account in $probe, $keeper) { + $rule = Get-TestAccountAuditRule -Acl $acl -Account $account + $rule | Should -HaveCount 1 + $rule[0].AuditFlags | Should -Be ([System.Security.AccessControl.AuditFlags]::Failure) + $rule[0].FileSystemRights.HasFlag([System.Security.AccessControl.FileSystemRights]::ReadData) | Should -BeTrue + } + } + } + Context 'Binding -SecurityDescriptor from the pipeline by property name' { It 'Should add the audit entry for the piped object that has an audit section, write a ReadSecurityError for the one that does not, and leave the item on disk unchanged until Set-NTFSSecurityDescriptor writes it' -ForEach @( @{ Set = 'SDSimple'; ExtraProperties = @{ AppliesTo = 'ThisFolderOnly' } } @@ -478,6 +496,44 @@ Describe 'Remove-NTFSAudit' { } } + Context 'Removing for several accounts in one call' { + # judged-audit.csv (Remove-NTFSAudit PathComplex): no existing call removes for several accounts in one call. + It 'Should remove the audit entry of each account that -Account names in one call, and leave another account alone' -Skip:(-not $canReadAudit) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveSeveralAccounts' + $bystander = 'S-1-5-11' + foreach ($account in $probe, $keeper, $bystander) { + Add-NTFSAudit -Path $file -Account $account -AccessRights ReadData -InheritanceFlags None -PropagationFlags None -ErrorAction Stop + } + $daclBefore = Get-TestDaclSddl -Path $file + + $result = @(Remove-NTFSAudit -Path $file -Account $probe, $keeper -AccessRights ReadData -InheritanceFlags None -PropagationFlags None -PassThru -ErrorAction Stop) + + $acl = Get-Acl -LiteralPath $file -Audit + (Get-TestAccountAuditRule -Acl $acl -Account $probe) | Should -BeNullOrEmpty + (Get-TestAccountAuditRule -Acl $acl -Account $keeper) | Should -BeNullOrEmpty + (Get-TestAccountAuditRule -Acl $acl -Account $bystander) | Should -HaveCount 1 + $result | Should -HaveCount 1 + $result[0].Account.Sid | Should -Be $bystander + Get-TestDaclSddl -Path $file | Should -BeExactly $daclBefore + } + } + + Context 'When the item has no SACL' { + # judged-audit.csv (Remove-NTFSAudit PathComplex): Audit.Tests.ps1:406 asserts no error only; this adds the + # empty Audit SDDL and the unchanged DACL after the call. + It 'Should leave the empty Audit SDDL and the DACL unchanged' -Skip:(-not $canReadAudit) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveNoSaclComplex' + Get-TestAuditSddl -Path $file | Should -BeNullOrEmpty + $daclBefore = Get-TestDaclSddl -Path $file + + Remove-NTFSAudit -Path $file -Account $probe -AccessRights ReadData -InheritanceFlags None -PropagationFlags None -ErrorVariable removeErrors -ErrorAction SilentlyContinue + + $removeErrors | Should -BeNullOrEmpty + Get-TestAuditSddl -Path $file | Should -BeNullOrEmpty + Get-TestDaclSddl -Path $file | Should -BeExactly $daclBefore + } + } + Context 'Direct call of -Path with -AppliesTo' { # Remove-NTFSAudit PathSimple (judged-audit.csv): every existing call binds it only through a splat # (PermissionScopes.Tests.ps1); this one names the set's parameters directly. @@ -512,6 +568,38 @@ Describe 'Remove-NTFSAudit' { (Get-TestAccountAuditRule -Acl $sd.SecurityDescriptor -Account $keeper) | Should -HaveCount 1 } } + + Context 'Leaving the item on disk untouched until Set-NTFSSecurityDescriptor writes the change' { + # judged-audit.csv (Remove-NTFSAudit SDSimple and SDComplex): the pipeline test above (binding + # -SecurityDescriptor by property name) proves only that the in-memory descriptor lost the account's rule; + # this proves the item on disk keeps its audit section until Set-NTFSSecurityDescriptor writes it, and that + # -PassThru returns audit rule objects for the SDComplex set too. + It 'Should remove the entry from the descriptor only in memory, until Set-NTFSSecurityDescriptor writes it, and return audit rule objects with -PassThru' -ForEach @( + @{ Set = 'SDSimple'; ExtraProperties = @{ AppliesTo = 'ThisFolderOnly' } } + @{ Set = 'SDComplex'; ExtraProperties = @{ InheritanceFlags = 'None'; PropagationFlags = 'None' } } + ) -Skip:(-not $canReadAudit) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name "RemoveSDUntouched$Set" + Add-NTFSAudit -Path $file @entry @ExtraProperties -ErrorAction Stop + Add-NTFSAudit -Path $file -Account $keeper -AccessRights Delete @ExtraProperties -ErrorAction Stop + $auditBefore = Get-TestAuditSddl -Path $file + $daclBefore = Get-TestDaclSddl -Path $file + $sd = Get-NTFSSecurityDescriptor -Path $file -ErrorAction Stop + + $result = @(Remove-NTFSAudit -SecurityDescriptor $sd @entry @ExtraProperties -PassThru -ErrorAction Stop) + + $result | Should -Not -BeNullOrEmpty + $result | ForEach-Object -Process { $_ | Should -BeOfType [Security2.FileSystemAuditRule2] } + (Get-TestAccountAuditRule -Acl $sd.SecurityDescriptor -Account $probe) | Should -BeNullOrEmpty + Get-TestAuditSddl -Path $file | Should -BeExactly $auditBefore + + Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -ErrorAction Stop + + (Get-TestAccountAuditRule -Acl (Get-TestAuditAcl -Path $file) -Account $probe) | Should -BeNullOrEmpty + (Get-TestAccountAuditRule -Acl (Get-TestAuditAcl -Path $file) -Account $keeper) | Should -HaveCount 1 + Get-TestAuditSddl -Path $file | Should -Not -BeExactly $auditBefore + Get-TestDaclSddl -Path $file | Should -BeExactly $daclBefore + } + } } Describe 'Clear-NTFSAudit' { @@ -532,6 +620,25 @@ Describe 'Clear-NTFSAudit' { Get-TestDaclSddl -Path $first | Should -BeExactly $daclBefore[0] Get-TestDaclSddl -Path $second | Should -BeExactly $daclBefore[1] } + + # judged-audit.csv (Clear-NTFSAudit Path): no existing test pipes a failing path and asserts the result of + # the succeeding path that follows it. + It 'Should clear the audit entries of the path that follows a missing one, and write only a ReadFileError for the missing one' -Skip:(-not $canReadAudit) { + $missing = Join-Path -Path $sandbox -ChildPath ('ClearPipeMissing-{0}' -f [guid]::NewGuid().ToString('N')) + Assert-TestSandboxPath -Sandbox $sandbox -Path $missing + $good = New-TestSandboxItem -Sandbox $sandbox -Name 'ClearPipeGoodAfterMissing' + Add-NTFSAudit -Path $good @entry -InheritanceFlags None -PropagationFlags None -ErrorAction Stop + $daclBefore = Get-TestDaclSddl -Path $good + + $missing, $good | Clear-NTFSAudit -ErrorVariable clearErrors -ErrorAction SilentlyContinue + + $clearErrors | Should -HaveCount 1 + $clearErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadFileError,*' + $clearErrors[0].CategoryInfo.Category | Should -Be 'OpenError' + $clearErrors[0].TargetObject | Should -Be $missing + @(Get-NTFSAudit -Path $good -ExcludeInherited) | Should -BeNullOrEmpty + Get-TestDaclSddl -Path $good | Should -BeExactly $daclBefore + } } Context 'With -DisableInheritance' { @@ -663,6 +770,27 @@ Describe 'Get-NTFSAudit' { ($result | ForEach-Object -Process { '{0}|{1}|{2}' -f $_.Account.Sid, $_.AccessRights, $_.AuditFlags }) -join ';' | Should -Be ($expected -join ';') } } + + Context 'Output of a security descriptor, independent of the Path set' { + # judged-audit.csv (Get-NTFSAudit SD): the pipeline test above compares the SD set's output with the Path + # set's output; this asserts concrete values instead. + It 'Should return the entries of a security descriptor with the expected type, account, rights, flags, and inherited state' -Skip:(-not $canReadAudit) { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'GetAuditSDValues' -Directory + Add-NTFSAudit -Path $folder -Account $probe -AccessRights ReadData -AuditFlags Failure -InheritanceFlags None -PropagationFlags None -ErrorAction Stop + $sd = Get-NTFSSecurityDescriptor -Path $folder -ErrorAction Stop + + $result = @(Get-NTFSAudit -SecurityDescriptor $sd -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0] | Should -BeOfType [Security2.FileSystemAuditRule2] + $result[0].FullName | Should -Be $folder + $result[0].Account.Sid | Should -Be $probe + $result[0].AccessRights.HasFlag([Security2.FileSystemRights2]::ReadData) | Should -BeTrue + $result[0].AuditFlags | Should -Be ([System.Security.AccessControl.AuditFlags]::Failure) + $result[0].IsInherited | Should -BeFalse + $result[0].InheritanceEnabled | Should -BeTrue + } + } } Describe 'Get-NTFSOrphanedAudit' { @@ -687,6 +815,43 @@ Describe 'Get-NTFSOrphanedAudit' { } } + Context 'Without the Security privilege' { + # judged-audit.csv (Get-NTFSOrphanedAudit Path): Audit.Tests.ps1:304 asserts the ErrorId only; this adds + # the category. Without the privilege, .NET throws PrivilegeNotHeldException, not UnauthorizedAccessException, + # so the category is OpenError, like Get-NTFSAudit (Audit.Tests.ps1:48); PermissionDenied is a Limit (needs a + # lab or a remote server). + It 'Should write a ReadSecurityError with category OpenError and the path as target' -Skip:$canReadAudit { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'OrphanedNoPrivilege' + + $result = @(Get-NTFSOrphanedAudit -Path $file -ErrorVariable orphanedErrors -ErrorAction SilentlyContinue) + + $result | Should -BeNullOrEmpty + $orphanedErrors | Should -HaveCount 1 + $orphanedErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadSecurityError,*' + $orphanedErrors[0].CategoryInfo.Category | Should -Be 'OpenError' + $orphanedErrors[0].TargetObject | Should -Be $file + } + } + + Context 'Filtering a path with -ExcludeExplicit' { + It 'Should return only the inherited orphaned entry with -ExcludeExplicit' -Skip:(-not $canReadAudit) { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'OrphanedExcludeExplicitFolder' -Directory + Add-NTFSAudit -Path $folder -Account 'S-1-5-21-1-2-3-9101' -AccessRights ReadData -AppliesTo ThisFolderAndFiles -ErrorAction Stop + $child = Join-Path -Path $folder -ChildPath 'Child.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $child + Set-Content -LiteralPath $child -Value 'Child' + Add-NTFSAudit -Path $child -Account 'S-1-5-21-1-2-3-9102' -AccessRights Delete -InheritanceFlags None -PropagationFlags None -ErrorAction Stop + + $all = @(Get-NTFSOrphanedAudit -Path $child -ErrorAction Stop) + $result = @(Get-NTFSOrphanedAudit -Path $child -ExcludeExplicit -ErrorAction Stop) + + $all | Should -HaveCount 2 + $result | Should -HaveCount 1 + $result[0].Account.Sid | Should -Be 'S-1-5-21-1-2-3-9101' + $result[0].IsInherited | Should -BeTrue + } + } + Context 'Binding -SecurityDescriptor from the pipeline' { It 'Should return the orphaned entries of the piped descriptor that has an audit section and continue past one that does not' -Skip:(-not $canReadAudit) { $badFile = New-TestSandboxItem -Sandbox $sandbox -Name 'OrphanedSDBad' @@ -723,4 +888,29 @@ Describe 'Get-NTFSOrphanedAudit' { $result[0].FullName | Should -Be $file } } + + Context '-Account and -Exclude filters with a descriptor' { + # judged-audit.csv (Get-NTFSOrphanedAudit SD): no existing test passes -Account or -ExcludeInherited/ + # -ExcludeExplicit together with a descriptor. + It 'Should filter the orphaned entries of a piped descriptor by -Account, -ExcludeExplicit, and -ExcludeInherited' -Skip:(-not $canReadAudit) { + $folder = New-TestSandboxItem -Sandbox $sandbox -Name 'OrphanedSDFilterFolder' -Directory + Add-NTFSAudit -Path $folder -Account 'S-1-5-21-1-2-3-9201' -AccessRights ReadData -AppliesTo ThisFolderAndFiles -ErrorAction Stop + $child = Join-Path -Path $folder -ChildPath 'Child.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $child + Set-Content -LiteralPath $child -Value 'Child' + Add-NTFSAudit -Path $child -Account 'S-1-5-21-1-2-3-9202' -AccessRights Delete -InheritanceFlags None -PropagationFlags None -ErrorAction Stop + $sd = Get-NTFSSecurityDescriptor -Path $child -ErrorAction Stop + + $inheritedOnly = @(Get-NTFSOrphanedAudit -SecurityDescriptor $sd -ExcludeExplicit -ErrorAction Stop) + $explicitOnly = @(Get-NTFSOrphanedAudit -SecurityDescriptor $sd -ExcludeInherited -ErrorAction Stop) + $byAccount = @(Get-NTFSOrphanedAudit -SecurityDescriptor $sd -Account 'S-1-5-21-1-2-3-9202' -ErrorAction Stop) + + $inheritedOnly | Should -HaveCount 1 + $inheritedOnly[0].Account.Sid | Should -Be 'S-1-5-21-1-2-3-9201' + $explicitOnly | Should -HaveCount 1 + $explicitOnly[0].Account.Sid | Should -Be 'S-1-5-21-1-2-3-9202' + $byAccount | Should -HaveCount 1 + $byAccount[0].Account.Sid | Should -Be 'S-1-5-21-1-2-3-9202' + } + } } diff --git a/Tests/ParameterSets.Inheritance.Tests.ps1 b/Tests/ParameterSets.Inheritance.Tests.ps1 index f9bc66a..5455dbe 100644 --- a/Tests/ParameterSets.Inheritance.Tests.ps1 +++ b/Tests/ParameterSets.Inheritance.Tests.ps1 @@ -90,6 +90,38 @@ BeforeDiscovery { @{ Command = 'Disable-NTFSAuditInheritance'; Scope = 'audit'; PreDisable = $false; Expected = $false } @{ Command = 'Enable-NTFSAuditInheritance'; Scope = 'audit'; PreDisable = $true; Expected = $true } ) + + # No -Path: the four cmdlets that the docs say do nothing at all when no path arrives, directly or piped. + $noPathInheritanceCases = @( + @{ Command = 'Disable-NTFSAccessInheritance'; Parameters = @{}; Scope = 'access' } + @{ Command = 'Enable-NTFSAccessInheritance'; Parameters = @{}; Scope = 'access' } + @{ Command = 'Disable-NTFSAuditInheritance'; Parameters = @{}; Scope = 'audit' } + @{ Command = 'Enable-NTFSAuditInheritance'; Parameters = @{}; Scope = 'audit' } + @{ Command = 'Set-NTFSInheritance'; Parameters = @{ AccessInheritanceEnabled = $false }; Scope = 'access' } + ) + + # Error: a missing path, asserting the OpenError category, for the three Path-set cmdlets that $missingPathOpenErrorCases + # above does not cover. + $missingPathOpenErrorPathSetCases = @( + @{ Command = 'Disable-NTFSAccessInheritance'; Parameters = @{} } + @{ Command = 'Enable-NTFSAccessInheritance'; Parameters = @{} } + @{ Command = 'Set-NTFSInheritance'; Parameters = @{ AccessInheritanceEnabled = $false } } + ) + + # State: the -RemoveInheritedAuditRules/-RemoveExplicitAuditRules switches with a -SecurityDescriptor, mirroring + # $sdAccessSwitchCases above but read back with the independent .NET audit oracle, not Get-NTFSAudit. + $sdAuditSwitchCases = @( + @{ Command = 'Disable-NTFSAuditInheritance'; SwitchName = 'RemoveInheritedAuditRules'; Remove = $true } + @{ Command = 'Enable-NTFSAuditInheritance'; SwitchName = 'RemoveExplicitAuditRules'; Remove = $false } + @{ Command = 'Enable-NTFSAuditInheritance'; SwitchName = 'RemoveExplicitAuditRules'; Remove = $true } + ) + + # State: Set-NTFSInheritance -AuditInheritanceEnabled, read with the independent .NET audit oracle, not + # Get-NTFSInheritance/Get-NTFSAudit. + $setInheritanceAuditOracleCases = @( + @{ Enable = $false } + @{ Enable = $true } + ) } BeforeAll { @@ -144,6 +176,22 @@ BeforeAll { [System.IO.FileSystemAclExtensions]::GetAccessControl($info, $sections) } } + + function Protect-TestDaclWithoutModule { + # Protects the DACL of an item directly through .NET, keeping its inherited entries as explicit copies, so a + # test can read back a protected state that no NTFSSecurity cmdlet created. Not Set-Acl: see Remove-TestSandbox. + param ([string] $Path) + + $acl = Get-Acl -LiteralPath $Path + $acl.SetAccessRuleProtection($true, $true) + $item = Get-Item -LiteralPath $Path + if ($PSVersionTable.PSEdition -eq 'Core') { + [System.IO.FileSystemAclExtensions]::SetAccessControl($item, $acl) + } + else { + $item.SetAccessControl($acl) + } + } } AfterAll { @@ -749,3 +797,301 @@ Describe 'Pipeline: binding into the -Path and -SecurityDescriptor sets that no $byProperty[0].SecurityDescriptor.GetSecurityDescriptorSddlForm('Access') | Should -Be $expectedSddl } } + +Describe 'No -Path: the documented behavior of each cmdlet without a path or a security descriptor' { + # Push-Location moves the session into its own, empty sandbox folder first, so that even a cmdlet that wrongly + # fell back to the current location could only ever reach this folder, never one of another test. + It ' should do nothing without -Path, not even to the current location' -ForEach $noPathInheritanceCases { + if ($Scope -eq 'audit' -and -not $canChangeAudit) { + Set-ItResult -Skipped -Because 'reading the SACL back needs the Security privilege' + return + } + $here = New-TestSandboxItem -Sandbox $sandbox -Name 'NoPathLocation' -Directory + Push-Location -LiteralPath $here + try { + $before = (Get-Acl -LiteralPath $here).Sddl + $auditBefore = if ($Scope -eq 'audit') { (Get-TestAuditAcl -Path $here).GetSecurityDescriptorSddlForm('Audit') } + + $result = @(& $Command @Parameters -PassThru -ErrorVariable cmdErrors -ErrorAction SilentlyContinue) + + $cmdErrors | Should -BeNullOrEmpty + $result | Should -BeNullOrEmpty + (Get-Acl -LiteralPath $here).Sddl | Should -BeExactly $before + if ($Scope -eq 'audit') { + (Get-TestAuditAcl -Path $here).GetSecurityDescriptorSddlForm('Audit') | Should -BeExactly $auditBefore + } + } + finally { + Pop-Location + } + } + + It 'Set-NTFSOwner should do nothing without -Path, not even to the current location' { + $here = New-TestSandboxItem -Sandbox $sandbox -Name 'NoPathOwnerLocation' -Directory + Push-Location -LiteralPath $here + try { + $before = (Get-Acl -LiteralPath $here).Sddl + + $result = @(Set-NTFSOwner -Account $currentUser -PassThru -ErrorVariable cmdErrors -ErrorAction SilentlyContinue) + + $cmdErrors | Should -BeNullOrEmpty + $result | Should -BeNullOrEmpty + (Get-Acl -LiteralPath $here).Sddl | Should -BeExactly $before + } + finally { + Pop-Location + } + } + + It 'Get-NTFSOwner should return nothing without -Path, not even for the current location' { + $here = New-TestSandboxItem -Sandbox $sandbox -Name 'NoPathGetOwnerLocation' -Directory + Push-Location -LiteralPath $here + try { + $result = @(Get-NTFSOwner -ErrorVariable ownerErrors -ErrorAction SilentlyContinue) + + $ownerErrors | Should -BeNullOrEmpty + $result | Should -BeNullOrEmpty + } + finally { + Pop-Location + } + } + + It ' should report the current location when no -Path is given, not an empty result' -ForEach @( + @{ Command = 'Get-NTFSInheritance' } + @{ Command = 'Get-NTFSSecurityDescriptor' } + ) { + $here = New-TestSandboxItem -Sandbox $sandbox -Name 'CurrentLocationFallback' -Directory + Push-Location -LiteralPath $here + try { + $result = @(& $Command -ErrorVariable cmdErrors -ErrorAction SilentlyContinue) + + $cmdErrors | Should -BeNullOrEmpty + $result | Should -HaveCount 1 + $result[0].FullName | Should -Be $here + } + finally { + Pop-Location + } + } +} + +Describe 'Errors: OpenError category for a missing path, for the cmdlets not yet pinned by the existing OpenError test' { + It ' should report ReadFileError with category OpenError for a path that does not exist, and continue with the next path' -ForEach $missingPathOpenErrorPathSetCases { + $missing = Join-Path -Path $sandbox -ChildPath ('Missing-{0}' -f [guid]::NewGuid().ToString('N').Substring(0, 8)) + Assert-TestSandboxPath -Sandbox $sandbox -Path $missing + $next = New-TestSandboxItem -Sandbox $sandbox -Name 'NextAfterMissingPathSet' + + $output = @(& $Command -Path $missing, $next @Parameters -PassThru -ErrorVariable pathErrors -ErrorAction SilentlyContinue) + + $pathErrors | Should -HaveCount 1 + $pathErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadFileError,*' + $pathErrors[0].CategoryInfo.Category | Should -Be 'OpenError' + $pathErrors[0].TargetObject | Should -Be $missing + $output | Should -HaveCount 1 + $output[0].FullName | Should -Be $next + } +} + +Describe 'State: additional independent-oracle and unchanged-section coverage' { + It 'Disable-NTFSAccessInheritance -SecurityDescriptor should leave the owner unchanged once Set-NTFSSecurityDescriptor writes the protected DACL' { + $parent = New-TestSandboxItem -Sandbox $sandbox -Name 'DisableAccessOwnerParent' -Directory + $file = Join-Path -Path $parent -ChildPath 'Child' + Assert-TestSandboxPath -Sandbox $sandbox -Path $file + Add-NTFSAccess -Path $parent -Account 'S-1-5-21-1-2-3-4951' -AccessRights ReadData -ErrorAction Stop + Set-Content -LiteralPath $file -Value 'Child' + $ownerBefore = (Get-Acl -LiteralPath $file).GetOwner($sidType).Value + $sd = Get-NTFSSecurityDescriptor -Path $file + + Disable-NTFSAccessInheritance -SecurityDescriptor $sd -RemoveInheritedAccessRules -ErrorAction Stop + Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -ErrorAction Stop + + $acl = Get-Acl -LiteralPath $file + $acl.AreAccessRulesProtected | Should -BeTrue + $acl.GetOwner($sidType).Value | Should -Be $ownerBefore + } + + It 'Set-NTFSInheritance should set the SACL protected flag and audit-rule counts for enabled=, read independently with Get-Acl -Audit' -Skip:(-not $canChangeAudit) -ForEach $setInheritanceAuditOracleCases { + $parent = New-TestSandboxItem -Sandbox $sandbox -Name 'SetInheritanceAuditOracleParent' -Directory + $path = Join-Path -Path $parent -ChildPath 'Child' + Assert-TestSandboxPath -Sandbox $sandbox -Path $path + $parentAccount = 'S-1-5-21-1-2-3-4954' + Add-NTFSAudit -Path $parent -Account $parentAccount -AccessRights ReadData -AuditFlags Success -ErrorAction Stop + New-Item -ItemType Directory -Path $path | Out-Null + $inheritedBefore = @((Get-Acl -LiteralPath $path -Audit).GetAuditRules($false, $true, $sidType) | Where-Object -FilterScript { $_.IdentityReference.Value -eq $parentAccount }) + $inheritedBefore | Should -HaveCount 1 + if ($Enable) { + # -RemoveInheritedAuditRules, so re-enabling below restores exactly one entry, not a second one next to a + # leftover explicit copy (Enable alone keeps explicit entries, as Inheritance.Tests.ps1:422 pins). + Disable-NTFSAuditInheritance -Path $path -RemoveInheritedAuditRules -ErrorAction Stop + } + + $result = @(Set-NTFSInheritance -Path $path -AuditInheritanceEnabled $Enable -PassThru -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0].AuditInheritanceEnabled | Should -Be $Enable + $acl = Get-Acl -LiteralPath $path -Audit + $acl.AreAuditRulesProtected | Should -Be (-not $Enable) + $parentRules = @($acl.GetAuditRules($true, $true, $sidType) | Where-Object -FilterScript { $_.IdentityReference.Value -eq $parentAccount }) + $parentRules | Should -HaveCount 1 + $parentRules[0].IsInherited | Should -Be $Enable + } + + It ' -SecurityDescriptor should change the in-memory audit entries per -:, applied to the item only after Set-NTFSSecurityDescriptor writes it, leaving the owner and DACL unchanged' -Skip:(-not $canChangeAudit) -ForEach $sdAuditSwitchCases { + $parent = New-TestSandboxItem -Sandbox $sandbox -Name 'SdAuditSwitchParent' -Directory + $file = Join-Path -Path $parent -ChildPath 'Child' + Assert-TestSandboxPath -Sandbox $sandbox -Path $file + Add-NTFSAudit -Path $parent -Account 'S-1-5-21-1-2-3-4952' -AccessRights ReadData -AuditFlags Success -ErrorAction Stop + Set-Content -LiteralPath $file -Value 'Child' + if ($Command -eq 'Enable-NTFSAuditInheritance') { + Add-NTFSAudit -Path $file -Account 'S-1-5-21-1-2-3-4953' -AccessRights Delete -AuditFlags Failure -AppliesTo ThisFolderOnly -ErrorAction Stop + Disable-NTFSAuditInheritance -Path $file -RemoveInheritedAuditRules -ErrorAction Stop + } + $before = (Get-Acl -LiteralPath $file).Sddl + $sd = Get-NTFSSecurityDescriptor -Path $file + $switchParameters = @{ $SwitchName = $Remove } + + & $Command -SecurityDescriptor $sd @switchParameters -ErrorAction Stop + + $sd.SecurityDescriptor.AreAuditRulesProtected | Should -Be ($Command -eq 'Disable-NTFSAuditInheritance') + (Get-Acl -LiteralPath $file).Sddl | Should -BeExactly $before + + Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -ErrorAction Stop + + $acl = Get-TestAuditAcl -Path $file + if ($Command -eq 'Disable-NTFSAuditInheritance') { + $acl.AreAuditRulesProtected | Should -BeTrue + $inherited = @($acl.GetAuditRules($false, $true, $sidType) | Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-5-21-1-2-3-4952' }) + $explicitCopy = @($acl.GetAuditRules($true, $false, $sidType) | Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-5-21-1-2-3-4952' }) + if ($Remove) { + $inherited | Should -BeNullOrEmpty + $explicitCopy | Should -BeNullOrEmpty + } + else { + $explicitCopy | Should -HaveCount 1 + } + } + else { + $acl.AreAuditRulesProtected | Should -BeFalse + $explicitChild = @($acl.GetAuditRules($true, $false, $sidType) | Where-Object -FilterScript { $_.IdentityReference.Value -eq 'S-1-5-21-1-2-3-4953' }) + if ($Remove) { $explicitChild | Should -BeNullOrEmpty } else { $explicitChild | Should -HaveCount 1 } + } + (Get-Acl -LiteralPath $file).Sddl | Should -BeExactly $before + } + + It 'Get-NTFSInheritance should name the item in the Name property and report a DACL protected without the module' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'InheritanceNameAndExternalProtect' + Protect-TestDaclWithoutModule -Path $file + (Get-Acl -LiteralPath $file).AreAccessRulesProtected | Should -BeTrue + + $result = Get-NTFSInheritance -Path $file -ErrorAction Stop + + $result.Name | Should -Be ([IO.Path]::GetFileName($file)) + $result.AccessInheritanceEnabled | Should -BeFalse + } + + It 'Get-NTFSInheritance -SecurityDescriptor should return a typed object for a descriptor protected without the module' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'SdInheritanceExternalProtect' + Protect-TestDaclWithoutModule -Path $file + $sd = Get-NTFSSecurityDescriptor -Path $file + + $result = Get-NTFSInheritance -SecurityDescriptor $sd -ErrorAction Stop + + $result | Should -BeOfType ([Security2.FileSystemInheritanceInfo]) + $result.AccessInheritanceEnabled | Should -BeFalse + } + + It 'Get-NTFSOwner -SecurityDescriptor should return the real owner of a descriptor that was read without its Owner section' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'OwnerSectionless' + $expected = (Get-Acl -LiteralPath $file).GetOwner($sidType).Value + $sd = New-Object -TypeName 'Security2.FileSystemSecurity2' -ArgumentList ( + (Get-Item2 -Path $file), [System.Security.AccessControl.AccessControlSections]::Access + ) + + $result = @(Get-NTFSOwner -SecurityDescriptor $sd -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0] | Should -BeOfType ([Security2.FileSystemOwner]) + $result[0].Owner.Sid | Should -Be $expected + $result[0].FullName | Should -Be $file + } + + It 'Set-NTFSOwner should leave the SACL unchanged after it changes the owner' -Skip:(-not $canChangeAudit) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'OwnerSaclUnchanged' + Add-NTFSAudit -Path $file -Account 'S-1-1-0' -AccessRights Delete -AuditFlags Success -InheritanceFlags None -PropagationFlags None -ErrorAction Stop + $before = Get-Acl -LiteralPath $file + if ($before.GetOwner($sidType).Value -eq $currentUser) { + Set-ItResult -Skipped -Because 'the user already owns new items, so Set-NTFSOwner would not change the owner' + return + } + $auditBefore = (Get-TestAuditAcl -Path $file).GetSecurityDescriptorSddlForm('Audit') + + Set-NTFSOwner -Path $file -Account $currentUser -ErrorAction Stop + + (Get-Acl -LiteralPath $file).GetOwner($sidType).Value | Should -Be $currentUser + (Get-TestAuditAcl -Path $file).GetSecurityDescriptorSddlForm('Audit') | Should -BeExactly $auditBefore + } + + It 'Set-NTFSOwner -SecurityDescriptor should change only the owner on disk once Set-NTFSSecurityDescriptor writes it, leaving the DACL unchanged, and -PassThru should return a FileSystemOwner object' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'SdOwnerWriteDacl' + Add-NTFSAccess -Path $file -Account 'S-1-1-0' -AccessRights ReadData -ErrorAction Stop + $ownerBefore = (Get-Acl -LiteralPath $file).GetOwner($sidType).Value + if ($ownerBefore -eq $currentUser) { + Set-ItResult -Skipped -Because 'the user already owns new items, so Set-NTFSOwner would not change the owner' + return + } + $daclBefore = (Get-Acl -LiteralPath $file).GetSecurityDescriptorSddlForm('Access') + $sd = Get-NTFSSecurityDescriptor -Path $file + + $result = @(Set-NTFSOwner -SecurityDescriptor $sd -Account $currentUser -PassThru -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0] | Should -BeOfType ([Security2.FileSystemOwner]) + (Get-Acl -LiteralPath $file).GetSecurityDescriptorSddlForm('Access') | Should -BeExactly $daclBefore + + Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -ErrorAction Stop + + $acl = Get-Acl -LiteralPath $file + $acl.GetOwner($sidType).Value | Should -Be $currentUser + $acl.GetSecurityDescriptorSddlForm('Access') | Should -BeExactly $daclBefore + } +} + +Describe 'Set-NTFSSecurityDescriptor: the by-property-name pipeline and a denied PassThru re-read' { + It 'Should take a descriptor by property name through the pipeline, not only by value' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'SdSetByPropertyName' + $sd = Get-NTFSSecurityDescriptor -Path $file + Add-NTFSAccess -SecurityDescriptor $sd -Account 'S-1-5-21-1-2-3-4955' -AccessRights Delete -ErrorAction Stop + + $result = @([pscustomobject]@{ SecurityDescriptor = $sd } | Set-NTFSSecurityDescriptor -PassThru -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0].FullName | Should -Be $file + @(Get-NTFSAccess -Path $file -Account 'S-1-5-21-1-2-3-4955' -ExcludeInherited) | Should -HaveCount 1 + } + + Context 'When the written descriptor denies reading it again' { + BeforeAll { + $privateData['EnablePrivileges'] = $false + } + + AfterAll { + $privateData['EnablePrivileges'] = $enablePrivileges + } + + It 'Should report the PassThru ReadSecurityError with category ReadError and the item as target' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'PassThruReadDeniedCategory' + $sd = Get-NTFSSecurityDescriptor -Path $file + # A deny entry for OWNER RIGHTS replaces the right of the owner to read the security descriptor. + Add-NTFSAccess -SecurityDescriptor $sd -Account 'S-1-3-4' -AccessRights ReadPermissions -AccessType Deny -AppliesTo ThisFolderOnly -ErrorAction Stop + + $result = @(Set-NTFSSecurityDescriptor -SecurityDescriptor $sd -PassThru -ErrorVariable setErrors -ErrorAction SilentlyContinue) + + $result | Should -BeNullOrEmpty + $setErrors | Should -HaveCount 1 + $setErrors[0].FullyQualifiedErrorId | Should -BeLike 'ReadSecurityError,*' + $setErrors[0].CategoryInfo.Category | Should -Be 'ReadError' + $setErrors[0].TargetObject.FullName | Should -Be $file + } + } +} diff --git a/Tests/ParameterSets.Items.Tests.ps1 b/Tests/ParameterSets.Items.Tests.ps1 index 6441a31..7805650 100644 --- a/Tests/ParameterSets.Items.Tests.ps1 +++ b/Tests/ParameterSets.Items.Tests.ps1 @@ -14,6 +14,10 @@ param () BeforeDiscovery { Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force $canCreateSymbolicLinks = Test-PrivilegeHeld -Name 'SeCreateSymbolicLinkPrivilege' + # The restricted token of the basic-user runner cannot map a drive letter or reach the administrative share. + $canMapDrive = Test-DriveMappingAvailable + $canUseAdminShare = Test-AdminShareAvailable + $isElevated = Test-IsElevated } BeforeAll { @@ -593,3 +597,792 @@ Describe 'Get-FileHash2 with pipeline input and ordered failures' { $relativeResult.Hash | Should -BeExactly $result[0].Hash } } + +Describe 'Bracket and wildcard characters in a path are treated literally' { + # A bracket is an ordinary character in a path, as the existing -Filter test already pins for Get-ChildItem2 + # (ItemCmdlets.Tests.ps1:202); this closes the same gap for the path parameter of the other item cmdlets. + It ' should treat a path with brackets in the name as a literal file name' -ForEach @( + @{ Command = 'Copy-Item2' } + @{ Command = 'Move-Item2' } + @{ Command = 'Get-Item2' } + @{ Command = 'Remove-Item2' } + ) { + $sourceFolder = New-TestSandboxItem -Sandbox $sandbox -Name ('Bracket{0}Source' -f $Command.Replace('-', '')) -Directory + $bracketFile = Join-Path -Path $sourceFolder -ChildPath 'Report[1].txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $bracketFile + Set-Content -LiteralPath $bracketFile -Value 'Bracket' + + switch ($Command) { + 'Copy-Item2' { + $destination = Join-Path -Path $sourceFolder -ChildPath 'Copied' + New-Item -ItemType Directory -Path $destination | Out-Null + + $result = Copy-Item2 -Path $bracketFile -Destination $destination -PassThru $true -ErrorAction Stop + + $result.Name | Should -Be 'Report[1].txt' + Get-Content -LiteralPath (Join-Path -Path $destination -ChildPath 'Report[1].txt') | Should -Be 'Bracket' + # Should -Exist uses a non-literal Test-Path, which reads the brackets as a character class and + # would misreport a file that is actually there; [IO.File]::Exists is a literal, independent oracle. + [IO.File]::Exists($bracketFile) | Should -BeTrue + } + 'Move-Item2' { + $destination = Join-Path -Path $sourceFolder -ChildPath 'Moved' + New-Item -ItemType Directory -Path $destination | Out-Null + + $result = Move-Item2 -Path $bracketFile -Destination $destination -PassThru $true -ErrorAction Stop + + $result.Name | Should -Be 'Report[1].txt' + [IO.File]::Exists($bracketFile) | Should -BeFalse + Get-Content -LiteralPath (Join-Path -Path $destination -ChildPath 'Report[1].txt') | Should -Be 'Bracket' + } + 'Get-Item2' { + $result = Get-Item2 -Path $bracketFile -ErrorAction Stop + + $result.Name | Should -Be 'Report[1].txt' + $result | Should -BeOfType [Alphaleonis.Win32.Filesystem.FileInfo] + } + 'Remove-Item2' { + $result = Remove-Item2 -Path $bracketFile -PassThru -ErrorAction Stop + + $result.Name | Should -Be 'Report[1].txt' + [IO.File]::Exists($bracketFile) | Should -BeFalse + } + } + } + + # '*' and '?' are not in [IO.Path]::GetInvalidPathChars() in either edition (only '|' and the control characters + # are, and Windows PowerShell additionally rejects '"', '<', '>'), so a literal asterisk or question mark reaches + # the file system as an ordinary character instead of raising ArgumentException: AlphaFS, like System.IO, looks + # for the exact literal name and reports it missing rather than expanding the character as a search pattern. + It ' should not expand an asterisk in a path as a wildcard and should report FileNotFound' -ForEach @( + @{ Command = 'Move-Item2'; ErrorId = 'FileNotFound' } + @{ Command = 'Remove-Item2'; ErrorId = 'FileNotFound' } + ) { + $sourceFolder = New-TestSandboxItem -Sandbox $sandbox -Name ('Wildcard{0}Source' -f $Command.Replace('-', '')) -Directory + $fileA = Join-Path -Path $sourceFolder -ChildPath 'FileA.txt' + $fileB = Join-Path -Path $sourceFolder -ChildPath 'FileB.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $fileA, $fileB + Set-Content -LiteralPath $fileA -Value 'A' + Set-Content -LiteralPath $fileB -Value 'B' + $wildcardPath = Join-Path -Path $sourceFolder -ChildPath 'File*.txt' + + switch ($Command) { + 'Move-Item2' { + $destination = Join-Path -Path $sourceFolder -ChildPath 'Destination' + New-Item -ItemType Directory -Path $destination | Out-Null + + Move-Item2 -Path $wildcardPath -Destination $destination -ErrorVariable itemErrors -ErrorAction SilentlyContinue + } + 'Remove-Item2' { + Remove-Item2 -Path $wildcardPath -ErrorVariable itemErrors -ErrorAction SilentlyContinue + } + } + + $itemErrors | Should -HaveCount 1 + $itemErrors[0].FullyQualifiedErrorId | Should -BeLike "$ErrorId,*" + $itemErrors[0].CategoryInfo.Category | Should -Be 'ObjectNotFound' + $itemErrors[0].TargetObject | Should -Be $wildcardPath + $fileA | Should -Exist + $fileB | Should -Exist + Get-Content -LiteralPath $fileA | Should -Be 'A' + Get-Content -LiteralPath $fileB | Should -Be 'B' + } +} + +Describe 'Items whose path is longer than 260 characters' { + BeforeAll { + Add-Type -Path (Join-Path -Path (Get-Module -Name NTFSSecurity).ModuleBase -ChildPath 'AlphaFS.dll') + + # Builds a fresh folder tree and file below it whose full path is longer than 260 characters, in its own + # branch of the sandbox, and returns the file's full path. Mirrors ItemCmdlets.Tests.ps1:1029: only the short + # root goes through Assert-TestSandboxPath, because [IO.Path] (which it calls) throws PathTooLongException + # for the long path itself in Windows PowerShell; AlphaFS does not have that limit. A script block, not a + # function: PowerShell does not parse an attribute on a function statement nested inside another block, so + # PSUseShouldProcessForStateChangingFunctions (its name starts with New) could not be suppressed that way. + $newTestLongPathFile = { + param ([Parameter(Mandatory)] [string] $Name) + + $root = New-TestSandboxItem -Sandbox $sandbox -Name $Name -Directory + $deep = Join-Path -Path $root -ChildPath ((('A' * 100), ('B' * 100), ('C' * 100)) -join '\') + [Alphaleonis.Win32.Filesystem.Directory]::CreateDirectory($deep) | Out-Null + $file = Join-Path -Path $deep -ChildPath 'Long.txt' + [Alphaleonis.Win32.Filesystem.File]::WriteAllText($file, 'LongPathContent') + if ($file.Length -le 260) { + throw "The test path '$file' is not longer than 260 characters." + } + $file + } + } + + It 'Get-Item2 should return the file and its folder' { + $file = & $newTestLongPathFile -Name 'GetItemLong' + + $fileResult = Get-Item2 -Path $file -ErrorAction Stop + $folderResult = Get-Item2 -Path (Split-Path -Path $file -Parent) -ErrorAction Stop + + $fileResult | Should -BeOfType [Alphaleonis.Win32.Filesystem.FileInfo] + $fileResult.FullName | Should -Be $file + $folderResult | Should -BeOfType [Alphaleonis.Win32.Filesystem.DirectoryInfo] + } + + It 'Get-ChildItem2 should list the content of the folder' { + $file = & $newTestLongPathFile -Name 'ChildItemLong' + + $result = @(Get-ChildItem2 -Path (Split-Path -Path $file -Parent) -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0].FullName | Should -Be $file + } + + It 'Get-FileHash2 should hash the file and match an independent SHA256 read through AlphaFS' { + $file = & $newTestLongPathFile -Name 'HashLong' + $bytes = [Alphaleonis.Win32.Filesystem.File]::ReadAllBytes($file) + $expectedHash = -join (([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)) | ForEach-Object -Process { $_.ToString('X2') }) + + $result = Get-FileHash2 -Path $file -ErrorAction Stop + + $result.Hash | Should -BeExactly $expectedHash + } + + It 'Copy-Item2 should copy the file to a short destination and leave the source in place' { + $file = & $newTestLongPathFile -Name 'CopyLong' + $destination = New-TestSandboxItem -Sandbox $sandbox -Name 'CopyLongDestination' -Directory + + $result = Copy-Item2 -Path $file -Destination $destination -PassThru $true -ErrorAction Stop + + $result | Should -BeOfType [Alphaleonis.Win32.Filesystem.FileInfo] + $copied = Join-Path -Path $destination -ChildPath 'Long.txt' + Get-Content -LiteralPath $copied | Should -Be 'LongPathContent' + [Alphaleonis.Win32.Filesystem.File]::Exists($file) | Should -BeTrue + } + + It 'Move-Item2 should move the file to a short destination and remove the source' { + $file = & $newTestLongPathFile -Name 'MoveLong' + $destination = New-TestSandboxItem -Sandbox $sandbox -Name 'MoveLongDestination' -Directory + + $result = Move-Item2 -Path $file -Destination $destination -PassThru $true -ErrorAction Stop + + $result | Should -BeOfType [Alphaleonis.Win32.Filesystem.FileInfo] + [Alphaleonis.Win32.Filesystem.File]::Exists($file) | Should -BeFalse + $moved = Join-Path -Path $destination -ChildPath 'Long.txt' + Get-Content -LiteralPath $moved | Should -Be 'LongPathContent' + } +} + +Describe 'Item cmdlets at the root of a mapped drive' -Skip:(-not $canMapDrive) { + # NEVER the real root of a volume: a drive letter that subst maps to a sandbox folder is the root of a drive for + # Windows and for the module (see Tests\DriveRoot.Tests.ps1), without touching a real volume. + BeforeAll { + $driveSandbox = New-TestSandbox -Name 'ItemsDriveRoot' + $mapped = New-TestSandboxItem -Sandbox $driveSandbox -Name 'Mapped' -Directory + $driveRoot = New-TestDriveMapping -Sandbox $driveSandbox -Path $mapped + if (-not $driveRoot) { + throw 'No drive letter could be mapped to the sandbox folder.' + } + $existingFile = Join-Path -Path $mapped -ChildPath 'Existing.txt' + Assert-TestSandboxPath -Sandbox $driveSandbox -Path $existingFile + Set-Content -LiteralPath $existingFile -Value 'Existing' + } + + AfterAll { + if ($driveRoot) { + Remove-TestDriveMapping -Root $driveRoot + } + Remove-TestSandbox -Sandbox $driveSandbox + } + + It 'Get-Item2 should return a DirectoryInfo object for the root of the mapped drive' { + $result = Get-Item2 -Path $driveRoot -ErrorAction Stop + + $result | Should -BeOfType [Alphaleonis.Win32.Filesystem.DirectoryInfo] + $result.FullName.TrimEnd('\') | Should -Be $driveRoot.TrimEnd('\') + } + + It 'Get-ChildItem2 should list the content at the root of the mapped drive' { + $result = @(Get-ChildItem2 -Path $driveRoot -ErrorAction Stop) + + $result.Name | Should -Contain 'Existing.txt' + } + + It 'Test-Path2 should find the root of the mapped drive as a container' { + Test-Path2 -Path $driveRoot -PathType Container -ErrorAction Stop | Should -BeTrue + } + + It 'Copy-Item2 should copy a file to the root of the mapped drive' { + $source = New-TestSandboxItem -Sandbox $driveSandbox -Name 'CopyToDriveRootSource' + Set-Content -LiteralPath $source -Value 'CopyToRoot' + + $result = Copy-Item2 -Path $source -Destination $driveRoot -PassThru $true -ErrorAction Stop + + $result | Should -BeOfType [Alphaleonis.Win32.Filesystem.FileInfo] + $copied = Join-Path -Path $mapped -ChildPath (Split-Path -Path $source -Leaf) + Get-Content -LiteralPath $copied | Should -Be 'CopyToRoot' + $source | Should -Exist + } + + It 'Move-Item2 should move a file to the root of the mapped drive' { + $source = New-TestSandboxItem -Sandbox $driveSandbox -Name 'MoveToDriveRootSource' + Set-Content -LiteralPath $source -Value 'MoveToRoot' + + $result = Move-Item2 -Path $source -Destination $driveRoot -PassThru $true -ErrorAction Stop + + $result | Should -BeOfType [Alphaleonis.Win32.Filesystem.FileInfo] + $source | Should -Not -Exist + $moved = Join-Path -Path $mapped -ChildPath (Split-Path -Path $source -Leaf) + Get-Content -LiteralPath $moved | Should -Be 'MoveToRoot' + } +} + +Describe 'Omitted -Path falls back to the current location' { + BeforeAll { + $currentLocationFolder = New-TestSandboxItem -Sandbox $sandbox -Name 'OmittedPathLocation' -Directory + $currentLocationFile = Join-Path -Path $currentLocationFolder -ChildPath 'Child.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $currentLocationFile + Set-Content -LiteralPath $currentLocationFile -Value 'Child' + } + + It 'Get-Item2 should return the folder of the current location' { + Push-Location -LiteralPath $currentLocationFolder + try { + $result = Get-Item2 -ErrorAction Stop + } + finally { + Pop-Location + } + + $result | Should -BeOfType [Alphaleonis.Win32.Filesystem.DirectoryInfo] + $result.FullName.TrimEnd('\') | Should -Be $currentLocationFolder.TrimEnd('\') + } + + It 'Get-ChildItem2 should list the content of the current location' { + Push-Location -LiteralPath $currentLocationFolder + try { + $result = @(Get-ChildItem2 -ErrorAction Stop) + } + finally { + Pop-Location + } + + $result | Should -HaveCount 1 + $result[0].FullName | Should -Be $currentLocationFile + } +} + +Describe 'Get-ChildItem2 parameter interactions' { + BeforeAll { + $interactionFolder = New-TestSandboxItem -Sandbox $sandbox -Name 'ParamInteractions' -Directory + $interactionSubFolder = Join-Path -Path $interactionFolder -ChildPath 'Sub' + $interactionTopFile = Join-Path -Path $interactionFolder -ChildPath 'Top.txt' + $interactionNestedFile = Join-Path -Path $interactionSubFolder -ChildPath 'Nested.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $interactionSubFolder, $interactionTopFile, $interactionNestedFile + New-Item -ItemType Directory -Path $interactionSubFolder | Out-Null + Set-Content -LiteralPath $interactionTopFile -Value 'Top' + Set-Content -LiteralPath $interactionNestedFile -Value 'Nested' + + $attributeFolder = New-TestSandboxItem -Sandbox $sandbox -Name 'AttributesIgnored' -Directory + $hiddenSystemFile = Join-Path -Path $attributeFolder -ChildPath 'HiddenSystem.txt' + $systemFile = Join-Path -Path $attributeFolder -ChildPath 'System.txt' + $hiddenFile = Join-Path -Path $attributeFolder -ChildPath 'Hidden.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $hiddenSystemFile, $systemFile, $hiddenFile + Set-Content -LiteralPath $hiddenSystemFile -Value 'HS' + Set-Content -LiteralPath $systemFile -Value 'S' + Set-Content -LiteralPath $hiddenFile -Value 'H' + [IO.File]::SetAttributes($hiddenSystemFile, [IO.FileAttributes] 'Hidden, System') + [IO.File]::SetAttributes($systemFile, [IO.FileAttributes] 'System') + [IO.File]::SetAttributes($hiddenFile, [IO.FileAttributes] 'Hidden') + } + + It 'Should resolve a relative -Path against the current location' { + Push-Location -LiteralPath $interactionFolder + try { + $result = @(Get-ChildItem2 -Path (Split-Path -Path $interactionSubFolder -Leaf) -ErrorAction Stop) + } + finally { + Pop-Location + } + + $result | Should -HaveCount 1 + $result[0].FullName | Should -Be $interactionNestedFile + } + + It 'Should return only the folder when -Directory and -File are given together' { + $result = @(Get-ChildItem2 -Path $interactionFolder -Directory -File -ErrorAction Stop) + + $result | Should -HaveCount 1 + $result[0] | Should -BeOfType [Alphaleonis.Win32.Filesystem.DirectoryInfo] + $result[0].FullName | Should -Be $interactionSubFolder + } + + # The cmdlet page documents that -Attributes makes the cmdlet ignore -Force, -Hidden, -System, and -ReadOnly, and + # that it returns a matching hidden item without -Force; each row returns the same names as -Attributes alone, + # which these switches would narrow or widen if they were not ignored. + It 'Should return the same matches for -Attributes regardless of -Force, -Hidden, -System, and -ReadOnly ()' -ForEach @( + @{ Case = 'alone'; AttributesValue = 'System'; Switches = @{} } + @{ Case = 'with every other switch set'; AttributesValue = 'System'; Switches = @{ Force = $true; Hidden = $true; System = $true; ReadOnly = $true } } + @{ Case = 'alone, matching a hidden item without -Force'; AttributesValue = 'Hidden'; Switches = @{} } + @{ Case = 'with -ReadOnly set'; AttributesValue = 'Hidden'; Switches = @{ ReadOnly = $true } } + ) { + $expected = if ($AttributesValue -eq 'System') { @('HiddenSystem.txt', 'System.txt') } else { @('Hidden.txt', 'HiddenSystem.txt') } + + $result = @(Get-ChildItem2 -Path $attributeFolder -Attributes $AttributesValue @Switches -ErrorAction Stop) + + @($result.Name | Sort-Object) | Should -Be $expected + } + + It 'Should have no effect without -Recurse' { + $withDepth = @(Get-ChildItem2 -Path $interactionFolder -Depth 0 -ErrorAction Stop) + $withoutDepth = @(Get-ChildItem2 -Path $interactionFolder -ErrorAction Stop) + + @($withDepth.Name | Sort-Object) | Should -Be @('Sub', 'Top.txt') + @($withDepth.Name | Sort-Object) | Should -Be @($withoutDepth.Name | Sort-Object) + } + + It 'Should list the same items through the dir2 alias' { + $result = @(dir2 -Path $interactionFolder -ErrorAction Stop) + + @($result.Name | Sort-Object) | Should -Be @('Sub', 'Top.txt') + } + + It 'Should write AttributesEmpty with its category and target for an empty -Attributes value' { + $caught = $null + + try { + Get-ChildItem2 -Path $interactionFolder -Attributes 0 -ErrorAction Stop + } + catch { + $caught = $_ + } + + $caught.FullyQualifiedErrorId | Should -Be 'AttributesEmpty,NTFSSecurity.GetChildItem2' + $caught.CategoryInfo.Category | Should -Be 'InvalidArgument' + $caught.TargetObject | Should -Be ([IO.FileAttributes] 0) + } +} + +Describe 'Copy-Item2 destination conflicts and output' { + It 'Should replace an existing file directly with -Force, with PassThru=<_>' -ForEach @($false, $true) { + $sourceFolder = New-TestSandboxItem -Sandbox $sandbox -Name 'CopyForceSource' -Directory + $destinationFolder = New-TestSandboxItem -Sandbox $sandbox -Name 'CopyForceDestination' -Directory + $source = Join-Path -Path $sourceFolder -ChildPath 'Replace.txt' + $target = Join-Path -Path $destinationFolder -ChildPath 'Replace.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $source, $target + Set-Content -LiteralPath $source -Value 'NewContent' + Set-Content -LiteralPath $target -Value 'OldContent' + + $result = @(Copy-Item2 -Path $source -Destination $destinationFolder -Force -PassThru $_ -ErrorAction Stop) + + Get-Content -LiteralPath $target | Should -Be 'NewContent' + $source | Should -Exist + if ($_) { + $result | Should -HaveCount 1 + $result[0].FullName | Should -Be $target + } + else { + $result | Should -BeNullOrEmpty + } + } + + # The folder case of the same conflict (ItemCmdlets.Tests.ps1:633) already asserts ID, category, and target; this + # file case was asserted by error count only. + It 'Should write DestinationFileAlreadyExists with its category and target for a file that exists at the destination' { + $source = New-TestSandboxItem -Sandbox $sandbox -Name 'FileConflictSource' + $destinationFolder = New-TestSandboxItem -Sandbox $sandbox -Name 'FileConflictDestination' -Directory + $existing = Join-Path -Path $destinationFolder -ChildPath (Split-Path -Path $source -Leaf) + Assert-TestSandboxPath -Sandbox $sandbox -Path $existing + Set-Content -LiteralPath $existing -Value 'Existing' + + Copy-Item2 -Path $source -Destination $destinationFolder -ErrorVariable itemErrors -ErrorAction SilentlyContinue + + $itemErrors | Should -HaveCount 1 + $itemErrors[0].FullyQualifiedErrorId | Should -BeLike 'DestinationFileAlreadyExists,*' + $itemErrors[0].CategoryInfo.Category | Should -Be 'ResourceExists' + $itemErrors[0].TargetObject | Should -Be $existing + Get-Content -LiteralPath $existing | Should -Be 'Existing' + } + + It '-PassThru should return the output type for a file and for a folder, and write nothing without -PassThru' { + $sourceFile = New-TestSandboxItem -Sandbox $sandbox -Name 'PassThruTypeFile' + $sourceFolder = New-TestSandboxItem -Sandbox $sandbox -Name 'PassThruTypeFolder' -Directory + $destination = New-TestSandboxItem -Sandbox $sandbox -Name 'PassThruTypeDestination' -Directory + + $fileResult = @(Copy-Item2 -Path $sourceFile -Destination $destination -PassThru $true -ErrorAction Stop) + $folderResult = @(Copy-Item2 -Path $sourceFolder -Destination (Join-Path -Path $destination -ChildPath 'CopiedFolder') -PassThru $true -ErrorAction Stop) + $quietResult = @(Copy-Item2 -Path $sourceFile -Destination (Join-Path -Path $destination -ChildPath 'Quiet') -ErrorAction Stop) + + $fileResult | Should -HaveCount 1 + $fileResult[0] | Should -BeOfType [Alphaleonis.Win32.Filesystem.FileInfo] + $folderResult | Should -HaveCount 1 + $folderResult[0] | Should -BeOfType [Alphaleonis.Win32.Filesystem.DirectoryInfo] + $quietResult | Should -BeNullOrEmpty + } +} + +Describe 'Move-Item2 folder trees, in-place renames, and -Force' { + It 'Should move a non-empty folder tree to a new destination' { + $sourceFolder = New-TestSandboxItem -Sandbox $sandbox -Name 'MoveTreeSource' -Directory + $nestedFolder = Join-Path -Path $sourceFolder -ChildPath 'Nested' + $topFile = Join-Path -Path $sourceFolder -ChildPath 'Top.txt' + $nestedFile = Join-Path -Path $nestedFolder -ChildPath 'Inner.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $nestedFolder, $topFile, $nestedFile + New-Item -ItemType Directory -Path $nestedFolder | Out-Null + Set-Content -LiteralPath $topFile -Value 'Top' + Set-Content -LiteralPath $nestedFile -Value 'Inner' + $destinationParent = New-TestSandboxItem -Sandbox $sandbox -Name 'MoveTreeDestinationParent' -Directory + + $result = Move-Item2 -Path $sourceFolder -Destination $destinationParent -PassThru $true -ErrorAction Stop + + $movedFolder = Join-Path -Path $destinationParent -ChildPath (Split-Path -Path $sourceFolder -Leaf) + $result | Should -BeOfType [Alphaleonis.Win32.Filesystem.DirectoryInfo] + $sourceFolder | Should -Not -Exist + Get-Content -LiteralPath (Join-Path -Path $movedFolder -ChildPath 'Top.txt') | Should -Be 'Top' + Get-Content -LiteralPath (Join-Path -Path $movedFolder -ChildPath 'Nested\Inner.txt') | Should -Be 'Inner' + } + + It 'Should rename an item in place on the same volume' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'RenameSource' + Set-Content -LiteralPath $file -Value 'RenameContent' + $renamed = Join-Path -Path (Split-Path -Path $file -Parent) -ChildPath 'RenameTarget.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $renamed + + $result = Move-Item2 -Path $file -Destination $renamed -PassThru $true -ErrorAction Stop + + $file | Should -Not -Exist + $renamed | Should -Exist + Get-Content -LiteralPath $renamed | Should -Be 'RenameContent' + $result.FullName | Should -Be $renamed + } + + # Unlike Copy-Item2 -Force, which merges into an existing folder of the same name (ItemCmdlets.Tests.ps1:681), + # the cmdlet page documents that Move-Item2 -Force "overwrites an existing destination item" and runs with the + # ReplaceExisting option (Move-Item2.md, -Force and the Notes section); a non-empty folder at the destination + # confirms that this means a full replace, not a merge, which a probe showed first (the existing folder and the + # file it held were gone afterwards, and the moved folder took its place with no error). + It 'Should replace an existing folder outright with -Force, not merge it, and write no error' { + $sourceFolder = New-TestSandboxItem -Sandbox $sandbox -Name 'MoveForceFolderSource' -Directory + $destinationParent = New-TestSandboxItem -Sandbox $sandbox -Name 'MoveForceFolderDestinationParent' -Directory + $existingFolder = Join-Path -Path $destinationParent -ChildPath (Split-Path -Path $sourceFolder -Leaf) + Assert-TestSandboxPath -Sandbox $sandbox -Path $existingFolder + New-Item -ItemType Directory -Path $existingFolder | Out-Null + $sourceContent = Join-Path -Path $sourceFolder -ChildPath 'FromSource.txt' + $existingContent = Join-Path -Path $existingFolder -ChildPath 'AlreadyThere.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $sourceContent, $existingContent + Set-Content -LiteralPath $sourceContent -Value 'FromSource' + Set-Content -LiteralPath $existingContent -Value 'AlreadyThere' + + $result = Move-Item2 -Path $sourceFolder -Destination $destinationParent -Force -PassThru $true -ErrorVariable itemErrors -ErrorAction SilentlyContinue + + $itemErrors | Should -BeNullOrEmpty + $result | Should -BeOfType [Alphaleonis.Win32.Filesystem.DirectoryInfo] + $result.FullName | Should -Be $existingFolder + [IO.Directory]::Exists($sourceFolder) | Should -BeFalse + Get-Content -LiteralPath (Join-Path -Path $existingFolder -ChildPath 'FromSource.txt') | Should -Be 'FromSource' + [IO.File]::Exists($existingContent) | Should -BeFalse + } +} + + +Describe 'Remove-Item2 defaults, aliases, and error details' { + It 'Should do nothing when -Path is omitted' { + $omittedPathFolder = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveOmittedPath' -Directory + $kept = Join-Path -Path $omittedPathFolder -ChildPath 'Kept.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $kept + Set-Content -LiteralPath $kept -Value 'Kept' + + Push-Location -LiteralPath $omittedPathFolder + try { + $result = @(Remove-Item2 -PassThru -ErrorVariable removeErrors -ErrorAction SilentlyContinue) + } + finally { + Pop-Location + } + + $removeErrors | Should -BeNullOrEmpty + $result | Should -BeNullOrEmpty + $kept | Should -Exist + $omittedPathFolder | Should -Exist + } + + It 'Should remove the file through the alias' -ForEach @( + @{ Alias = 'rm2' } + @{ Alias = 'del2' } + ) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name "RemoveAlias$Alias" + + & $Alias -Path $file -ErrorAction Stop + + $file | Should -Not -Exist + } + + It 'Should write FileNotFound with its category and target for a path that does not exist' { + $missing = Join-Path -Path $sandbox -ChildPath ('RemoveMissing-{0}' -f [guid]::NewGuid().ToString('N').Substring(0, 8)) + + Remove-Item2 -Path $missing -ErrorVariable removeErrors -ErrorAction SilentlyContinue + + $removeErrors | Should -HaveCount 1 + $removeErrors[0].FullyQualifiedErrorId | Should -BeLike 'FileNotFound,*' + $removeErrors[0].CategoryInfo.Category | Should -Be 'ObjectNotFound' + $removeErrors[0].TargetObject | Should -Be $missing + } + + It 'Should write nothing for a successful removal without -PassThru' { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveQuiet' + + $result = @(Remove-Item2 -Path $file -ErrorAction Stop) + + $result | Should -BeNullOrEmpty + $file | Should -Not -Exist + } + + # The generic catch of RemoveItem2.cs (category NotSpecified) is reached by anything that is not an IOException; + # a Deny ACE for Delete makes the file system refuse with an UnauthorizedAccessException, a sibling of + # IOException, not a subclass of it, so it falls through to that generic branch instead of the InvalidData one + # that the locked-file and non-empty-folder cases already cover (Remove-Item2.Tests.ps1:36,97). NTFS allows a + # delete when either the file itself allows it or its parent grants DeleteSubdirectoriesAndFiles, so a dedicated + # folder denies the second route as well; otherwise the inherited folder permission of the sandbox still lets the + # delete through despite the Deny entry on the file alone (seen first-hand: no error was written). + It 'Should write DeleteError with category NotSpecified when a Deny ACE refuses the delete' { + $deniedFolder = New-TestSandboxItem -Sandbox $sandbox -Name 'RemoveAccessDeniedFolder' -Directory + $file = Join-Path -Path $deniedFolder -ChildPath 'Denied.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $file + Set-Content -LiteralPath $file -Value 'Denied' + Add-TestDenyRule -Sandbox $sandbox -Path $deniedFolder -Rights @{ 'S-1-1-0' = 'DeleteSubdirectoriesAndFiles' } + Add-TestDenyRule -Sandbox $sandbox -Path $file -Rights @{ 'S-1-1-0' = 'Delete' } + + Remove-Item2 -Path $file -ErrorVariable removeErrors -ErrorAction SilentlyContinue + + $removeErrors | Should -HaveCount 1 + $removeErrors[0].FullyQualifiedErrorId | Should -BeLike 'DeleteError,*' + $removeErrors[0].CategoryInfo.Category | Should -Be 'NotSpecified' + $removeErrors[0].TargetObject | Should -Be $file + $removeErrors[0].Exception | Should -BeOfType [System.UnauthorizedAccessException] + $file | Should -Exist + } +} + +Describe 'Test-Path2 with several path shapes' { + It 'Should accept several piped objects and bind -PathType by property name for each' { + $pipedFile = New-TestSandboxItem -Sandbox $sandbox -Name 'PipedPathTypeFile' + $pipedFolder = New-TestSandboxItem -Sandbox $sandbox -Name 'PipedPathTypeFolder' -Directory + $requests = @( + [pscustomobject]@{ FullName = $pipedFile; PathType = 'Leaf' } + [pscustomobject]@{ FullName = $pipedFile; PathType = 'Container' } + [pscustomobject]@{ FullName = $pipedFolder; PathType = 'Container' } + ) + + $result = @($requests | Test-Path2 -ErrorAction Stop) + + $result -join ',' | Should -Be 'True,False,True' + } + + It 'Should find an item whose name contains brackets' { + $bracketFile = Join-Path -Path $sandbox -ChildPath 'TestPathBracket[2].txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $bracketFile + Set-Content -LiteralPath $bracketFile -Value 'Bracket' + + Test-Path2 -Path $bracketFile -PathType Leaf -ErrorAction Stop | Should -BeTrue + } + + It 'Should find an item through a UNC path of the administrative share' -Skip:(-not $canUseAdminShare) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'UncPathFile' + $uncPath = ConvertTo-TestAdminSharePath -Sandbox $sandbox -Path $file + + Test-Path2 -Path $uncPath -PathType Leaf -ErrorAction Stop | Should -BeTrue + } +} + +Describe 'Get-FileHash2 binds -Algorithm by property name' { + It 'Should hash with the algorithm bound by property name from the pipeline' { + # Stored in a variable, like FileHash.Tests.ps1 and the MD5 call above: PSScriptAnalyzer flags a literal + # weak-algorithm argument, not one that comes from a variable. + $propertyAlgorithm = 'MD5' + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'HashAlgorithmByProperty' + Set-Content -LiteralPath $file -Value 'AlgorithmPropertyContent' + $request = [pscustomobject]@{ FullName = $file; Algorithm = $propertyAlgorithm } + + $result = $request | Get-FileHash2 -ErrorAction Stop + + $result.Algorithm | Should -Be $propertyAlgorithm + $result.Hash | Should -BeExactly (Get-FileHash -LiteralPath $file -Algorithm $propertyAlgorithm).Hash + } +} + +Describe 'Get-DiskSpace free-space values and validation error' { + It 'Should report free-space values close to an independent .NET read for the system drive' { + $before = New-Object -TypeName 'System.IO.DriveInfo' -ArgumentList $env:SystemDrive + + $result = Get-DiskSpace -DriveLetter $env:SystemDrive -ErrorAction Stop + + $after = New-Object -TypeName 'System.IO.DriveInfo' -ArgumentList $env:SystemDrive + # Free space can change between two live reads of a system drive under normal system activity, so the + # comparison admits a tolerance around the values read immediately before and after the call, instead of + # exact equality; the tolerance is still tight enough to catch a swapped or a wrong field. + $tolerance = 500MB + $result.TotalNumberOfFreeBytes | Should -BeGreaterOrEqual ([Math]::Min($before.TotalFreeSpace, $after.TotalFreeSpace) - $tolerance) + $result.TotalNumberOfFreeBytes | Should -BeLessOrEqual ([Math]::Max($before.TotalFreeSpace, $after.TotalFreeSpace) + $tolerance) + $result.FreeBytesAvailable | Should -BeGreaterOrEqual ([Math]::Min($before.AvailableFreeSpace, $after.AvailableFreeSpace) - $tolerance) + $result.FreeBytesAvailable | Should -BeLessOrEqual ([Math]::Max($before.AvailableFreeSpace, $after.AvailableFreeSpace) + $tolerance) + } + + # PowerShell's own parameter-validation engine raises this error before the cmdlet runs, so unlike the cmdlet's + # own WriteError calls it carries no target object; this pins that engine behavior as observed, not assumed. + It 'Should write the validation error for a bad -DriveLetter with its category, and no target object' { + $caught = $null + + try { + Get-DiskSpace -DriveLetter 'C' -ErrorAction Stop + } + catch { + $caught = $_ + } + + $caught.FullyQualifiedErrorId | Should -Be 'ParameterArgumentValidationError,NTFSSecurity.GetDiskSpace' + $caught.CategoryInfo.Category | Should -Be 'InvalidData' + $caught.TargetObject | Should -BeNullOrEmpty + } +} + +Describe 'New-NTFSHardLink positional binding, FileInfo input, and share errors' { + It 'Should bind -Path and -Target positionally' { + $target = New-TestSandboxItem -Sandbox $sandbox -Name 'PositionalHardLinkTarget' + Set-Content -LiteralPath $target -Value 'PositionalTarget' + $link = Join-Path -Path $sandbox -ChildPath 'PositionalHardLinkLink.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $link + + New-NTFSHardLink $link $target -ErrorAction Stop + + $link | Should -Exist + Get-Content -LiteralPath $link | Should -Be 'PositionalTarget' + } + + # Target has no FullName alias, so a FileInfo object can reach it only by value, through the + # FileSystemPathTransformationAttribute of BaseCmdlets.cs, which reads .FullName for a FileSystemInfo argument. + # -Path is bound explicitly by name here, which keeps it out of the pipeline-by-value binding so that the piped + # FileInfo binds to -Target only. + It 'Should accept a FileInfo object piped by value for -Target' { + $target = New-TestSandboxItem -Sandbox $sandbox -Name 'FileInfoBoundTarget' + Set-Content -LiteralPath $target -Value 'FileInfoTarget' + $targetItem = Get-Item2 -Path $target -ErrorAction Stop + $link = Join-Path -Path $sandbox -ChildPath 'FileInfoBoundLink.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $link + + $targetItem | New-NTFSHardLink -Path $link -ErrorAction Stop + + $link | Should -Exist + Get-Content -LiteralPath $link | Should -Be 'FileInfoTarget' + } + + # The existing share test (Links.Tests.ps1:150) asserts only the ID of this error. + It '-PassThru should write GetHardLinkError with category ReadError and the link path as target on a network share' -Skip:(-not $canUseAdminShare) { + $target = New-TestSandboxItem -Sandbox $sandbox -Name 'ShareCategoryTarget' + $link = Join-Path -Path $sandbox -ChildPath 'ShareCategoryLink.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $link + $sharePath = ConvertTo-TestAdminSharePath -Sandbox $sandbox -Path $link + $shareTarget = ConvertTo-TestAdminSharePath -Sandbox $sandbox -Path $target + + $result = @(New-NTFSHardLink -Path $sharePath -Target $shareTarget -PassThru -ErrorVariable linkErrors -ErrorAction SilentlyContinue) + + $link | Should -Exist + $linkErrors | Should -HaveCount 1 + $linkErrors[0].FullyQualifiedErrorId | Should -BeLike 'GetHardLinkError,*' + $linkErrors[0].CategoryInfo.Category | Should -Be 'ReadError' + $linkErrors[0].TargetObject | Should -Be $sharePath + $result | Should -BeNullOrEmpty + } +} + +Describe 'Get-NTFSHardLink category on a network share' { + # The existing share test (Links.Tests.ps1:247) asserts the ID and the target but not the category. + It 'Should write GetHardLinkError with category ReadError for a file on a network share' -Skip:(-not $canUseAdminShare) { + $file = New-TestSandboxItem -Sandbox $sandbox -Name 'HardLinkShareCategoryFile' + $sharePath = ConvertTo-TestAdminSharePath -Sandbox $sandbox -Path $file + + Get-NTFSHardLink -Path $sharePath -ErrorVariable linkErrors -ErrorAction SilentlyContinue + + $linkErrors | Should -HaveCount 1 + $linkErrors[0].FullyQualifiedErrorId | Should -BeLike 'GetHardLinkError,*' + $linkErrors[0].CategoryInfo.Category | Should -Be 'ReadError' + $linkErrors[0].TargetObject | Should -Be $sharePath + } +} + +Describe 'New-NTFSSymbolicLink positional binding and folder links' { + It 'Should bind -Path and -Target positionally' -Skip:(-not $canCreateSymbolicLinks) { + $target = New-TestSandboxItem -Sandbox $sandbox -Name 'PositionalSymbolicTarget' + Set-Content -LiteralPath $target -Value 'PositionalSymbolicTarget' + $link = Join-Path -Path $sandbox -ChildPath 'PositionalSymbolicLink.txt' + Assert-TestSandboxPath -Sandbox $sandbox -Path $link + + New-NTFSSymbolicLink $link $target -ErrorAction Stop + + (Get-Item -LiteralPath $link -Force).LinkType | Should -Be 'SymbolicLink' + Get-Content -LiteralPath $link | Should -Be 'PositionalSymbolicTarget' + } + + # The existing folder-link test (Links.Tests.ps1:304) checks only the Directory attribute and reachability; only + # the file-link test (Links.Tests.ps1:297) asserts LinkType and the resolved target. + It 'Should set LinkType and the resolved target for a link to a folder' -Skip:(-not $canCreateSymbolicLinks) { + $targetFolder = New-TestSandboxItem -Sandbox $sandbox -Name 'FolderLinkTypeTarget' -Directory + $link = Join-Path -Path $sandbox -ChildPath 'FolderLinkType' + Assert-TestSandboxPath -Sandbox $sandbox -Path $link + + New-NTFSSymbolicLink -Path $link -Target $targetFolder -ErrorAction Stop + + (Get-Item -LiteralPath $link -Force).LinkType | Should -Be 'SymbolicLink' + # Windows PowerShell returns the target as an array, PowerShell 7 as a string (Links.Tests.ps1:286). + @((Get-Item -LiteralPath $link -Force).Target)[0] | Should -Be $targetFolder + } +} + +Describe 'Enable-Privileges -PassThru privilege states' { + AfterEach { + # Leaves the process token as the rest of the file expects it, regardless of which assertion above failed. + Disable-Privileges -ErrorAction SilentlyContinue -WarningAction SilentlyContinue | Out-Null + } + + # Only Get-Privileges was used as the read-back before; this reads the four states directly from the -PassThru + # objects and cross-checks them against whoami /priv, an independent source outside the module. + It 'Should report Enabled for TakeOwnership, Restore, Backup, and Security in -PassThru, matching whoami /priv' -Skip:(-not $isElevated) { + $result = @(Enable-Privileges -PassThru -ErrorAction Stop) + $whoamiPrivileges = & whoami.exe /priv /fo csv | Select-Object -Skip 1 | ConvertFrom-Csv -Header 'Name', 'Description', 'State' + + foreach ($name in 'TakeOwnership', 'Restore', 'Backup', 'Security') { + $entry = @($result | Where-Object -Property Privilege -EQ -Value $name) + $entry | Should -HaveCount 1 -Because "Enable-Privileges -PassThru should list $name" + $entry[0].PrivilegeState | Should -Be 'Enabled' + + $whoamiEntry = @($whoamiPrivileges | Where-Object -Property Name -EQ -Value ('Se{0}Privilege' -f $name)) + # The State column is localized; only the rows that whoami prints in English are compared (Get-Privileges + # compared with an independent source, above). + if ($whoamiEntry.Count -gt 0 -and $whoamiEntry[0].State -in 'Enabled', 'Disabled') { + $whoamiEntry[0].State | Should -Be 'Enabled' + } + } + } +} + +Describe 'Disable-Privileges -PassThru privilege states' { + It 'Should report Disabled for TakeOwnership, Restore, and Backup in -PassThru after a successful disable' -Skip:(-not $isElevated) { + Enable-Privileges -ErrorAction Stop + + $result = @(Disable-Privileges -PassThru -ErrorAction Stop) + + foreach ($name in 'TakeOwnership', 'Restore', 'Backup') { + $entry = @($result | Where-Object -Property Privilege -EQ -Value $name) + $entry | Should -HaveCount 1 -Because "Disable-Privileges -PassThru should list $name" + $entry[0].PrivilegeState | Should -Be 'Disabled' + } + } +} + +# NOT DONE: "the Removed PrivilegeState value" (WORKLIST row 14) is not reachable through Get-Privileges. A probe +# (elevated, PowerShell 7) that removed the Backup privilege of a child process and compared Get-Privileges before +# and after showed the entry disappear from the list entirely instead of remaining with PrivilegeState=Removed: +# BEFORE: ...,Backup=0,Restore=0,... AFTER: ...,Restore=0,... (Backup is gone, not listed as Removed) +# Privileges.GetPrivileges(accessTokenHandle) (ProcessPrivileges\Privileges.cs) builds its collection only from what +# GetTokenInformation(TokenPrivileges) currently returns, which drops an entry once SE_PRIVILEGE_REMOVED takes effect +# (matching the documented Win32 behavior of that attribute); the Removed state is produced only by the unrelated +# Privileges.GetPrivilegeAttributes(privilege, collection) lookup helper, which Get-Privileges never calls. No input +# makes Get-Privileges itself emit a Removed entry, so this item is left out instead of pinning a test that cannot +# exercise the branch it names; this is a structural gap of the same kind as the "Limit" rows, not a defect of a +# cmdlet, so nothing is reported under DEFECTS. +