Hey, Scripting Guy! Creating CAB Files with Windows PowerShell

The Microsoft Scripting Guys

Contents

Expanding Cab Files
Using the Makecab.exe Utility

One of my favorite movies ("Johnny Mnemonic") was about a guy who was a data smuggler in the future. He would travel to various countries and meet with clients. He had a jack surgically implanted into the base of his head, and the customers would plug their Zune-looking device into the jack and transfer massive amounts of data into his brain. Because this data smuggler had his brain partitioned and was evidently using NTFS file permissions, he did not have rights to the data-smuggling portion of his brain. The data was therefore safe—even from the smuggler's own wandering thoughts. In one scene, the client wanted him to courier a massive amount of data, but evidently our smuggler did not have a large enough capacity to carry so much data. So what did he do? He compressed his brain—I love it!

Data compression is not only good fodder for movies, it is equally useful for network administrators. We all know, and most likely use, various file compression utilities. I use them every week in sending chapters of my new book by e-mail to my editor at Microsoft Press. I do this not so much because I am bandwidth constrained, but because we have e-mail quotas that restrict the size of the e-messages we can store, send, and receive. I have also used file compression utilities when I needed to archive files from my laptop to one of the several portable disks floating around my office.

When I used to travel around the world teaching scripting workshops, I would be asked routinely, "How can I compress files through scripting?" And I'd answer, "You need to buy a third-party utility that supports command-line switches." One day, I was reading through the registry, and I ran across a COM object named (interestingly enough) makecab.makecab. Hmm, what do you suppose that object does? Yep, you're right. It lets you make cabinet (.cab) files—highly compressed files used by various applications for packaging and deployment. But there is nothing to stop an enterprising network administrator or scripting guru from appropriating these tools for themselves. And that's just what we'll do. Let's start with the script in Figure 1.

Figure 1 CreateCab.ps1

Param(
$filepath = "C:\fso", 
$path = "C:\fso\aCab.cab",
[switch]$debug
)
Function New-Cab($path,$files)
{
$makecab = "makecab.makecab"
Write-Debug "Creating Cab path is: $path"
$cab = New-Object -ComObject $makecab
if(!$?) { $(Throw "unable to create $makecab object")}
$cab.CreateCab($path,$false,$false,$false)
ForEach ($file in $files)
{
$file = $file.fullname.tostring()
$fileName = Split-Path -path $file -leaf
Write-Debug "Adding from $file"
Write-Debug "File name is $fileName"
$cab.AddFile($file,$filename)
}
Write-Debug "Closing cab $path"
$cab.CloseCab()
} #end New-Cab

# *** entry point to script ***
if($debug) {$DebugPreference = "continue"}
$files = Get-ChildItem -path $filePath | Where-Object { !$_.psiscontainer }
New-Cab -path $path -files $files
CreateCab.ps1 –filepath C:\fso1

The first thing we need to do is to create some command-line parameters using the Param statement. The Param statement must be the first noncommented line in the script. When the script is run from within the Windows PowerShell console or from within a script editor, the command-line parameters are used to control the way the script executes. In this way, you don't have to edit the script each time you want to create a .cab file from a different directory. You need only supply a new value for the –filepath parameter, as shown here:

The good thing about command-line parameters is that they use partial parameter completion, which means you need to supply only enough of the parameter for it to be unique. Therefore, you could use a command-line syntax such as this:

CreateCab.ps1 –f c:\fso1 –p c:\fso2\bcab.cab –d

This syntax would search the c:\fso1 directory and obtain all the files. It would then create a cabinet file named bcab.cab in the fso2 folder off the c:\ drive. It would also produce debugging information while it was running. Note that the –debug parameter is a switched parameter, which means that it affects the script only when it is present. Here's the relevant section of the CreateCab.ps1 script:

Param(
  $filepath = "C:\fso", 
  $path = "C:\fso\aCab.cab",
  [switch]$debug
  )

Now we'll create the New-Cab function, which accepts two input parameters, –path and –files:

Function New-Cab($path,$files)

You can assign the program ID, makecab.makecab to a variable named $makecab, which will make the script a bit easier to read. This is also a good place to put the first Write-Debug statement:

{
 $makecab = "makecab.makecab"
 Write-Debug "Creating Cab path is: $path"

Next we will create the COM object:

 $cab = New-Object -ComObject $makecab

A bit of error checking is in order, which can be accomplished using the $? automatic variable:

 if(!$?) { $(Throw "unable to create $makecab object")}

If no errors occurred during the attempt to create the makecab.makecab object, you can use the object contained in the $cab variable and call the CreateCab method:

 $cab.CreateCab($path,$false,$false,$false)

Once the .cab file has been created, you can add files to it by using the ForEach statement:

 ForEach ($file in $files)
 {
 $file = $file.fullname.tostring()
 $fileName = Split-Path -path $file -leaf

After you have turned the full filename into a string and removed the directory information using the Split-Path cmdlet, you include another Write-Debug statement to let the user of the script know what's going on, this way:

 Write-Debug "Adding from $file"
 Write-Debug "File name is $fileName"

Next you add the file to the cabinet file:

 $cab.AddFile($file,$filename)
 }
 Write-Debug "Closing cab $path"

To close the cabinet file, you use the CloseCab method:

 $cab.CloseCab()
} #end New-Cab

Now we'll go to the entry point of the script. First we check to see whether the script is being run in debug mode by looking for the $debug variable. If the $debug variable is not present, you don't need to do anything. If it is present, you need to set the value of the $DebugPreference variable to continue, which allows the Write-Debug statements to be printed on the screen. By default, the $DebugPreference is set to silentlycontinue, which means skip past the command without doing anything. Here's the code:

if($debug) {$DebugPreference = "continue"}

Now you need to obtain a collection of files. To do this, you can use the Get-ChildItem cmdlet:

$files = Get-ChildItem -path $filePath | 
 Where-Object { !$_.psiscontainer }

Then you pass the collection to the New-Cab function as shown here:

New-Cab -path $path -files $files

When you run the CreateCab.ps1 script in debug mode, you'll see output as shown in Figure 2.

fig02.gif

Figure 2 Running CreateCab.ps1 in debug mode

Expanding Cab Files

You can't use the makecab.makecab object to expand the cabinet file because it doesn't have an expand method. Nor can you use the makecab.expandcab object because it doesn't exist. But the ability to expand a cabinet file is inherent in the Windows shell, so you can use the shell object. To access the shell, you can use the Shell.Application COM object, as shown in the ExpandCab.ps1 script in Figure 3.

Figure 3 ExpandCab.ps1

Param(
  $cab = "C:\fso\acab.cab",
  $destination = "C:\fso1",
  [switch]$debug
  )
Function ConvertFrom-Cab($cab,$destination)
{
 $comObject = "Shell.Application"
 Write-Debug "Creating $comObject"
 $shell = New-Object -Comobject $comObject
 if(!$?) { $(Throw "unable to create $comObject object")}
 Write-Debug "Creating source cab object for $cab"
 $sourceCab = $shell.Namespace($cab).items()
 Write-Debug "Creating destination folder object for $destination"
 $DestinationFolder = $shell.Namespace($destination)
 Write-Debug "Expanding $cab to $destination"
 $DestinationFolder.CopyHere($sourceCab)
}

# *** entry point ***
if($debug) { $debugPreference = "continue" }
ConvertFrom-Cab -cab $cab -destination $destination

First the script creates the command-line parameters, much as CreateCab.ps1 did:

Param(
  $cab = "C:\fso\acab.cab",
  $destination = "C:\fso1",
  [switch]$debug
  )

Next it creates the ConvertFrom-Cab function, which accepts two command-line parameters, one that contains the .cab file, and one that contains the destination to expand the files:

Function ConvertFrom-Cab($cab,$destination)

Now you create an instance of the Shell.Application object, a very powerful object with a number of useful methods. Figure 4 shows the members of the Shell.Application object.

Figure 4 Members of the Shell.Application object
Name MemberType Definition
AddToRecent Method void AddToRecent (Variant, string)
BrowseForFolder Method Folder BrowseForFolder (int, string, int, Variant)
CanStartStopService Method Variant CanStartStopService (string)
CascadeWindows Method void CascadeWindows ()
ControlPanelItem Method void ControlPanelItem (string)
EjectPC Method void EjectPC ()
Explore Method void Explore (Variant)
ExplorerPolicy Method Variant ExplorerPolicy (string)
FileRun Method void FileRun ()
FindComputer Method void FindComputer ()
FindFiles Method void FindFiles ()
FindPrinter Method void FindPrinter (string, string, string)
GetSetting Method bool GetSetting (int)
GetSystemInformation Method Variant GetSystemInformation (string)
Help Method void Help ()
IsRestricted Method int IsRestricted (string, string)
IsServiceRunning Method Variant IsServiceRunning (string)
MinimizeAll Method void MinimizeAll ()
NameSpace Method Folder NameSpace (Variant)
Open Method void Open (Variant)
RefreshMenu Method void RefreshMenu ()
ServiceStart Method Variant ServiceStart (string, Variant)
ServiceStop Method Variant ServiceStop (string, Variant)
SetTime Method void SetTime ()
ShellExecute Method void ShellExecute (string, Variant, Variant, Variant, Variant)
ShowBrowserBar Method Variant ShowBrowserBar (string, Variant)
ShutdownWindows Method void ShutdownWindows ()
Suspend Method void Suspend ()
TileHorizontally Method void TileHorizontally ()
TileVertically Method void TileVertically ()
ToggleDesktop Method void ToggleDesktop ()
TrayProperties Method void TrayProperties ()
UndoMinimizeALL Method void UndoMinimizeALL ()
Windows Method IDispatch Windows ()
WindowsSecurity Method void WindowsSecurity ()
Application Property IDispatch Application () {get}
Parent Property IDispatch Parent () {get}

Because you will want to use the name of the COM object more than once, it is a good practice to assign the program ID of the COM object to a variable. You will be able to use the string with the New-Object cmdlet, as well as when providing feedback to the user. Here's the line of code that assigns the Shell.Application program ID to a string.

{
 $comObject = "Shell.Application"

To provide feedback, you can use the Write-Debug cmdlet with a message that you are attempting to create the Shell.Application object:

 Write-Debug "Creating $comObject"

Next we'll actually create the object:

 $shell = New-Object -Comobject $comObject

Then we test for errors. To do this, you can use the automatic variable $?, which tells you if the last command completed successfully. It's a Boolean true/false. You can use this fact to simplify the coding. You use the not operator, !, in conjunction with an if statement. If the variable is not true, you use the Throw statement to raise an error and halt execution of the script, this way:

 if(!$?) { $(Throw "unable to create $comObject object")}

If the script successfully creates the Shell.Application object, we provide some feedback:

 Write-Debug "Creating source cab object for $cab"

The next step in the operation is to connect to the .cab file. To do this, you can use the Namespace method from the Shell.Application object. This is another important step, so it makes sense to use another Write-Debug statement as a progress indicator for the user:

 $sourceCab = $shell.Namespace($cab).items()
 Write-Debug "Creating destination folder object for $destination"

Now we connect to the destination folder. To do this, you use the Namespace method, then another Write-Debug statement to let the user know which folder was actually connected to:

 $DestinationFolder = $shell.Namespace($destination)
 Write-Debug "Expanding $cab to $destination"

With all that preparation out of the way, the actual command for expanding the cabinet file is somewhat anticlimactic. You use the CopyHere method from the folder object that is stored in the $DestinationFolder variable. You give the reference to the .cab file that is stored in the $sourceCab variable as the input parameter this way:

 $DestinationFolder.CopyHere($sourceCab)
}

The starting point to the script does two things. First, it checks for the presence of the $debug variable. If $debug is present, it sets the $debugPreference to continue to force the Write-Debug cmdlet to print out messages to the console window. Second, it calls the ConvertFrom-Cab function and passes the path to the cab file from the –cab command-line parameter and the destination for the expanded files from the –destination parameter:

if($debug) { $debugPreference = "continue" }
ConvertFrom-Cab -cab $cab -destination $destination

When you run the ExpandCab.ps1 script in debug mode, you will see output similar to that in Figure 5.

fig05.gif

Figure 5 Running ExpandCab.ps1 in debug mode

Using the Makecab.exe Utility

If you run these two scripts on Windows Server 2003 or Windows XP, you won't have any problems, but the makecab.makecab COM object doesn't exist on Windows Vista or later. This misfortune doesn't stop the determined scripter, however, because you can always use the makecab.exe utility from the command line. To do so, you can use the CreateCab2.ps1 script, shown in Figure 6.

Figure 6 CreateCab2.ps1

Param(
  $filepath = "C:\fso", 
  $path = "C:\fso1\cabfiles",
  [switch]$debug
  )
Function New-DDF($path,$filePath)
{
 $ddfFile = Join-Path -path $filePath -childpath temp.ddf
 Write-Debug "DDF file path is $ddfFile"
 $ddfHeader =@"
;*** MakeCAB Directive file
;
.OPTION EXPLICIT      
.Set CabinetNameTemplate=Cab.*.cab
.set DiskDirectory1=C:\fso1\Cabfiles
.Set MaxDiskSize=CDROM
.Set Cabinet=on
.Set Compress=on
"@
 Write-Debug "Writing ddf file header to $ddfFile" 
 $ddfHeader | Out-File -filepath $ddfFile -force -encoding ASCII
 Write-Debug "Generating collection of files from $filePath"
 Get-ChildItem -path $filePath | 
 Where-Object { !$_.psiscontainer } |
 ForEach-Object `
 { 
 '"' + $_.fullname.tostring() + '"' | 
 Out-File -filepath $ddfFile -encoding ASCII -append
 }
 Write-Debug "ddf file is created. Calling New-Cab function"
 New-Cab($ddfFile)
} #end New-DDF

Function New-Cab($ddfFile)
{
 Write-Debug "Entering the New-Cab function. The DDF File is $ddfFile"
 if($debug)
 { makecab /f $ddfFile /V3 }
 Else
 { makecab /f $ddfFile }
} #end New-Cab

# *** entry point to script ***
if($debug) {$DebugPreference = "continue"}
New-DDF -path $path -filepath $filepath

As with the other scripts, CreateCab2.ps1 first creates a couple of command-line parameters:

Param(
  $filepath = "C:\fso", 
  $path = "C:\fso1\cabfiles",
  [switch]$debug
  )

When you run the script with the –debug switch, the output will be similar to what is shown in Figure 7.

fig07.gif

Figure 7 Running CreateCab2.ps1 in debug mode

Next the script creates the New-DDF function, which creates a basic .ddf file that is used by the MakeCab.exe program to create the .cab file. The syntax for these types of files is documented in the Microsoft Cabinet Software Development Kit. After you use the function keyword to create the New-DDF function, you use the Join-Path cmdlet to create the file path to the temporary .ddf file. You could concatenate the drive, the folder, and the filename together, but this could be a cumbersome and error-prone operation. As a best practice, you should always use the Join-Path cmdlet to build your file paths, this way:

Function New-DDF($path,$filePath)
{
 $ddfFile = Join-Path -path $filePath -childpath temp.ddf

To provide a bit of feedback to the user if the script is run with the—debug switch, use the Write-Debug cmdlet as shown here:

 Write-Debug "DDF file path is $ddfFile"

Now you need to create the first portion of the .ddf file. To do this, you can use an expanding here-string, which means you won't have to concern yourself with escaping special characters. As an example, comments in a .ddf file are prefaced with a semicolon, which is a reserved character in Windows PowerShell. If you were to try to create this text without a here-string, you would need to escape each of the semicolons to avoid compile-time errors. By using an expanding here-string, you can take advantage of the expansion of variables. A here-string begins with the ampersand and a quotation mark, and ends with a quotation mark and an ampersand:

 $ddfHeader =@"
;*** MakeCAB Directive file
;
.OPTION EXPLICIT      
.Set CabinetNameTemplate=Cab.*.cab
.set DiskDirectory1=C:\fso1\Cabfiles
.Set MaxDiskSize=CDROM
.Set Cabinet=on
.Set Compress=on
"@

Next you may want to add feedback via the Write-Debug cmdlet:

 Write-Debug "Writing ddf file header to $ddfFile" 

Now we come to the part that could cause some problems. The .ddf file must be pure ASCII. By default, Windows PowerShell uses Unicode. To ensure that you have an ASCII file, you must use the Out-File cmdlet. Most of the time, you can avoid using Out-File by using the file redirection arrows, but this is not one of those occasions. Here's the syntax:

 $ddfHeader | Out-File -filepath $ddfFile -force 
-encoding ASCII

You probably want to provide some more debug information with Write-Debug before you gather your collection of files via the Get-ChildItem cmdlet:

 Write-Debug "Generating collection of files from $filePath"
 Get-ChildItem -path $filePath | 

It is important to filter out folders from the collection because MakeCab.exe is not able to compress folders. To do this, use the Where-Object with a not operator that says the object is not a container:

 Where-Object { !$_.psiscontainer } |

Next, you need to work with each file as it comes across the pipeline. To do this, use the ForEach-Object cmdlet. Because ForEach-Object is a cmdlet, as opposed to a language statement, the curly brackets must be on the same line as the ForEach-Object cmdlet name. The problem with this is that it has a tendency to bury the curly brackets in the code. As a best practice, I like to line up the curly brackets unless the command is very short like the previous Where-Object command. To do this, however, requires the use of the line continuation character (the backtick). I know some developers who avoid line continuation like the plague, but I think lining up curly brackets is more important because it makes the code easier to read. Here is the beginning of the ForEach-Object cmdlet:

 ForEach-Object `

Because the .ddf file used by MakeCab.exe is ASCII text, you will need to convert the fullname property of the System.IO.Fileinfo object returned by the Get-ChildItem cmdlet to a string. In addition, because you may have files with spaces in their names, it makes sense to enclose the file fullname value in a set of quotation marks:

 { 
 '"' + $_.fullname.tostring() + '"' | 

You then pipeline the filenames to the Out-File cmdlet, making sure to specify the ASCII encoding and to use the –append switch to avoid overwriting everything else in the text file:

 Out-File -filepath $ddfFile -encoding ASCII -append
 }

Now you can provide another update to the debug users, and then call the New-Cab function:

 Write-Debug "ddf file is created. Calling New-Cab function"
 New-Cab($ddfFile)
} #end New-DDF

When you enter the New-Cab function, you can also supply that information to the user:

Function New-Cab($ddfFile)
{
 Write-Debug "Entering the New-Cab function. The DDF File is $ddfFile"

Next, if the script is run with the –debug switch, you can use MakeCab.exe's /V parameter to provide detailed debugging information (3 is full verbosity; 0 is none). If the script isn't run with the –debug switch, you don't want to clutter the screen with too much information so you should stick with the utility's default:

 if($debug)
 { makecab /f $ddfFile /V3 }
 Else
 { makecab /f $ddfFile }
} #end New-Cab

The entry point to the script checks to see if the $debug variable is present. If it is, the $debugPreference automatic variable is set to continue, and debugging information will be displayed via the Write-Debug cmdlet. After that check has been performed, the New-DDF cmdlet is called with the two values supplied to the command line: the path and the filepath:

if($debug) {$DebugPreference = "continue"}
New-DDF -path $path -filepath $filepath

Well, that is about it for this month's article on compressing and uncompressing files. If you feel like your head is about to explode, you can try to plug your Zune into your ear and see if you can run one of these scripts to compress part of your brain. But…I'm telling you right now…it works only in the movies. Sorry. If you didn't lock up, you should stop by the TechNet Script Center and check out our daily "Hey, Scripting Guy!" article. See you there.

Ed Wilson, a well-known scripting expert, is the author of eight books, including Windows PowerShell Scripting Guide (2008) and Microsoft Windows PowerShell Step by Step (2007). Ed holds more than 20 industry certifications, including Microsoft Certified Systems Engineer (MCSE) and Certified Information Systems Security Professional (CISSP). In his spare time, he enjoys woodworking, underwater photography, and scuba diving. And tea.

Craig Liebendorfer is a wordsmith and longtime Microsoft Web editor. Craig still can't believe there's a job that pays him to work with words every day. One of his favorite things is irreverent humor, so he should fit right in here. He considers his greatest accomplishment in life to be his magnificent daughter.