views:

1978

answers:

4

In our build process there is currently the potential for non-code based files (such as image files) to be added to our web project, but not included in the MSI installer built by WiX.

To help prevent this, I want to perform the following in the AfterBuild target for our WiX project:

  • Get a count of all files built (output from web deployment project)
  • Get a count of all files built into MSI (from "File" table in MSI)
  • Compare counts and fail build if they don't match

If I fire up Orca I can easily see the File table and count, but I don't know how to automate this from MSBuild. Is there some API or other mechanism to get this information out of an MSI?

I don't mind writing a custom MSBuild task to extract the MSI File table count.

A: 

WinRAR identifies the MSI as a self-extracting CAB archive (after giving it a .rar extension). I suppose you could copy the file somewhere, rename it, unpack it with WinRAR, then count the files. The files will not have their original names, though.

This seems a bit outdated and I don't know if it could be of any help.

cdonner
+1  A: 

MSI files are little baby databases with a custom SQL engine. You just need to run the query:

SELECT `File` FROM `File`

and count the number of rows that come back. Easiest way to integrate into an MSBuild Task would probably be to use WiX's DTF which provides managed wrappers for all of the MSI APIs.

The solution will be really simple once you get all the tools in place.

Rob Mensching
+1 I'm a wix fan but never noticed the goodness in the "C:\program files\Windows Installer XML v3\sdk" folder, I've added a sample in my own answer
Wim Coenen
Thanks Rob! I've marked wcoenen's answer as correct simply because he added the code sample. It would be nice to mark both as correct, but you got my +1.
Si
+3  A: 

Create a new visual studio project, add a reference to c:\windows\system32\msi.dll and use the following code to read the number of files in a msi file:

Type installerType = Type.GetTypeFromProgID("WindowsInstaller.Installer");
var installer =
   (WindowsInstaller.Installer)Activator.CreateInstance(installerType);
var msi = installer.OpenDatabase(@"path\to\some\file.msi", 0);
var fileView = msi.OpenView("SELECT FileName FROM File");
fileView.Execute(null);
int fileCount = 0;
while (fileView.Fetch() != null)
{
   fileCount++;
}
Console.WriteLine(fileCount);

This code uses the WindowsInstaller.Installer COM object, which is the entry-point for the windows installer automation API. Take a look at the complete reference documentation.

edit: apparently wix comes with managed assemblies (in C:\program files\Windows Installer XML v3\sdk) which wrap msi.dll. I guess this is what Rob is referring to by "DTF" in his answer. Using the types in the Microsoft.Deployment.WindowsInstaller assembly and namespace would simplify the code sample to this:

var database = new Database(@"\path\to\some\file.msi");
var list = database.ExecuteQuery("SELECT FileName FROM File");
Console.WriteLine(list.Count);
Wim Coenen
+3  A: 

Since there are multiple ways you could implement this, i'm answering my own question with the results I'm now using thanks to the answers from wcoenen and Rob.

This is the custom MSBuild task:

public class VerifyMsiFileCount : Task
{
    [Required]
    public string MsiFile { get; set; }

    [Required]
    public string Directory { get; set; }

    public override bool Execute()
    {
       Database database = new Database(MsiFile, DatabaseOpenMode.ReadOnly);
        IList msiFiles = database.ExecuteQuery("SELECT FileName FROM File", new Record(0));
        IList<string> files = new List<string>(
            System.IO.Directory.GetFiles(Directory, "*", SearchOption.AllDirectories));
        return compareContents(msiFiles, files);
    }

    bool compareContents(IList msiFiles, IList<string> files)
    {
        // Always false if count mismatch, but helpful to know which file(s) are missing
        bool result = msiFiles.Count == files.Count;

        StringBuilder sb = new StringBuilder(msiFiles.Count);
        foreach (string msiFile in msiFiles)
        {
            sb.AppendLine(msiFile.ToUpper());
        }
        string allMsiFiles = sb.ToString();

        // Could be optimized using regex - each non-matched line in allMsiFiles
        string filename;
        foreach (string file in files)
        {
            filename = file.ToUpper();
            // Strip directory as File table in MSI does funky things with directory prefixing
            if (filename.Contains(Path.DirectorySeparatorChar.ToString()))
            {
                filename = filename.Substring(file.LastIndexOf(Path.DirectorySeparatorChar) + 1);
            }
            if (!allMsiFiles.Contains(filename))
            {
                result = false;
                MSBuildHelper.Log(this, file + " appears to be missing from MSI File table",
                    MessageImportance.High);
            }
        }
        return result;
    }
}

Couple of things to note:

  • I've left out documentation for brevity.
  • MSBuildHelper.Log is just a simple wrapper for ITask.BuildEngine.LogMessageEvent to catch NullReferenceException running unit tests.
  • Still room for improvement, e.g. using ITaskItem instead of string for properties, regex for comparison.
  • The comparison logic may look a little weird, but the File table does some funky stuff with directory prefixing, and I also wanted to avoid the edge case where a file may be deleted and a new file added, so the file count is correct but the msi contents are wrong :)

Here are the corresponding unit tests, assumption is you have Test.msi in your test project which is copied to the output directory.

[TestFixture]
public class VerifyMsiFileCountFixture
{
    VerifyMsiFileCount verify;

    [SetUp]
    public void Setup()
    {
        verify = new VerifyMsiFileCount();
    }

    [Test]
    [ExpectedException(typeof(InstallerException))]
    public void Execute_ThrowsInstallerException_InvalidMsiFilePath()
    {
        verify.Directory = Environment.CurrentDirectory;
        verify.MsiFile = "Bogus";
        verify.Execute();
    }

    [Test]
    [ExpectedException(typeof(DirectoryNotFoundException))]
    public void Execute_ThrowsDirectoryNotFoundException_InvalidDirectoryPath()
    {
        verify.Directory = "Bogus";
        verify.MsiFile = "Test.msi";
        verify.Execute();
    }

    [Test]
    public void Execute_ReturnsTrue_ValidDirectoryAndFile()
    {
        string directory = Path.Combine(Environment.CurrentDirectory, "Temp");
        string file = Path.Combine(directory, "Test.txt");
        Directory.CreateDirectory(directory);
        File.WriteAllText(file, "Temp");
        try
        {
            verify.Directory = directory;
            verify.MsiFile = "Test.msi";
            Assert.IsTrue(verify.Execute());
        }
        finally
        {
            File.Delete(file);
            Directory.Delete(directory);
        }
    }

    [Test]
    public void Execute_ReturnsFalse_NoFileDefined()
    {
        string directory = Path.Combine(Environment.CurrentDirectory, "Temp");
        Directory.CreateDirectory(directory);
        try
        {
            verify.Directory = directory;
            verify.MsiFile = "Test.msi";
            Assert.IsFalse(verify.Execute());
        }
        finally
        {
            Directory.Delete(directory);
        }
    }

    [Test]
    public void Execute_ReturnsFalse_IncorrectFilename()
    {
        string directory = Path.Combine(Environment.CurrentDirectory, "Temp");
        string file = Path.Combine(directory, "Bogus.txt");
        Directory.CreateDirectory(directory);
        File.WriteAllText(file, "Temp");
        try
        {
            verify.Directory = directory;
            verify.MsiFile = "Test.msi";
            Assert.IsFalse(verify.Execute());
        }
        finally
        {
            File.Delete(file);
            Directory.Delete(directory);
        }
    }

    [Test]
    public void Execute_ReturnsFalse_ExtraFileDefined()
    {
        string directory = Path.Combine(Environment.CurrentDirectory, "Temp");
        string file1 = Path.Combine(directory, "Test.txt");
        string file2 = Path.Combine(directory, "Bogus.txt");
        Directory.CreateDirectory(directory);
        File.WriteAllText(file1, "Temp");
        File.WriteAllText(file2, "Temp");
        try
        {
            verify.Directory = directory;
            verify.MsiFile = "Test.msi";
            Assert.IsFalse(verify.Execute());
        }
        finally
        {
            File.Delete(file1);
            File.Delete(file2);
            Directory.Delete(directory);
        }
    }
}
Si

related questions