Artykuł

Export-CliXML and Import-CliXML serialization woes

I've been working today trying to deliver to one of my Clients Active Directory documentation. To my surprise, something that worked fine for a very long time has started to provide weird results. So, after spending about 8 hours taking apart a few of my PowerShell modules trying to find out what is wrong finally, I've found it: Export-CliXML / Import-CliXML were behaving in a way I didn't expect them to. But let's start from the beginning, shall we? Those two commands are great. I've used them multiple times with great success (or so I thought). It all started with the following Word Table showing 1 and 0 in places where I've expected Domain Local or Universal Groups, and I had no clue where to look, as most of my tests were working great, just this one customer data was delivering some weird results.

Expected result

Export-CliXML and Import-CliXML - What those are for?

Those commands are handy for a lot of scenarios, but I've mostly used them for two things. I am exporting data to XML from servers to work on data on my workstation or exporting data to file so that I don't have to redownload the data again and again if I need to work on it. It takes a lot of time to ask Active Directory or Office 365 for all the data it has so Export-CliXML is useful that way. You would typically use them as follows:

$Forest | Export-Clixml -LiteralPath $PSScriptRoot\Test.xml -Depth 5 # Used offsite
$Forest = Import-Clixml -LiteralPath $PSScriptRoot\Test.xml # used on my machine

One would be executed on a server, and then I would copy the file over and use it locally using Import-CliXML, Of course, you can also use it locally. Ask your AD or Office 365 for data, wait for 30 minutes for a task to finish and give me results. So instead of rerunning this task when I need the same data, over and over again, I import it from a file in a couple of seconds. Simple, and it works.

Export-CliXML and Import-CliXML - Where's the problem?

So if Export-CliXML and Import-CliXML are as good as you say they are why are you complaining? Well, as you probably guessed from the introduction those command are not always working as you expect them to.

$Groups = Get-ADGroup -filter *
$PrettierGroups = foreach ($Group in $Groups) {
    [PsCustomObject][ordered] @{
        'Group Name'     = $Group.Name
        'Group Category' = $Group.GroupCategory
        'Group Scope'    = $Group.GroupScope
        'Group SID'      = $Group.SID.Value
    }
}
$PrettierGroups

Looks nice right? I merely took Get-ADGroup output and rewrote it so that it looks more or less the way I want it to look. Now if I would want to look at just one object I would simply do this

$PrettierGroups[0]

And if I would like to see the property of it I would do this

$PrettierGroups[0].'Group Category'

So while everything looks great and expected I just wanted to show you one final output before we go and try Export-CliXML and Import-CliXML on this object.

Notice property value__ and guess what it the output is? 1. Looks familiar right?

Export-CliXML and Import-CliXML - In action!

We've nicely created $PrettierGroups object that we simply export to file.

$PrettierGroups | Export-Clixml -Depth 5 -LiteralPath $PSScriptRoot\Test.xml

Good so far? Yes, it's a very cool feature! Now let's try to load this file into same variable (or different if you wish)

$PrettierGroups = Import-Clixml -LiteralPath $PSScriptRoot\Test.xml
$PrettierGroups

Woila! We've now loaded external data on our machine and continue our work, or so I thought!

$PrettierGroups[0]

Behaves exactly as expected.

Let's try to ask for a single property

$PrettierGroups = Import-Clixml -LiteralPath $PSScriptRoot\Test.xml
$PrettierGroups[0].'Group Category'

And that's where things are not what I expected. Exactly same code, different output, 8 hours wasted taking apart all my modules and trying to find what I did wrong. You can try and get the Value of that object, which gives me what I needed.

$PrettierGroups[0].'Group Category'.Value

Not the same, is it? Right.

Export-CliXML and Import-CliXML - Why it does that?

So why it doesn't work? Let's see the type of value we're accessing

$PrettierGroups = Import-Clixml -LiteralPath $PSScriptRoot\Test.xml
$PrettierGroups[0].'Group Category'.GetType()
IsPublic IsSerial Name                                     BaseType
-------- -------- ----                                     --------
True     True     Int32                                    System.ValueType

Let's compare it to original

$Groups = Get-ADGroup -filter *
$PrettierGroups = foreach ($Group in $Groups) {
    [PsCustomObject][ordered] @{
        'Group Name'     = $Group.Name
        'Group Category' = $Group.GroupCategory
        'Group Scope'    = $Group.GroupScope
        'Group SID'      = $Group.SID.Value
    }
}
$PrettierGroups[0].'Group Category'.GetType()
IsPublic IsSerial Name                                     BaseType
-------- -------- ----                                     --------
True     True     ADGroupCategory                          System.Enum

See the difference? System.Enum is no longer Enum. While it wouldn't be a problem if it was just converted to string it outputs proper data to screen, but if you acceess it directly.

$PrettierGroups = Import-Clixml -LiteralPath $PSScriptRoot\Test.xml
$PrettierGroups[0].'Group Category'.GetType()
$PrettierGroups[0].'Group Category'.Value.GetType()
IsPublic IsSerial Name                                     BaseType
-------- -------- ----                                     --------
True     True     Int32                                    System.ValueType
True     True     String                                   System.Object

We can also check for TypeNameOfValue which tells us it's a deserialized object and the main reason for this issue.

$PrettierGroups = Import-Clixml -LiteralPath $PSScriptRoot\Test.xml
$PrettierGroups[0].PSObject.Properties.Item('Group Category')
MemberType      : NoteProperty
IsSettable      : True
IsGettable      : True
Value           : Security
TypeNameOfValue : Deserialized.Microsoft.ActiveDirectory.Management.ADGroupCategory
Name            : Group Category
IsInstance      : True

While Export-CliXml converted Enum to String, it does that in a way you need to be careful when accessing your data. It wasn't visible to me in DEBUG in VSCode either. The data would look exactly as expected, yet when my modules were accessing properties, they would deliver what you saw in the introduction. Now that I know of this problem, I can address this.

Export-CliXML and Import-CliXML - Workarounds

How do you address this? Well, there are three ways I can think of. One is to make it easy for Export-CliXML to export exactly what you want to see. In my case, I want to see String so I cast the data to a string.

$Groups = Get-ADGroup -filter *
$PrettierGroups = foreach ($Group in $Groups) {
    [PsCustomObject] @{
        'Group Name'     = $Group.Name
        'Group Category' = [string] $Group.GroupCategory
        'Group Scope'    = [string] $Group.GroupScope
        'Group SID'      = [string] $Group.SID.Value
    }
}
$PrettierGroups | Export-Clixml -LiteralPath $PSScriptRoot\Test.xml

After that, the data works appropriately, and no checking for Value is required. Alternatively, you can modify your code to check for Value of the property. It's not pretty, and it requires additional checks in place, but it does work. Not sure if there are any potential issues with that thou.

$PrettierGroups = Import-Clixml -LiteralPath $PSScriptRoot\Test.xml
if ($PrettierGroups[0].'Group Category'.Value) {
    $PrettierGroups[0].'Group Category'.Value
} else {
    $PrettierGroups[0].'Group Category'
}

A third way I found out by actually checking how XML file is built. As you can see below Enum value uses Int value and there's the value I care which I can get with ToString() method.

<Obj N="Group Scope" RefId="2">
  <TN RefId="2">
    <T>Microsoft.ActiveDirectory.Management.ADGroupScope</T>
    <T>System.Enum</T>
    <T>System.ValueType</T>
    <T>System.Object</T>
  </TN>
  <ToString>DomainLocal</ToString>
  <I32>0</I32>
</Obj>
$PrettierGroups[0].'Group Category'.ToString()

If you have a better solution, do let me know. Now I need to go (will take weeks) and update some of my modules with things I've learned today. Hope you did too! I will open an issue on PowerShell so maybe this can be better addressed in PowerShell 7, or maybe it won't. Time will tell.

Tags: , ,

This is a unique website which will require a more modern browser to work! Please upgrade today!