web-dev-qa-db-fra.com

Est-il possible d'accéder aux données compressées avant décompression dans HttpClient?

Je travaille sur la bibliothèque cliente Google Cloud Storage .NET . Il existe trois fonctionnalités (entre .NET, ma bibliothèque cliente et le service de stockage) qui se combinent de manière désagréable:

  • Lors du téléchargement de fichiers (objets dans la terminologie de Google Cloud Storage), le serveur inclut un hachage des données stockées. Mon code client valide ensuite ce hachage par rapport aux données qu'il a téléchargées.

  • Une fonctionnalité distincte de Google Cloud Storage est que l'utilisateur peut définir le codage de contenu de l'objet, et cela est inclus en tant qu'en-tête lors du téléchargement, lorsque la demande contient un codage d'acceptation correspondant. (Pour le moment, ignorons le comportement lorsque la demande ne comprend pas cela ...)

  • HttpClientHandler peut décompresser le contenu gzip (ou dégonfler) automatiquement et de manière transparente.

Lorsque ces trois éléments sont combinés, nous avons des problèmes. Voici un programme court mais complet qui le démontre, mais sans utiliser ma bibliothèque cliente (et sans frapper un fichier accessible au public):

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}");
        }
    }
}

Fichier de projet .NET Core:

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

Sortie:

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

Comme vous pouvez le voir, le MD5 du contenu n'est pas le même que la partie MD5 du X-Goog-Hash entête. (Dans ma bibliothèque client, j'utilise le hachage crc32c, mais cela montre le même comportement.)

Ce n'est pas un bogue dans HttpClientHandler - c'est prévu, mais une douleur quand je veux valider le hachage. Fondamentalement, je dois au contenu avant et après la décompression. Et je ne trouve aucun moyen de le faire.

Pour clarifier quelque peu mes exigences, je sais comment empêcher la décompression dans HttpClient et décompresser ensuite lors de la lecture du flux - mais je dois pouvoir le faire sans changer le code qui utilise le résultat HttpResponseMessage dans le HttpClient. (Il y a beaucoup de code qui traite des réponses, et je veux seulement faire le changement dans un seul endroit central.)

J'ai un plan, que j'ai prototypé et qui fonctionne pour autant que je l'ai trouvé jusqu'à présent, mais qui est un peu moche. Il s'agit de créer un gestionnaire à trois couches:

  • HttpClientHandler avec décompression automatique désactivée.
  • Un nouveau gestionnaire qui remplace le flux de contenu par une nouvelle sous-classe Stream qui délègue au flux de contenu d'origine, mais hache les données lors de leur lecture.
  • Un gestionnaire de décompression uniquement, basé sur le code Microsoft DecompressionHandler .

Bien que cela fonctionne, il présente les inconvénients de:

  • Licence open source: vérifier exactement ce que je dois faire pour créer un nouveau fichier dans mon référentiel basé sur le code Microsoft sous licence MIT
  • Bifurquer efficacement le code MS, ce qui signifie que je devrais probablement faire une vérification régulière pour voir si des bogues ont été trouvés
  • Le code Microsoft utilise des membres internes de l'assembly, il ne porte donc pas aussi proprement qu'il le pourrait.

Si Microsoft rendait DecompressionHandler public, cela aiderait beaucoup - mais cela devrait être dans un délai plus long que ce dont j'ai besoin.

Ce que je recherche, c'est une approche alternative si possible - quelque chose que j'ai manqué qui me permet d'accéder au contenu avant la décompression. Je ne veux pas réinventer HttpClient - la réponse est souvent fragmentée par exemple, et je ne veux pas avoir à entrer dans ce côté des choses. C'est un point d'interception assez spécifique que je recherche.

62
Jon Skeet

En regardant ce que @Michael a fait, je me suis rendu compte que je manquais. Après avoir obtenu le contenu compressé, vous pouvez utiliser CryptoStream et GZipStream et StreamReader pour lire la réponse sans la charger en mémoire plus que nécessaire. CryptoStream hachera le contenu compressé lors de sa décompression et de sa lecture. Remplacez le StreamReader par un FileStream et vous pouvez écrire les données dans un fichier avec une utilisation minimale de la mémoire :)

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}");
            }
        }
    }
}

Sortie:

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

V2 de réponse

Après avoir lu la réponse de Jon et une réponse mise à jour, j'ai la version suivante. À peu près la même idée, mais j'ai déplacé le streaming dans un HttpContent spécial que j'injecte. Pas vraiment joli mais l'idée est là.

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;
    }
}
11
shmuelie

J'ai réussi à obtenir le headerhash correct en:

  • création d'un gestionnaire personnalisé qui hérite de HttpClientHandler
  • remplacement SendAsync
  • lire comme octet la réponse en utilisant base.SendAsync
  • Compressez-le à l'aide de GZipStream
  • Hachage du Gzip Md5 en base64 (en utilisant votre code)

ce problème est, comme vous l'avez dit "avant la décompression" n'est pas vraiment respecté ici

L'idée est de faire fonctionner ce if comme vous le souhaitez https://github.com/dotnet/corefx/blob/master/src/System.Net.Http.WinHttpHandler/src/System /Net/Http/WinHttpResponseParser.cs#L80-L91

ça correspond

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
Alexandre Hgs

Qu'en est-il de la désactivation de la décompression automatique, en ajoutant manuellement le Accept-Encoding en-tête (s) puis décompression après vérification du hachage?

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}");
    }
}
3
Michael