Welcome to MSDN Blogs Sign in | Join | Help

Generating and Consuming Output

First off, I would like to apologize for the big time gap between posts. A couple of weeks ago, I was writing the code for the next topic (well, this topic now), when I ran into what I thought was a problem with the code in my previous posts. When I started running the postbuild steps from last time (with a few modifications), the step to sign the MSI was suddenly failing because, apparently, the MSI was still locked. So I went through my code some more times, triying as hard as I could to close everything down, calling the GC to do its thing, everything I could think of. Eventually, I came up with the theory that the interop I was dealing with was holding onto its handles because of some weird ref-counting reasons, and I thought I was screwed: I was worried I'd have to write my own wrapper around the Windows Installer APIs that would be more careful about closing handles when I was done. Not something I wanted to get into, so I kept putting off my changes. Eventually, I heard about System.Runtime.InteropServices.Marshal.ReleaseComObject(...) which suddenly got my tasks back working. So I reverted all my changes to get me back to the state of the code at the end of my last post. Of course, when I started re-running the tasks as written last time, poof! everything was working again. So, now I think I may be losing my mind.

One other item to note: I'm usually pretty good about making baby steps when editing my code, so that when something breaks, it is easy to track down where it happened. Think of it as the Red/Green/Refactor loop, with only manual testing for the red (a problem I hope to correct at a later time). But this time, I didn't bother with the baby steps because I was in a meeting and didn't want to bother with a lot of the steps (especially with the blogging parts). So when everything stopped working, it was extra hard to figure out what exactly was wrong. Lesson learned, at least until the next time I ignore the lesson.

Anyways, on to this blog's topic: adding another task to help in the signing scenario. Along the way, I hope to introduce TaskItems as well as showing how to return items from tasks.

One problem with the build task that we created last time is that if your MSI had multiple external cabs, the task would be run for every single cab that was part of the build output. That means a lot of opening and closing of the MSI, which means disk cost hit, which means time wasted. We already have the cabs defined as an array of Items. Its just a matter of passing this array into the task, and knowing how to use it once it gets there.

Passing the array is easy. We need to redefine the Cabinet property to take an ITaskItem array:

[Required]
public ITaskItem[] Cabinets
{
	get { return cabinets; }
	set { cabinets = value; }
}

being careful to revise the cabinet member from string to ITaskItem. We can then use this parameter in a foreach loop like this:

foreach (ITaskItem cabinet in Cabinets)
{
	Array certificateArray = installer.FileSignatureInfo(cabinet.ItemSpec, 0, MsiSignatureInfo.msiSignatureInfoCertificate);

Note the usage of "cabinet.ItemSpec". This is how one accesses the "Include" attribute of the a task item. A little bit of code re-arranging, and this task will finally be able to be run:

public override bool Execute()
{
	string certificateFileName = string.Empty;
	try
	{
		Type classType = Type.GetTypeFromProgID("WindowsInstaller.Installer");
		Object installerClassObject = Activator.CreateInstance(classType);
		Installer installer = (Installer)installerClassObject;

		Database msi = Installer.OpenDatabase(Database, MsiOpenDatabaseMode.msiOpenDatabaseModeTransact);
		View viewCert = msi.OpenView("SELECT * FROM `MsiDigitalCertificate`");
		viewCert.Execute(null);
		View viewSig = msi.OpenView("SELECT * FROM `MsiDigitalSignature`");
		viewSig.Execute(null);

		foreach (ITaskItem cabinet in Cabinets)
		{
			Array certificateArray = installer.FileSignatureInfo(cabinet.ItemSpec, 0, MsiSignatureInfo.msiSignatureInfoCertificate);
			certificateFileName = Path.GetTempFileName();
			using (BinaryWriter fout = new BinaryWriter(new FileStream(certificateFileName, FileMode.Open)))
			{
				fout.Write((byte[])certificateArray);
			}

			Record recordCert = installer.CreateRecord(2);
			recordCert.set_StringData(1, "Test");
			recordCert.SetStream(2, certificateFileName);
			viewCert.Modify(MsiViewModify.msiViewModifyInsert, recordCert);

			Record recordSig = installer.CreateRecord(4);
			recordSig.set_StringData(1, "Media");
			recordSig.set_StringData(2, Media);
			recordSig.set_StringData(3, "Test");
			viewSig.Modify(MsiViewModify.msiViewModifyInsert, recordSig);

			if (!string.IsNullOrEmpty(certificateFileName) && File.Exists(certificateFileName))
			{
				File.Delete(certificateFileName);
			}
		}
		msi.Commit();
	}
	catch (Exception ex)
	{
		Log.LogErrorFromException(ex);
		return false;
	}
	finally
	{
		if (!string.IsNullOrEmpty(certificateFileName) && File.Exists(certificateFileName))
		{
			File.Delete(certificateFileName);
		}
	}

	return true;
}

Of courses, we have to update our project to use the newer parameter, as the build will fail with the following errors:

Build FAILED.
E:\MWadeBlog\SetupSignCabs\PostBuild.proj(25,13): error MSB4064: The "Cabinet" parameter is not supported by the "PopulateDigitalSignature" task. Verify the parameter exists on the task, and it is a settable public instance property.
E:\MWadeBlog\SetupSignCabs\PostBuild.proj(23,9): error MSB4063: The "PopulateDigitalSignature" task could not be initialized with its input parameters.
    0 Warning(s)
    2 Error(s)

Updating the usage of the PopulateDigitalSignature task thusly:

<PopulateDigitalSignature 
    Database="$(BuiltOutputPath)"
    Cabinets="@(BuiltCabinetFile)"
/>

And building gives:

    signtool.exe sign /sha1 963C7D3298349519368D1E36AB2BABBE84060DFA "E:\MWadeBlog\SetupSignCabs\Debug\SetupSignCabs.msi"

    
    Number of errors: 1
    
    SignTool Error: The file is being used by another process.
    SignTool Error: An error occurred while attempting to sign: E:\MWadeBlog\SetupSignCabs\Debug\SetupSignCabs.msi

Ah-ha! Maybe I'm not going crazy. I don't know what change between the previous version of the code and this one causes the problem; really, the most major change (that is related to the MSI API usage) is the change in scope of the Record objects, but I can't think what difference that would make. Anyway, I'm happy to report that a couple of FinalReleaseComObject calls seems to fix this. (And to stem any potential questions about why one would use FinalReleaseComObject rather than ReleaseComObject, I'm not entirely sure: FinalReleaseComObject just feels right in this case). So, the knew code looks like this:

foreach (ITaskItem cabinet in Cabinets)
{
	// Code cut to save space

	System.Runtime.InteropServices.Marshal.FinalReleaseComObject(recordCert);
	System.Runtime.InteropServices.Marshal.FinalReleaseComObject(recordSig);
}
msi.Commit();

System.Runtime.InteropServices.Marshal.FinalReleaseComObject(viewCert);
System.Runtime.InteropServices.Marshal.FinalReleaseComObject(viewSig);
System.Runtime.InteropServices.Marshal.FinalReleaseComObject(msi);
System.Runtime.InteropServices.Marshal.FinalReleaseComObject(installer);

I tried various combinations of what to release and what to not release, but I was getting inconsistent results (don't you just love that?). So I think its probably safest to release all of these.

There are 2 problems with this approach (well, there are probably more, but there are only 2 I want to blog about). One, the media entry is fixed for all cabinet entries. Considering this is supposed to be the DiskId entry in the media table, that's probably not right. Second, there is no reason to rely on the files on disk to figure out what the cabinet files are: that is information stored in the MSI itself. We can solve both of these issues by adding a new build task which reads the MSI and returns the external cabinets via an output property. By making the output an array of TaskItems, it is possible to include metadata which will include the necessary media information.

We will call the task FindExternalCabs. It will have one input, the MSI file, and one output, the array of ITaskItems which will have the names of the external cabs. The code is below:

using System;
using System.Collections.Generic;
using System.Text;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
using WindowsInstaller;

namespace SetupProjects.Tasks
{

	public class FindExternalCabs : Task
	{
		private string database = string.Empty;
		private List<ITaskItem> cabinets = null;

		[Required]
		public string Database
		{
			get { return database; }
			set { database = value; }
		}

		[Output]
		public ITaskItem[] Cabinets
		{
			get { return cabinets.ToArray(); }
			set { cabinets = new List<ITaskItem>(value); }
		}
		
		public override bool Execute()
		{
			cabinets = new List<ITaskItem>();
			Type classType = Type.GetTypeFromProgID("WindowsInstaller.Installer");
			Object installerClassObject = Activator.CreateInstance(classType);
			Installer installer = (Installer)installerClassObject;

			Database msi = installer.OpenDatabase(Database, MsiOpenDatabaseMode.msiOpenDatabaseModeReadOnly);
			View viewMedia = msi.OpenView("Select * FROM `Media`");
			viewMedia.Execute(null);
			Record record = viewMedia.Fetch();

			while (record != null)
			{
				string cabinet = record.get_StringData(4);
				if (!string.IsNullOrEmpty(cabinet) && !cabinet.StartsWith("#"))
				{
					ITaskItem item = new TaskItem(cabinet);
					item.SetMetadata("DiskId", record.get_IntegerData(1).ToString());
					cabinets.Add(item);
				}
				record = viewMedia.Fetch();
			}

			viewMedia.Close();

			System.Runtime.InteropServices.Marshal.FinalReleaseComObject(viewMedia);
			System.Runtime.InteropServices.Marshal.FinalReleaseComObject(msi);
			System.Runtime.InteropServices.Marshal.FinalReleaseComObject(installer);

			return true;
		}
	}
}

A lot of this should be pretty self-explanatory, but I would like to point out a few items of interest. First, notice that the Cabinets property is attributed with [Output]. This signifies to MSBuild that this is an output parameter. Second, notice the use of the TaskItem objects in the while loop. The constructor takes a string, which ends up corresponding to the ItemSpec item of an ITaskItem. We then add metadata to the item, which can then be used as information in another task. In this case, we can get rid of the Media parameter PopulateDigitalSignature task.

In addition to removing the Media parameter from the PopulateDigitalSignature task, there are a few other changes that need to be made. First, the ItemSpec of each ITaskItem is not the full path to the cabinet, but is only the cabinet file name. Therefore, it is necessary to create the full path to the cabinet in the call to FileSignatureInfo. And we are replacing the Media parameter with the "DiskId" metadata value of the task item. So, we need to access that item via a call to "GetMetadata". I have attached the updated foreach loop below:

foreach (ITaskItem cabinet in Cabinets)
{
	Array certificateArray = installer.FileSignatureInfo(Path.Combine(Path.GetDirectoryName(Database), cabinet.ItemSpec), 0, MsiSignatureInfo.msiSignatureInfoCertificate);
	certificateFileName = Path.GetTempFileName();
	using (BinaryWriter fout = new BinaryWriter(new FileStream(certificateFileName, FileMode.Open)))
	{
		fout.Write((byte[])certificateArray);
	}

	Record recordCert = installer.CreateRecord(2);
	recordCert.set_StringData(1, "Test");
	recordCert.SetStream(2, certificateFileName);
	viewCert.Modify(MsiViewModify.msiViewModifyInsert, recordCert);

	string media = "1";
	if (!string.IsNullOrEmpty(cabinet.GetMetadata("DiskId")))
	{
		media = cabinet.GetMetadata("DiskId");
	}

	Record recordSig = installer.CreateRecord(4);
	recordSig.set_StringData(1, "Media");
	recordSig.set_StringData(2, media);
	recordSig.set_StringData(3, "Test");
	viewSig.Modify(MsiViewModify.msiViewModifyInsert, recordSig);

	if (!string.IsNullOrEmpty(certificateFileName) && File.Exists(certificateFileName))
	{
		File.Delete(certificateFileName);
	}

	System.Runtime.InteropServices.Marshal.FinalReleaseComObject(recordCert);
	System.Runtime.InteropServices.Marshal.FinalReleaseComObject(recordSig);
}

First, we need to use and consume the output from the FindExternalCabs task:

<FindExternalCabs Database="$(BuiltOutputPath)">
    <Output TaskParameter="Cabinets" ItemName="BuiltCabinetFiles" />
</FindExternalCabs>

The Output info above has 2 important attributes:

  1. TaskParameter, which specifies the name of the property that we are interested in
  2. ItemName, which is the name which will be used to refer to the output later in the project file. If the output of the task had been a property instead of an item, we would use PropertyName instead.

Note that I used the plural name "BuiltCabinetFiles"; this just made more sense to me. For whatever reason, with this is an output parameter, I tend to think of these as a group of items. As a result, it is necessary to update the signing task that uses this, as well as the PopulateDigitalSignature task:

<Exec Command='signtool.exe sign /sha1 $(_ResolvedCertificateThumbprint) "$(BuiltOutputDir)\%(BuiltCabinetFiles.Identity)"' />
<PopulateDigitalSignature 
    Database="$(BuiltOutputPath)"
    Cabinets="@(BuiltCabinetFiles)"
/>

The last thing to note is that it was also necessary to include the full path to the cabinet file, just like we updated the task. The full post build project is below:
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
    <UsingTask TaskName="PopulateDigitalSignature" AssemblyFile="$(MSBuildExtensionsPath)\SetupProjects.Tasks.dll" />
    <UsingTask TaskName="FindExternalCabs" AssemblyFile="$(MSBuildExtensionsPath)\SetupProjects.Tasks.dll" />

    <PropertyGroup>
        <CertificateKeyFile>$(ProjectDir)\..\..\SetupSigning_TemporaryKey.pfx</CertificateKeyFile>
    </PropertyGroup>

    <ItemGroup>
        <BuiltSetupFile Include="$(BuiltOutputDir)\*.*" Exclude="$(BuiltOutputDir)\*.cab" />
    </ItemGroup>

    <Target Name="SignSetup">
        <ResolveKeySource
            CertificateFile="$(CertificateKeyFile)">
            <Output TaskParameter="ResolvedThumbprint" PropertyName="_ResolvedCertificateThumbprint" />
        </ResolveKeySource>
        
        <FindExternalCabs Database="$(BuiltOutputPath)">
            <Output TaskParameter="Cabinets" ItemName="BuiltCabinetFiles" />
        </FindExternalCabs>

        <Exec Command='signtool.exe sign /sha1 $(_ResolvedCertificateThumbprint) "$(BuiltOutputDir)\%(BuiltCabinetFiles.Identity)"' />
        <PopulateDigitalSignature 
            Database="$(BuiltOutputPath)"
            Cabinets="@(BuiltCabinetFiles)"
        />
        <Exec Command='signtool.exe sign /sha1 $(_ResolvedCertificateThumbprint) "%(BuiltSetupFile.Identity)"' />
    </Target>
</Project>

There are a couple more items I want to clean up in the code, but I think I'll wait until the next post.

To summarize, this time we learned about:

  1. Consuming an ITaskItem, including dealing with metadata
  2. Creating and consuming output from an MSBuild task
  3. Creating ITaskItem and creating metadata
Published Thursday, December 21, 2006 5:38 AM by mwade

Attachment(s): MWadeBlog_06_12_20.zip

Comment Notification

If you would like to receive an email when updates are made to this post, please register here

Subscribe to this post's comments using RSS

Comments

# Almost there...

Friday, December 22, 2006 12:18 AM by Re-inventing The Wheel

Last time (which was only yesterday), I made a few more strides towards signing cabs in a manner consistent

Leave a Comment

(required) 
required 
(required) 

  
Enter Code Here: Required
 
Page view tracker