在HttpClient中解压缩之前是否可以访问压缩数据?


71

我正在使用Google Cloud Storage .NET客户端库。有三种功能(.NET,我的客户端库和存储服务之间)以不愉快的方式结合在一起:

  • 下载文件(Google Cloud Storage术语中的对象)时,服务器会包含已存储数据的哈希值。然后,我的客户代码会根据下载的数据来验证该哈希。

  • Google云端存储的另一个功能是,用户可以设置对象的Content-Encoding,并且当请求包含匹配的Accept-Encoding时,该对象将作为下载的标头包含在内。(目前,让我们忽略请求中不包含的行为...)

  • HttpClientHandler 可以自动透明地解压缩gzip(或压缩)内容。

当所有这三个结合在一起时,我们就会遇到麻烦。这是一个简短但完整的程序,演示了这一点,但是没有使用我的客户端库(并且没有访问可公开访问的文件):

using System;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;

class Program
{
    static async Task Main()
    {
        string url = "https://www.googleapis.com/download/storage/v1/b/"
            + "storage-library-test-bucket/o/gzipped-text.txt?alt=media";
        var handler = new HttpClientHandler
        {
            AutomaticDecompression = DecompressionMethods.GZip
        };
        var client = new HttpClient(handler);

        var response = await client.GetAsync(url);
        byte[] content = await response.Content.ReadAsByteArrayAsync();
        string text = Encoding.UTF8.GetString(content);
        Console.WriteLine($"Content: {text}");

        var hashHeader = response.Headers.GetValues("X-Goog-Hash").FirstOrDefault();
        Console.WriteLine($"Hash header: {hashHeader}");

        using (var md5 = MD5.Create())
        {
            var md5Hash = md5.ComputeHash(content);
            var md5HashBase64 = Convert.ToBase64String(md5Hash);
            Console.WriteLine($"MD5 of content: {md5HashBase64}");
        }
    }
}

.NET Core项目文件:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp2.0</TargetFramework>
    <LangVersion>7.1</LangVersion>
  </PropertyGroup>
</Project>

输出:

Content: hello world
Hash header: crc32c=T1s5RQ==,md5=xhF4M6pNFRDQnvaRRNVnkA==
MD5 of content: XrY7u+Ae7tCTyyK7j1rNww==

如您所见,内容的MD5与X-Goog-Hash标头的MD5部分不同。(在我的客户端库中,我使用的是crc32c哈希,但这显示了相同的行为。)

这不是错误,HttpClientHandler这是预料之中的,但是当我想验证哈希值时却很痛苦。基本上,我需要在减压之前之后的内容。而且我找不到任何办法。

在一定程度上澄清我的要求,我知道如何防止减压在HttpClient从流中读取的时候,而是事后解压缩-但我需要能够做到这一点不改变任何使用所产生的代码HttpResponseMessageHttpClient。(有很多处理响应的代码,我只想在一个中心位置进行更改。)

我有一个计划,该计划已经原型化,并且可以按照我到目前为止发现的方式工作,但是有点难看。它涉及创建三层处理程序:

  • HttpClientHandler 禁用自动减压。
  • 一个新的处理程序,用一个新的Stream子类替换内容流,该子类委派给原始内容流,但在读取数据时对其进行哈希处理。
  • 基于MicrosoftDecompressionHandler代码的仅解压处理程序。

尽管这样做有效,但具有以下缺点:

  • 开源许可:根据MIT许可的Microsoft代码,准确检查我需要做些什么才能在我的存储库中创建一个新文件
  • 有效地分叉了MS代码,这意味着我可能应该定期检查以查看是否发现了任何错误。
  • Microsoft代码使用程序集的内部成员,因此它的移植效果不尽如人意。

如果微软DecompressionHandler公开上市,那将有很大帮助-但这可能比我需要的时间更长。

我正在寻找的是一种可能的替代方法-我错过了一些让我在解压之前就掌握内容的方法。我不想重塑HttpClient-例如,响应通常是分块的,我也不想涉足这一方面。这是我正在寻找的一个非常具体的拦截点。


在存储方面,这对我来说听起来像是压缩部分,就像“我确实有一个未压缩的文件,但是如果我可以压缩它并让浏览器的解压缩部分解压缩,那将是很好的。它自动”。如果是这样,改为存储/提供解压缩内容的哈希值是否有意义?听起来这只是一个服务器空间和cpu优化,避免了服务器端的压缩步骤。我在这里想念什么?由于这个原因,不是很多客户端库都存在完全相同的问题吗?
Lasse V. Karlsen

@LasseVågsætherKarlsen:这将是很好,如果响应可能包含压缩数据的两个哈希未压缩数据(你不会希望客户端必须解压缩它只是哈希,如果他们想保持它,否则压缩的),但我怀疑我将能够获得改变。是的,其他一些客户端库可能确实存在相同的问题-但我与Google的官方维护者保持联系,他们正在对其进行检查:)
Jon Skeet

1
@LasseVågsætherKarlsen:如果您仍在从GCS获取信息,那不是HttpClientHandler在做-那就是GCS。如果您要求使用gzip的Content-Encoding的文件,但未指定Accept-Encoding:gzip,则它将为您解压缩该文件,并提供没有Content-Encoding头的解压缩内容。(并且仍然包括压缩文件的哈希。我知道,这是有问题的……我不想涉足这个问题中的所有可能的怪癖,但请告诉我您是否认为我应该提及这一点。)
乔恩Skeet

4
简而言之,似乎此哈希设计为不可验证的,这对我来说似乎毫无意义。
Lasse V. Karlsen

1
@zaitsman:我通常会比在源代码上更相信我在网络上看到的内容:)我已经在.NET Core上运行了大多数测试,但是在Windows上运行-绝对可以禁用压缩。
乔恩·斯基特

Answers:


14

看着@Michael的所作所为给我暗示我失踪了。获取压缩的内容后,您可以使用CryptoStreamGZipStreamStreamReader来读取响应,而无需将其加载到内存中的次数超过所需。CryptoStream在解压缩和读取时,将对压缩的内容进行哈希处理。将StreamReadera替换为a FileStream,您可以将数据写入文件的内存占用最少:)

using System;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;

class Program
{
    static async Task Main()
    {
        string url = "https://www.googleapis.com/download/storage/v1/b/"
            + "storage-library-test-bucket/o/gzipped-text.txt?alt=media";
        var handler = new HttpClientHandler
        {
            AutomaticDecompression = DecompressionMethods.None
        };
        var client = new HttpClient(handler);
        client.DefaultRequestHeaders.Add("Accept-Encoding", "gzip");

        var response = await client.GetAsync(url);
        var hashHeader = response.Headers.GetValues("X-Goog-Hash").FirstOrDefault();
        Console.WriteLine($"Hash header: {hashHeader}");
        string text = null;
        using (var md5 = MD5.Create())
        {
            using (var cryptoStream = new CryptoStream(await response.Content.ReadAsStreamAsync(), md5, CryptoStreamMode.Read))
            {
                using (var gzipStream = new GZipStream(cryptoStream, CompressionMode.Decompress))
                {
                    using (var streamReader = new StreamReader(gzipStream, Encoding.UTF8))
                    {
                        text = streamReader.ReadToEnd();
                    }
                }
                Console.WriteLine($"Content: {text}");
                var md5HashBase64 = Convert.ToBase64String(md5.Hash);
                Console.WriteLine($"MD5 of content: {md5HashBase64}");
            }
        }
    }
}

输出:

Hash header: crc32c=T1s5RQ==,md5=xhF4M6pNFRDQnvaRRNVnkA==
Content: hello world
MD5 of content: xhF4M6pNFRDQnvaRRNVnkA==

答案V2

阅读了乔恩的回答和更新的答案后,我得到了以下版本。几乎相同的想法,但是我将流媒体转移到HttpContent我注入的特殊内容中。不是很漂亮,但是想法就在那里。

using System;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

class Program
{
    static async Task Main()
    {
        string url = "https://www.googleapis.com/download/storage/v1/b/"
            + "storage-library-test-bucket/o/gzipped-text.txt?alt=media";
        var handler = new HttpClientHandler
        {
            AutomaticDecompression = DecompressionMethods.None
        };
        var client = new HttpClient(new Intercepter(handler));
        client.DefaultRequestHeaders.Add("Accept-Encoding", "gzip");

        var response = await client.GetAsync(url);
        var hashHeader = response.Headers.GetValues("X-Goog-Hash").FirstOrDefault();
        Console.WriteLine($"Hash header: {hashHeader}");
        HttpContent content1 = response.Content;
        byte[] content = await content1.ReadAsByteArrayAsync();
        string text = Encoding.UTF8.GetString(content);
        Console.WriteLine($"Content: {text}");
        var md5Hash = ((HashingContent)content1).Hash;
        var md5HashBase64 = Convert.ToBase64String(md5Hash);
        Console.WriteLine($"MD5 of content: {md5HashBase64}");
    }

    public class Intercepter : DelegatingHandler
    {
        public Intercepter(HttpMessageHandler innerHandler) : base(innerHandler)
        {
        }

        protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
        {
            var response = await base.SendAsync(request, cancellationToken);
            response.Content = new HashingContent(await response.Content.ReadAsStreamAsync());
            return response;
        }
    }

    public sealed class HashingContent : HttpContent
    {
        private readonly StreamContent streamContent;
        private readonly MD5 mD5;
        private readonly CryptoStream cryptoStream;
        private readonly GZipStream gZipStream;

        public HashingContent(Stream content)
        {
            mD5 = MD5.Create();
            cryptoStream = new CryptoStream(content, mD5, CryptoStreamMode.Read);
            gZipStream = new GZipStream(cryptoStream, CompressionMode.Decompress);
            streamContent = new StreamContent(gZipStream);
        }

        protected override Task SerializeToStreamAsync(Stream stream, TransportContext context) => streamContent.CopyToAsync(stream, context);
        protected override bool TryComputeLength(out long length)
        {
            length = 0;
            return false;
        }

        protected override Task<Stream> CreateContentReadStreamAsync() => streamContent.ReadAsStreamAsync();

        protected override void Dispose(bool disposing)
        {
            try
            {
                if (disposing)
                {
                    streamContent.Dispose();
                    gZipStream.Dispose();
                    cryptoStream.Dispose();
                    mD5.Dispose();
                }
            }
            finally
            {
                base.Dispose(disposing);
            }
        }

        public byte[] Hash => mD5.Hash;
    }
}

如果我的代码都读取数据,那会很好-但事实并非如此。(或者至少是在非常不同的地方这样做的。)我真的需要保持API不变,使用HttpClient并在读取时截取数据:(我将在有机会使问题出现时编辑问题。要求更加明确
乔恩·斯基特

@JonSkeet您是一个棘手的客户!想想我这次明白了:)
shmuelie

正确,现在这是我所描述的有效解决方法,除了没有散列和解压缩之间的分离-并且没有DecompressionHandler做的标头复制。我很高兴我们最终到达了大致相同的地方,即使它没有我希望的那么无创。
乔恩·斯基特

重要的区别是我不使用任何内部:)
shmuelie

@shmulie:是的,但是通过重新实现位-就像我打算做的那样。(还有标题等。)
Jon Skeet

4

我设法通过以下方式纠正了headerhash:

  • 创建一个继承HttpClientHandler的自定义处理程序
  • 压倒一切 SendAsync
  • 读取为字节响应 base.SendAsync
  • 使用GZipStream压缩
  • 将Gzip Md5散列到base64(使用您的代码)

正如您所说,“减压之前”在这里并没有得到真正的尊重

这个想法是要得到这个 if工作像您希望的那样 https://github.com/dotnet/corefx/blob/master/src/System.Net.Http.WinHttpHandler/src/System/Net/Http/WinHttpResponseParser.cs#L80 -L91

它匹配

class Program
{
    const string url = "https://www.googleapis.com/download/storage/v1/b/storage-library-test-bucket/o/gzipped-text.txt?alt=media";

    static async Task Main()
    {
        //await HashResponseContent(CreateHandler(DecompressionMethods.None));
        //await HashResponseContent(CreateHandler(DecompressionMethods.GZip));
        await HashResponseContent(new MyHandler());

        Console.ReadLine();
    }

    private static HttpClientHandler CreateHandler(DecompressionMethods decompressionMethods)
    {
        return new HttpClientHandler { AutomaticDecompression = decompressionMethods };
    }

    public static async Task HashResponseContent(HttpClientHandler handler)
    {
        //Console.WriteLine($"Using AutomaticDecompression : '{handler.AutomaticDecompression}'");
        //Console.WriteLine($"Using SupportsAutomaticDecompression : '{handler.SupportsAutomaticDecompression}'");
        //Console.WriteLine($"Using Properties : '{string.Join('\n', handler.Properties.Keys.ToArray())}'");

        var client = new HttpClient(handler);

        var response = await client.GetAsync(url);
        byte[] content = await response.Content.ReadAsByteArrayAsync();
        string text = Encoding.UTF8.GetString(content);
        Console.WriteLine($"Content: {text}");

        var hashHeader = response.Headers.GetValues("X-Goog-Hash").FirstOrDefault();
        Console.WriteLine($"Hash header: {hashHeader}");
        byteArrayToMd5(content);

        Console.WriteLine($"=====================================================================");
    }

    public static string byteArrayToMd5(byte[] content)
    {
        using (var md5 = MD5.Create())
        {
            var md5Hash = md5.ComputeHash(content);
            return Convert.ToBase64String(md5Hash);
        }
    }

    public static byte[] Compress(byte[] contentToGzip)
    {
        using (MemoryStream resultStream = new MemoryStream())
        {
            using (MemoryStream contentStreamToGzip = new MemoryStream(contentToGzip))
            {
                using (GZipStream compressionStream = new GZipStream(resultStream, CompressionMode.Compress))
                {
                    contentStreamToGzip.CopyTo(compressionStream);
                }
            }

            return resultStream.ToArray();
        }
    }
}

public class MyHandler : HttpClientHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        var response = await base.SendAsync(request, cancellationToken);
        var responseContent = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false);

        Program.byteArrayToMd5(responseContent);

        var compressedResponse = Program.Compress(responseContent);
        var compressedResponseMd5 = Program.byteArrayToMd5(compressedResponse);

        Console.WriteLine($"recompressed response to md5 : {compressedResponseMd5}");

        return response;
    }
}

4
之所以起作用,是因为我碰巧使用.NET使用默认设置gzip以该文件的内容开头。但是gzipping内容有几种不同的方法,最终会产生不同的哈希值。如果gzip是稳定的(即压缩相同的输入总是给出相同的输出),这将是可行的-但在这种情况下将不起作用:(
Jon Skeet

这是因为默认值非常奇怪(和调试,同时探听)似乎调用if语句,false因此它不应该解压缩实际上 user-images.githubusercontent.com/2266487/...
亚历山大HGS

1
如果库未发送Accept-Encoding,则服务器会即时解压缩内容。我怀疑在这种情况下就是这种情况-然后使用与原始压缩相同的设置重新压缩它,因此最终得到相同的哈希值。
乔恩·斯基特

4

禁用自动解压缩,手动添加Accept-Encoding标头,然后在哈希验证后解压缩怎么办?

private static async Task Test2()
{
    var url = @"https://www.googleapis.com/download/storage/v1/b/storage-library-test-bucket/o/gzipped-text.txt?alt=media";
    var handler = new HttpClientHandler
    {
        AutomaticDecompression = DecompressionMethods.None
    };
    var client = new HttpClient(handler);
    client.DefaultRequestHeaders.Add("Accept-Encoding", "gzip");

    var response = await client.GetAsync(url);
    var raw = await response.Content.ReadAsByteArrayAsync();

    var hashHeader = response.Headers.GetValues("X-Goog-Hash").FirstOrDefault();
    Debug.WriteLine($"Hash header: {hashHeader}");

    bool match = false;
    using (var md5 = MD5.Create())
    {
        var md5Hash = md5.ComputeHash(raw);
        var md5HashBase64 = Convert.ToBase64String(md5Hash);
        match = hashHeader.EndsWith(md5HashBase64);
        Debug.WriteLine($"MD5 of content: {md5HashBase64}");
    }

    if (match)
    {
        var memInput = new MemoryStream(raw);
        var gz = new GZipStream(memInput, CompressionMode.Decompress);
        var memOutput = new MemoryStream();
        gz.CopyTo(memOutput);
        var text = Encoding.UTF8.GetString(memOutput.ToArray());
        Console.WriteLine($"Content: {text}");
    }
}

1
这基本上是我的原型的一个简单但效率较低的版本。问题在于,当这些文件可以是多个GB时,它将整个流保留在内存中。我需要将散列插入内容返回的流中:(
Jon Skeet

1
如果我们要谈论的是千兆字节,那么此方法不可用,对不起:(
迈克尔
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.