Saturday, May 18, 2013

Hacking ValidateSet

I guess I should start off this post by saying what I'm doing is a dirty hack, in no way supported, and in general a terrible idea. But it's also really awesome.

Occasionally I find it would be nice to be able to dynamically generate the values used in a ValidateSet attribute on a function parameter. Joel Bennett wrote a post a while back explaining how to build a custom validation attribute that could be written to include the ability to update the set list dynamically. Or you could even use his technique of using ValidateScript and throwing a custom error message to generate the set dynamically.

The problem with these other techniques is that ValidateSet comes with magic that they don't include. This magic provides tab-completion, error messages, and a listing of valid values in help, all for free. I actually started off my experiment by following Joel's post and implementing my own ValidateDynamicSetAttribute class that provided Add() and Remove() methods so the values could be changed on the fly.

But that class wouldn't have come with the magic of ValidateSet, and while I was using ILSpy to learn how ValidateSet was implemented, I discovered that it was based on a private string array and it turns out I had just learned how to access private members while I was at the PowerShell Summit 2013 (thanks Adam!).

The result is the following function that takes a FunctionInfo object (use Get-Command), the name of the parameter that is using ValidateSet, and the new set of valid inputs. It hacks its way into the command, locates the correct parameter, locates all the ValidateSet attributes on it, and rips into the heart of each one and replaces the private validValues array with the one provided in the -NewSet parameter.

001
002
003
004
005
006
007
008
009
010
011
012
013
014
015
016
017
018
019
020
021
022
023
024
025
026
027
028
029
030
031
032
033
034
035
036
037
038
039
040
041
042
043
044
045
046
047
048
049
050
051
052
053
054
055
056
057
058
059
060
061
062
063
064
065
066
067
068
069
070
071
072
073
074
075
076
077
078
079
080
081
082
083
084
085
<#   .SYNOPSIS     Replace the set of valid values on a funciton parameter that was defined     using ValidateSet.   .DESCRIPTION     Replace the set of valid values on a funciton parameter that was defined     using ValidateSet.   .PARAMETER Command     A FunctionInfo object for the command that has the parameter validation to     be updated. Get this using:          Get-Command -Name YourCommandName   .PARAMETER ParameterName     The name of the parameter that is using ValidateSet.      .PARAMETER NewSet     The new set of valid values to use for parameter validation.   .EXAMPLE     Define a test function:          PS> Function Test-Function {       param(         [ValidateSet("one")]         $P       )     }          PS> Update-ValidateSet -Command (Get-Command Test-Function) -ParameterName "P" -NewSet @("one","two")          After running Update-ValidateSet, Test-Function will accept the values "one"     and "two" as valid input for the -P parameter.   .OUTPUTS     Nothing   .NOTES     This function is updating a private member of ValidateSetAttribute and is     thus not following the rules of .Net and could break at any time. Use at     your own risk!          Author : Chris Duck   .LINK     http://blog.whatsupduck.net/2013/05/hacking-validateset.html #> function Update-ValidateSet {   [CmdletBinding(SupportsShouldProcess=$true)]   param(     [Parameter(Mandatory=$true)]     [ValidateNotNullOrEmpty()]     [System.Management.Automation.FunctionInfo]$Command,     [Parameter(Mandatory=$true)]     [ValidateNotNullOrEmpty()]     [string]$ParameterName,     [Parameter(Mandatory=$true)]     [ValidateNotNullOrEmpty()]     [String[]]$NewSet   )   #Find the parameter on the command object   $Parameter = $Command.Parameters[$ParameterName]   if($Parameter) {     #Find all of the ValidateSet attributes on the parameter     $ValidateSetAttributes = @($Parameter.Attributes | Where-Object {$_ -is [System.Management.Automation.ValidateSetAttribute]})     if($ValidateSetAttributes) {       $ValidateSetAttributes | ForEach-Object {         #Get the validValues private member of the ValidateSetAttribute class         $ValidValuesField = [System.Management.Automation.ValidateSetAttribute].GetField("validValues", [System.Reflection.BindingFlags]::NonPublic -bor [System.Reflection.BindingFlags]::Instance)         if($PsCmdlet.ShouldProcess("$Command -$ParameterName", "Set valid set to: $($NewSet -join ', ')")) {           #Update the validValues array on each instance of ValidateSetAttribute           $ValidValuesField.SetValue($_, $NewSet)         }       }     } else {       Write-Error -Message "Parameter $ParameterName in command $Command doesn't use [ValidateSet()]"     }   } else {     Write-Error -Message "Parameter $ParameterName was not found in command $Command"   } }

Thursday, February 21, 2013

SharePoint 2007 Wiki Pages Broken

One of our SharePoint wiki libraries got in a state where we couldn't edit any of the pages. When we clicked "Edit" we would get the document properties page instead of the wiki editor page. I believe the root cause was someone moving a page from another wiki and the page ended up in the library as a "document" instead of a "wiki page".

I found a solution here, but it required installing SharePoint Manager 2007 in order to change the ContentTypesEnabled property on the list. I wasn't really wanting to install any additional software, so I set out to determine if there was a way to modify the property using the SharePoint API, and it turns out there is.

The following script uses the SharePoint API to modify your list properties to enable the content types UI elements in your list settings in order to clean up your wiki content types. I ran it from a SharePoint server in my farm, I assume it's possible to run remotely but have no idea how that would be done.

001
002
003
004
005
006
[reflection.assembly]::LoadWithPartialName("Microsoft.SharePoint") $Site = [Microsoft.SharePoint.SPSite]("http://site.com") $Web = $Site.OpenWeb("pat/to/web") $WikiList = $Web.Lists["WikiListName"] $WikiList.ContentTypesEnabled = $true $WikiList.Update()

With this script, you can follow the instructions linked above to fix the content types on your existing documents, using the script instead of SharePoint Manager to enable editing the content types on your wiki library. Then you can delete the "Document" content type from your library.

After you have everything cleaned up, you can run the following script to return your wiki library to the default configuration.

001
002
003
004
005
006
[reflection.assembly]::LoadWithPartialName("Microsoft.SharePoint") $Site = [Microsoft.SharePoint.SPSite]("http://site.com") $Web = $Site.OpenWeb("pat/to/web") $WikiList = $Web.Lists["WikiListName"] $WikiList.ContentTypesEnabled = $false $WikiList.Update()

Thursday, April 5, 2012

Deploying WDS Across Domain Forests

I'm not going to cover how to setup your unattend file, or how to customize a PE image... there are plenty of people out there who have covered those topics. What I do want to cover here is how to edit your PE image so that you can force it to connect to a specific WDS server. This will help solve the problem where you want to deploy computers into two domains on the same subnet, but WDS only looks for prestaged computers objects on the domain it is joined to.

This process does require two WDS servers, one joined to each domain you want to deploy computers into. I'm going to refer to them as WDSPrimary and WDSSecondary. I'm assuming you have either WDSPrimary configured to listen to broadcast requests, or your DHCP server options 66 and 67 pointed at WDSPrimary and boot\x64\wdsnbp.com respectively. The challenge is to be able to add a boot image on WDSPrimary that tells setup.exe to contact WDSSecondary to deploy the image.

Extract Your Existing Boot Image

Assuming you already have a boot image customized for deploying to WDSPrimary, export that image to a work folder using the Windows Deployment Services management console. For this example, I'm going to use c:\PEBoot\SecondaryBoot.wim as the extracted file name.

Mount the Image

Using Windows 7 or Server 2008 R2, first create a mount folder to mount the wim image to. I'll use c:\PEBoot\mount. Then run the following command to mount the image (remember to launch your shell as Administrator)

dism /mount-wim /mountdir:c:\PEBoot\mount /wimfile:c:\PEBoot\SecondaryBoot.wim /index:2

Configure the PE Image

Window PE includes a file called winpeshl.ini (Win 8/2012 version) that you can use to specify custom applications to run instead of starting setup.exe automatically. We will use this file to start setup.exe with custom options that will tell it to connect to WDSSecondary instead of defaulting to the WDS server that it booted from (WDSPrimary).

Open notepad and create a new file with the following contents

[LaunchApps]
%SYSTEMDRIVE%\windows\system32\wpeinit.exe
%SYSTEMDRIVE%\setup.exe, "/wds /wdsdiscover /wdsserver:WDSSecondary.contoso.com"

Make sure you enclose multiple command-line parameters in double quotes when you specify them in winpeshl.ini or they will not be properly passed to the command.

Unmount the PE Image and Import it into WDS

Now we need to unmount the wim file and commit our changes. This is accomplished using the following command.

dism /unmount-wim /mountdir:c:\PEBoot\mount /commit

Now all that is left is to import the new boot image into the primary WDS server that all clients boot to. In this case, you would go to WDSPrimary and right click "Boot Images" and select "Add Boot Image". Now when you PXE boot, you will have an option to boot to the customized image that connects to WDSSecondary to deploy the operating system.

Friday, March 23, 2012

Re: Zero out Free Disk Space

We've been having weekly script club meetings at work where anyone who is interested gets together in a conference room and we all work on scripts together so we all have a chance to learn new techniques while solving our real-world problems at the same time. This week I developed a script to mimic the behavior of the SysInternals utility SDelete. The script will be used to reclaim thin-provisioned space from our SAN, and I wanted to develop it during script club to use as an example of how to use the classes in the System.IO namespace to get better IO performance from PowerShell.

One of my co-workers shared the script from the meeting with Don Jones and he was nice enough to post it on his blog along with a few comments about the script.

Don's blog doesn't seem to support public comments, so I wanted to take a few minutes to respond to his comments here. I've reproduced his comments (in italics below) along with my responses:

I wish the comment-based help was a bit more complete, but I love that they're using it!
This script was written in a "class" setting and I was focusing more on solving the task at hand than producing a share-able script. The parameter block and help content were actually thrown in at the very end of the session just as a quick "finishing touch" and were never even run. I agree that well documented scripts are important and I try to write more complete help on my scripts when I don't have an audience watching me code :)
They're overriding $PercentFree to always be 0.5 - not sure if that's on purpose
This is a bug due to the way I'm trying to teach programming at work. I've been encouraging people to use variables for things even when they are just starting a script and are still in "explore" mode, with the idea that this makes it easy to turn the script into a function just by deleting the explicit variable declarations and moving them up to a parameter block.
Yay! Error handling!
I really wanted to take the opportunity to highlight the proper way to deal with external resources (ie file handles, network ports, etc) for my team. I see a lot of people who either don't bother to close things or don't put the close statements in a finally block and end up leaking resources.
Yay! Variable-replacement-within-double-quotes instead of concatenation!
Really brilliant technique of creating an empty file and then deleting it - clever thinking.
Can't really take credit for this one, just replicating the way sdelete works :)
Kind of a lot of variables. Like, why put 64KB into $ArraySize, and then only use it once? Why not use use the literal 64KB? Doing so would make the script a bit more concise and possibly easier to read.
This again comes from my programming style. At the time I thought there was potential we might want to make $ArraySize a parameter so I built it from the start so that it would be easy to convert down the line if we decided to go that way. Also I wanted to make it very easy to modify so we could do some performance testing to determine what the optimal block size to write was. In general I try to avoid hard coding any values into the meat of my scripts to make them easier to modify if the needs change down the road.

After Further Review

The script that Don was kind enough to review was written live during class and I never even got to the point of saving the script to a file in my editor, much less cleaning it up to make it "enterprise class" or "share worthy". Some other rough areas I have noticed in it after having time to review it are:

  • It should probably test to make sure the file doesn't exist already so it doesn't clobber existing data
  • It should test to make sure the $Root path/volume exists
  • It should validate that $PercentFree is between 0 and 1
  • The delete command should probably be moved up into the finally block to ensure the file gets deleted
  • I also would consider putting the whole thing in a process block, adding a "Name" alias to $Root, and enabling "ValueFromPipelineByPropertyName" so you could do:

    gwmi Win32_Volume | Write-ZeroesToFreeSpace

The "Real" Script

I've sort of posted these last two blog entries in reverse order, as my previous post is the cleaned up version of the script that addresses Don's comments as well as my additional comments posted above.

Thanks again Don for taking the time to review our little script and give us the opportunity to share a little bit of our development process with the community!

A PowerShell Alternative to SDelete

Problem Background

Many storage appliances support thin provisioning by not storing large blocks of zeros on disk, thus saving expensive physical disks for actual data. One of the challenges of this feature is that Windows (or most file systems really) does not zero out space that was used by files that have been deleted. This means that over time as files are written an deleted, the amount of physical disk that is saved by thin provisioning grows smaller.

One popular solution (really the only one we've been able to find) is to use the SysInternals utility SDelete's -z switch to write zeros to all the free space, thus allowing the storage appliance to use all that empty space for something else. The challenge with sdelete is that it only supports volumes mounted as drive letters, not volumes that are mounted to folders. Another concern with SDelete is that it appears to fill up the entire disk, if only for that brief second between finishing writing the file out and deleting the file. We were concerned with using this tool at work on a workload like SQL that might not take kindly to a disk filling up.

Our storage administrator tried to solve this problem using the standard PowerShell techniques of redirecting null strings to files and found the performance to be somewhat lacking. I don't want to get into a detailed analysis of PowerShell IO characteristics (mostly because I haven't spent much time researching how PowerShell does IO), but instead just post that the solution is to turn to the System.IO .Net namespace and perform the IO operations the "hard way".

The Script

Here is the script I came up with using a FileStream to write out the zero file. We informally compared a couple of runs between this script and sdelete and saw roughly the same performance.

Write-ZeroesToFreeSpace.ps1

001
002
003
004
005
006
007
008
009
010
011
012
013
014
015
016
017
018
019
020
021
022
023
024
025
026
027
028
029
030
031
032
033
034
035
036
037
038
039
040
041
042
043
044
045
046
047
048
049
050
051
052
053
054
055
056
057
058
059
060
061
062
063
064
065
066
067
068
069
070
071
072
073
074
075
076
077
078
079
080
081
082
083
084
085
086
087
088
089
090
091
092
093
094
095
096
097
098
099
100
101
102
<# .SYNOPSIS  Writes a large file full of zeroes to a volume in order to allow a storage  appliance to reclaim unused space. .DESCRIPTION  Creates a file called ThinSAN.tmp on the specified volume that fills the  volume up to leave only the percent free value (default is 5%) with zeroes.  This allows a storage appliance that is thin provisioned to mark that drive  space as unused and reclaim the space on the physical disks.   .PARAMETER Root  The folder to create the zeroed out file in. This can be a drive root (c:\)  or a mounted folder (m:\mounteddisk). This must be the root of the mounted  volume, it cannot be an arbitrary folder within a volume.   .PARAMETER PercentFree  A float representing the percentage of total volume space to leave free. The  default is .05 (5%) .EXAMPLE  PS> Write-ZeroesToFreeSpace -Root "c:\"    This will create a file of all zeroes called c:\ThinSAN.tmp that will fill the  c drive up to 95% of its capacity.   .EXAMPLE  PS> Write-ZeroesToFreeSpace -Root "c:\MountPoints\Volume1" -PercentFree .1    This will create a file of all zeroes called  c:\MountPoints\Volume1\ThinSAN.tmp that will fill up the volume that is  mounted to c:\MountPoints\Volume1 to 90% of its capacity. .EXAMPLE  PS> Get-WmiObject Win32_Volume -filter "drivetype=3" | Write-ZeroesToFreeSpace    This will get a list of all local disks (type=3) and fill each one up to 95%  of their capacity with zeroes.   .NOTES  You must be running as a user that has permissions to write to the root of the  volume you are running this script against. This requires elevated privileges  using the default Windows permissions on the C drive. #> param(   [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)]   [ValidateNotNullOrEmpty()]   [Alias("Name")]   $Root,   [Parameter(Mandatory=$false)]   [ValidateRange(0,1)]   $PercentFree =.05 ) process{   #Convert the $Root value to a valid WMI filter string   $FixedRoot = ($Root.Trim("\") -replace "\\","\\") + "\\"   $FileName = "ThinSAN.tmp"   $FilePath = Join-Path $Root $FileName      #Check and make sure the file doesn't already exist so we don't clobber someone's data   if( (Test-Path $FilePath) ) {     Write-Error -Message "The file $FilePath already exists, please delete the file and try again"   } else {     #Get a reference to the volume so we can calculate the desired file size later     $Volume = gwmi win32_volume -filter "name='$FixedRoot'"     if($Volume) {       #I have not tested for the optimum IO size ($ArraySize), 64kb is what sdelete.exe uses       $ArraySize = 64kb       #Calculate the amount of space to leave on the disk       $SpaceToLeave = $Volume.Capacity * $PercentFree       #Calculate the file size needed to leave the desired amount of space       $FileSize = $Volume.FreeSpace - $SpacetoLeave       #Create an array of zeroes to write to disk       $ZeroArray = new-object byte[]($ArraySize)              #Open a file stream to our file       $Stream = [io.File]::OpenWrite($FilePath)       #Start a try/finally block so we don't leak file handles if any exceptions occur       try {         #Keep track of how much data we've written to the file         $CurFileSize = 0         while($CurFileSize -lt $FileSize) {           #Write the entire zero array buffer out to the file stream           $Stream.Write($ZeroArray,0, $ZeroArray.Length)           #Increment our file size by the amount of data written to disk           $CurFileSize += $ZeroArray.Length         }       } finally {         #always close our file stream, even if an exception occurred         if($Stream) {           $Stream.Close()         }         #always delete the file if we created it, even if an exception occurred         if( (Test-Path $FilePath) ) {           del $FilePath         }       }     } else {       Write-Error "Unable to locate a volume mounted at $Root"     }   } }

Wednesday, February 8, 2012

Powershell ValidateNotNullOrEmpty Bug

I was showing a co-worker how easy it is to ensure that the parameters to his script were actually being set using the [Parameter(Mandatory=$true)] and [ValidateNotNullOrEmpty()] decorators on his parameter declaration block, and we encountered a bug where he was able to pass an empty string as a parameter to his function and the validation did not catch it.

Reproduction Steps

In our search to explain what was going on, we located a couple of forum posts which led us to 2 bugs filed on connect that I believe are related to the same problem: 610176 and 677559.

Our steps mirrored 610176 almost exactly, so I'm going to copy the reproduction steps from that bug here, with a few changes.

001
002
003
004
005
006
007
008
009
010
Function test {   param([Parameter(Mandatory=$true)]         [ValidateNotNullOrEmpty()]         $param   )   $param.GetType().FullName   "Entered: '$param'"   [string]::IsNullOrEmpty($param) }

The expected result when you pass an empty string would be an error stating that the parameter failed vaildation, no matter how you generated that string:

PS> test ""
test : Cannot validate argument on parameter 'param'. The argument is null or empty. Supply an argument that is not null or empty and then try the command again.

However, when you call the function without any parameters, Powershell sees that you forgot a mandatory parameter and prompts you for a value. If you just press , the empty string incorrectly passes validation and your function is executed:

PS> test

cmdlet test at command pipeline position 1
Supply values for the following parameters:
param:<just press enter here>

System.String
Entered: ''
True

As you can see, the parameter is a string and it is an empty string, which should never have passed validation.

Explanation

Using Reflector, I tracked down the ValidateNotNullOrEmptyAttribute class (load System.Management.Automation from the GAC and then drill down to the System.Management.Automation namespace and then the Validate method on the ValidateNotNullOrEmptyAttribute class) and discovered the following code:


    ... (tests the arguments variable for null)

    str = arguments as string;
    if (str != null)
    {
        if (string.IsNullOrEmpty(str))
        {
            throw new ValidationMetadataException("ArgumentIsEmpty", null, "Metadata", ValidateNotNullOrEmptyFailure", new object[0]);
        }
    }
    else
    {

    ... (continues on to handle special cases for enumerable objects)

You can see they are using the C# as operator to attempt to convert the parameter into a string object. The problem is that Powershell uses an adaptive type system to work magic on some particularly annoying types (XML and WMI come to mind), and apparently the method that is reading the input when you forget to specify a mandatory parameter (and also the Read-Host cmdlet as demonstrated in 677559) are returning Powershell objects that look like strings, but aren't actual .Net strings.

Go Vote

If you're not a fan of this behavior, go vote for the bug on Connect. I've posted a comment with a link back to this post, so hopefully there is enough detail here to get the problem fixed in V3 :)

Digging Deeper

So I fired up Visual C# Express and wrote a little C# program that embeds a Powershell runspace and reproduces the problem, then extracts the variables and tests them in C# to see what types the objects really are. While I was testing different scenarios with my co-worker, I discovered you can also convert a string object to a Powershell adapted object by just referencing $MyString.PSBase, and this breaks ValidateNotNullOrEmpty just as badly as Read-Host, so I used this method in my C# application as it was easier to code than trying to work out how to get input from the C# console to the Powershell runtime properly.


using System;
using System.Collections.Generic;
using System.Text;
using System.Management.Automation;
using System.Management.Automation.Runspaces;

namespace ConsoleApplication1
{
  public class Program
  {
    public static void Main(string[] args)
    {
      using (Runspace rs = RunspaceFactory.CreateRunspace())
      {
        rs.Open();
        using (Pipeline pl = rs.CreatePipeline())
        {
          pl.Commands.AddScript("$str = \"\"");
          pl.Commands.AddScript("$pso = \"\"");
          pl.Commands.AddScript("$pso.psbase");
          pl.Invoke();
          Object oStr = rs.SessionStateProxy.GetVariable("str");
          Object oPso = rs.SessionStateProxy.GetVariable("pso");
          Console.WriteLine(string.Format("oStr type = {0}", oStr.GetType().FullName));
          Console.WriteLine(string.Format("oPso type = {0}", oPso.GetType().FullName));
          string sStr = oStr as string;
          if (sStr != null)
          {
            Console.WriteLine(string.Format("sStr.IsNullOrEmpty = {0}", string.IsNullOrEmpty(sStr)));
          }
          else
          {
            Console.WriteLine("oStr is not a string");
          }
          string sPso = oPso as string;
          if (sPso != null)
          {
            Console.WriteLine(string.Format("sPso.IsNullOrEmpty = {0}", string.IsNullOrEmpty(sPso)));
          }
          else
          {
            Console.WriteLine("oPso is not a string");
          }
        }
      }
    }
  }
}

The output is as follows:

oStr type = System.String
oPso type = System.Management.Automation.PSObject
sStr.IsNullOrEmpty = True
oPso is not a string

As you can see, the string->string conversion was successful (oStr->sStr variables), while the PSObject->string conversion was not (oPso->sPso). This results in the PSObject argument being treated as a regular object (which is not null) instead of a string, even though it is type-adapting a string object.

Saturday, January 28, 2012

Casting Objects to Boolean in Powershell

A question came up on the Powershell technet forum asking why an empty System.DirectoryServices.SearchResultCollection was evaluating to $true. The original post is HERE, but the gist of the question is this:

Why does an empty System.DirectoryServices.SearchResultCollection object evaluate to $true when used in an if statement (ie if($SearchResult)), but an empty System.Array and System.Collections.ArrayList both evaulate to $false?

I was aware that Powershell has some special rules for casting objects, but a search for exactly what those rule are only returned very generic terms for how collections were handled (ie this blog post and this book). Nothing specified exactly which types/interfaces were expanded as collections and which were not.

So I did a little digging and came up with the following...

The reason a SearchResultCollection evaluates to true even when it is empty is because it does not implement the IList interface:

PS> [System.DirectoryServices.SearchResultCollection].GetInterfaces()

IsPublic IsSerial Name
-------- -------- ----
True     False    ICollection
True     False    IEnumerable
True     False    IDisposable


PS> [System.Array].GetInterfaces()

IsPublic IsSerial Name
-------- -------- ----
True     False    ICloneable
True     False    IList
True     False    ICollection
True     False    IEnumerable


PS> [System.Collections.ArrayList].GetInterfaces()

IsPublic IsSerial Name
-------- -------- ----
True     False    IList
True     False    ICollection
True     False    IEnumerable
True     False    ICloneable

As you can see, both System.Array and System.Collections.ArrayList implement IList.  This is apparently what Powershell uses to determine if it should "look inside" when it converts the object to a boolean.

You can see (what I think is) the proof of this if you load up Reflector (dotPeek, ILSpy, and JustDecompile are free alternatives)

  1. Open System.Management.Automation from the GAC
  2. Expand the System.Management.Automation namespace
  3. Browse down to LanguagePrimitives
  4. Browse down to the IsTrue(object obj) method and decompile it

In there you can see the algorithm (which I am assuming is being used in this case) that converts objects to boolean.  The basic logic is:

  1. If it is null, return $false
  2. If it is a boolean, return the boolean
  3. If it is a string, return $false if it is empty, else return $true
  4. If it is a number, return $false if it is 0, else return $true
  5. If it is a SwitchParameter, call its own ToBool() method
  6. Convert it to an IList
    1. If this conversion fails, return true (meaning it was an object that was not null, not any of the "special" things above, and not a list for PS to count)
    2. If it is a list and has 0 elements, return $false
    3. If it is a list and has 1 element, return the IsTrue(list[0]) value (ie recurse on the one element and return its value
    4. If it is a list with more than 1 thing in it, return $true

As you can see, the Array and ArrayList fall into rules 6.ii-6.iv because they implement IList, whereas the SearchResultCollection falls into rule 6.i because it does not implement IList so the conversion to a list fails, which means it was a plain old non-null object which evaluates to $true in Powershell.