比较具有相同内容的 XLSX 文件之间的 MD5 哈希值

Comparing MD5 hashes between XLSX files with identical content

我们有一个内部网络应用程序,它接受来自用户的各种格式的文件,以便将大量数据导入我们的系统。

我们最近实施的升级之一是添加一种方法来检测以前是否上传过文件,如果是,则向用户发出警告并提供重新提交文件或取消的选项上传。

为此,我们正在计算上传文件的 MD5,并将其与包含先前上传文件信息的数据库 table 进行比较,以确定它是否重复。如果 MD5 匹配,则显示警告,否则将新文件信息插入 table 并继续文件处理。

以下是用于生成 MD5 哈希的 C# 代码:

private static string GetHash(byte[] input)
{
    using (MD5 md5 = MD5.Create())
    {
        byte[] data = md5.ComputeHash(input);

        StringBuilder bob = new StringBuilder();

        for (int i = 0; i < data.Length; i++)
            bob.Append(data[i].ToString("x2").ToUpper());

        return bob.ToString();
    }
}

一切都运行良好...只有一个例外。

用户可以为此过程上传 .xlsx 文件,不幸的是,这种文件类型还在文件内容中存储了文件的元数据。 (通过将 .xlsx 文件的扩展名更改为 .zip 并提取内容 [见下文],可以很容易地看到这一点。)

因此,.xlsx 文件的 MD5 散列将随着每次后续保存而改变,即使文件内容相同(只是打开并保存文件而不做任何修改)将刷新元数据并导致不同的 MD5 散列)。

在这种情况下,具有相同记录但在不同时间或由不同用户创建的文件将跳过重复文件检测并得到处理。

我的问题:有没有一种方法可以确定 .xlsx 文件的 内容 是否与前一个文件 没有 [=48] 的内容相匹配=] 存储文件内容?换句话说:有没有一种方法可以只生成 .xlsx 文件的 内容 MD5 散列?

您可以在计算哈希值之前从文档中删除不应影响哈希值的部分。

这可以通过将 Open XML 包的所有部分提取到单个 XML 文档中,删除不需要的节点并计算生成的 XML 文档的哈希来实现。请注意,您必须重新计算已上传的 Excel 文件的哈希值,因为哈希值现在不再基于二进制文件内容。

这里是一个简单的示例程序(添加对WindowsBase.dll的引用):

using System;
using System.IO;
using System.IO.Packaging;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Xml.Linq;

internal class Program
{
    private static readonly XNamespace dcterms = "http://purl.org/dc/terms/";

    private static void Main(string[] args)
    {
        var fileName = args[0];

        // open the ZIP package
        var package = Package.Open(fileName);

        // convert the package to a single XML document
        var xdoc = OpcToFlatOpc(package);

        // remove the nodes we are not interested in
        // here you can add other nodes as well
        xdoc.Descendants(dcterms + "modified").Remove();

        // get a stream of the XML and compute the hash
        using (var ms = new MemoryStream())
        {
            xdoc.Save(ms);
            ms.Position = 0;

            string md5 = GetHash(ms);
            Console.WriteLine(md5);
        }
    }

    private static string GetHash(Stream stream)
    {
        using (var md5 = MD5.Create())
        {
            var data = md5.ComputeHash(stream);

            var bob = new StringBuilder();

            for (int i = 0; i < data.Length; i++)
            {
                bob.Append(data[i].ToString("X2"));
            }

            return bob.ToString();
        }
    }

    private static XDocument OpcToFlatOpc(Package package)
    {
        XNamespace pkg = "http://schemas.microsoft.com/office/2006/xmlPackage";
        var declaration = new XDeclaration("1.0", "UTF-8", "yes");
        var doc = new XDocument(
            declaration,
            new XProcessingInstruction("mso-application", "progid=\"Word.Document\""),
            new XElement(
                pkg + "package",
                new XAttribute(XNamespace.Xmlns + "pkg", pkg.ToString()),
                package.GetParts().Select(GetContentsAsXml)));

        return doc;
    }

    private static XElement GetContentsAsXml(PackagePart part)
    {
        XNamespace pkg = "http://schemas.microsoft.com/office/2006/xmlPackage";
        if (part.ContentType.EndsWith("xml"))
        {
            using (var partstream = part.GetStream())
            {
                using (var streamReader = new StreamReader(partstream))
                {
                    string streamString = streamReader.ReadToEnd();
                    if (!string.IsNullOrEmpty(streamString))
                    {
                      var newXElement =
                        new XElement(
                          pkg + "part",
                          new XAttribute(pkg + "name", part.Uri),
                          new XAttribute(pkg + "contentType", part.ContentType),
                          new XElement(pkg 
                            + "xmlData", XElement.Parse(streamString)));
                        return newXElement;
                    }

                    return null;
                }
            }
        }

        using (var str = part.GetStream())
        {
            using (var binaryReader = new BinaryReader(str))
            {
                int len = (int)binaryReader.BaseStream.Length;
                var byteArray = binaryReader.ReadBytes(len);

                // the following expression creates the base64String, then chunks
                // it to lines of 76 characters long
                string base64String = Convert.ToBase64String(byteArray)
                       .Select((c, i) => new { Character = c, Chunk = i / 76 })
                       .GroupBy(c => c.Chunk)
                       .Aggregate(
                           new StringBuilder(),
                           (s, i) =>
                               s.Append(
                                   i.Aggregate(
                                       new StringBuilder(),
                                       (seed, it) => seed.Append(it.Character),
                                       sb => sb.ToString()))
                                .Append(Environment.NewLine),
                           s => s.ToString());

                return new XElement(
                    pkg + "part",
                    new XAttribute(pkg + "name", part.Uri),
                    new XAttribute(pkg + "contentType", part.ContentType),
                    new XAttribute(pkg + "compression", "store"),
                    new XElement(pkg + "binaryData", base64String));
            }
        }
    }
}

一个更简单的策略利用了 .xlsx 文件扩展名与 .zip 文件扩展名相同的事实。 .zip 文件包含其内容 un-compressed 版本的 CRC-32 散列。

参见:wikipedia entry on ZIP format file headers

具体来说,如果您从 14 个字节的偏移量开始并占用 4 个字节,您将在创建文件时获得文件内容的散列值(实际上是 CRC-32 校验和)。

有关提取字节的策略,请查看此处:StackO post on reading bytes from a file in C#。最好的解决方案似乎是这样的:

byte[] crc32 = new byte[4];
using (BinaryReader reader = new BinaryReader(new FileStream(xlsxFile, FileMode.Open)))
{
    reader.BaseStream.Seek(14, SeekOrigin.Begin);
    reader.Read(crc32, 0, 4);
}

但是——警告——这是一个未经测试的建议。 YMMV.

另一个警告——许多打开 .xlsx 文件的应用程序对 xml sub-files 中的 ID 进行看似任意的更改,这些 ID 被压缩到更大的电子表格文件中。两个文件可以具有实际上相同的内容,但它们的校验和会因为这些差异而不同。

对于 Unix 用户/Linux/想要比较 .xlsx 与脚本化 shell 实用程序(xxdunzipdiff) 参见:StackO post on shell utilities for comparing XLSX files .