diff --git a/src/Middleware/HostFiltering/sample/HostFilteringSample.csproj b/src/Middleware/HostFiltering/sample/HostFilteringSample.csproj index 2bf095937a..d10e5be3fd 100644 --- a/src/Middleware/HostFiltering/sample/HostFilteringSample.csproj +++ b/src/Middleware/HostFiltering/sample/HostFilteringSample.csproj @@ -1,7 +1,7 @@  - netcoreapp2.1;net461 + netcoreapp2.2;net461 diff --git a/src/Middleware/HostFiltering/src/HostFilteringMiddleware.cs b/src/Middleware/HostFiltering/src/HostFilteringMiddleware.cs index d355edd0fc..0404d38538 100644 --- a/src/Middleware/HostFiltering/src/HostFilteringMiddleware.cs +++ b/src/Middleware/HostFiltering/src/HostFilteringMiddleware.cs @@ -102,7 +102,11 @@ namespace Microsoft.AspNetCore.HostFiltering throw new InvalidOperationException("No allowed hosts were configured."); } - _logger.LogDebug("Allowed hosts: " + string.Join("; ", allowedHosts)); + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Allowed hosts: {Hosts}", string.Join("; ", allowedHosts)); + } + _allowedHosts = allowedHosts; return _allowedHosts; } @@ -148,29 +152,26 @@ namespace Microsoft.AspNetCore.HostFiltering // Http/1.1 requires the header but the value may be empty. if (!_options.AllowEmptyHosts) { - _logger.LogInformation($"{context.Request.Protocol} request rejected due to missing or empty host header."); + _logger.LogInformation("{Protocol} request rejected due to missing or empty host header.", context.Request.Protocol); return false; } - if (_logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug($"{context.Request.Protocol} request allowed with missing or empty host header."); - } + _logger.LogDebug("{Protocol} request allowed with missing or empty host header.", context.Request.Protocol); return true; } if (_allowAnyNonEmptyHost == true) { - _logger.LogTrace($"All hosts are allowed."); + _logger.LogTrace("All hosts are allowed."); return true; } if (HostString.MatchesAny(host, allowedHosts)) { - _logger.LogTrace($"The host '{host}' matches an allowed host."); + _logger.LogTrace("The host '{Host}' matches an allowed host.", host); return true; } - _logger.LogInformation($"The host '{host}' does not match an allowed host."); + _logger.LogInformation("The host '{Host}' does not match an allowed host.", host); return false; } } diff --git a/src/Middleware/HttpOverrides/sample/HttpOverridesSample.csproj b/src/Middleware/HttpOverrides/sample/HttpOverridesSample.csproj index 1d5cf506c1..1a3cd4f427 100644 --- a/src/Middleware/HttpOverrides/sample/HttpOverridesSample.csproj +++ b/src/Middleware/HttpOverrides/sample/HttpOverridesSample.csproj @@ -1,7 +1,7 @@  - netcoreapp2.1;net461 + netcoreapp2.2;net461 diff --git a/src/Middleware/HttpOverrides/src/ForwardedHeadersMiddleware.cs b/src/Middleware/HttpOverrides/src/ForwardedHeadersMiddleware.cs index decda81d58..8143e9adad 100644 --- a/src/Middleware/HttpOverrides/src/ForwardedHeadersMiddleware.cs +++ b/src/Middleware/HttpOverrides/src/ForwardedHeadersMiddleware.cs @@ -233,7 +233,7 @@ namespace Microsoft.AspNetCore.HttpOverrides if (currentValues.RemoteIpAndPort != null && checkKnownIps && !CheckKnownAddress(currentValues.RemoteIpAndPort.Address)) { // Stop at the first unknown remote IP, but still apply changes processed so far. - _logger.LogDebug(1, $"Unknown proxy: {currentValues.RemoteIpAndPort}"); + _logger.LogDebug(1, "Unknown proxy: {RemoteIpAndPort}", currentValues.RemoteIpAndPort); break; } @@ -248,12 +248,12 @@ namespace Microsoft.AspNetCore.HttpOverrides else if (!string.IsNullOrEmpty(set.IpAndPortText)) { // Stop at the first unparsable IP, but still apply changes processed so far. - _logger.LogDebug(1, $"Unparsable IP: {set.IpAndPortText}"); + _logger.LogDebug(1, "Unparsable IP: {IpAndPortText}", set.IpAndPortText); break; } else if (_options.RequireHeaderSymmetry) { - _logger.LogWarning(2, $"Missing forwarded IPAddress."); + _logger.LogWarning(2, "Missing forwarded IPAddress."); return; } } @@ -349,6 +349,14 @@ namespace Microsoft.AspNetCore.HttpOverrides private bool CheckKnownAddress(IPAddress address) { + if (address.IsIPv4MappedToIPv6) + { + var ipv4Address = address.MapToIPv4(); + if (CheckKnownAddress(ipv4Address)) + { + return true; + } + } if (_options.KnownProxies.Contains(address)) { return true; diff --git a/src/Middleware/HttpOverrides/src/Internal/IPEndPointParser.cs b/src/Middleware/HttpOverrides/src/Internal/IPEndPointParser.cs index a797584b94..a550ee70e4 100644 --- a/src/Middleware/HttpOverrides/src/Internal/IPEndPointParser.cs +++ b/src/Middleware/HttpOverrides/src/Internal/IPEndPointParser.cs @@ -1,6 +1,7 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System.Globalization; using System.Net; namespace Microsoft.AspNetCore.HttpOverrides.Internal @@ -62,7 +63,7 @@ namespace Microsoft.AspNetCore.HttpOverrides.Internal if (portPart != null) { int port; - if (int.TryParse(portPart, out port)) + if (int.TryParse(portPart, NumberStyles.None, CultureInfo.InvariantCulture, out port)) { endpoint = new IPEndPoint(address, port); return true; diff --git a/src/Middleware/HttpOverrides/test/ForwardedHeadersMiddlewareTest.cs b/src/Middleware/HttpOverrides/test/ForwardedHeadersMiddlewareTest.cs index f88243c9e7..5f18bf4574 100644 --- a/src/Middleware/HttpOverrides/test/ForwardedHeadersMiddlewareTest.cs +++ b/src/Middleware/HttpOverrides/test/ForwardedHeadersMiddlewareTest.cs @@ -822,5 +822,48 @@ namespace Microsoft.AspNetCore.HttpOverrides Assert.Equal("localhost", context.Request.Host.ToString()); Assert.Equal("Protocol", context.Request.Scheme); } + + [Theory] + [InlineData("22.33.44.55,::ffff:127.0.0.1", "", "", "22.33.44.55")] + [InlineData("22.33.44.55,::ffff:172.123.142.121", "172.123.142.121", "", "22.33.44.55")] + [InlineData("22.33.44.55,::ffff:172.123.142.121", "::ffff:172.123.142.121", "", "22.33.44.55")] + [InlineData("22.33.44.55,::ffff:172.123.142.121,172.32.24.23", "", "172.0.0.0/8", "22.33.44.55")] + [InlineData("2a00:1450:4009:802::200e,2a02:26f0:2d:183::356e,::ffff:172.123.142.121,172.32.24.23", "", "172.0.0.0/8,2a02:26f0:2d:183::1/64", "2a00:1450:4009:802::200e")] + [InlineData("22.33.44.55,2a02:26f0:2d:183::356e,::ffff:127.0.0.1", "2a02:26f0:2d:183::356e", "", "22.33.44.55")] + public async Task XForwardForIPv4ToIPv6Mapping(string forHeader, string knownProxies, string knownNetworks, string expectedRemoteIp) + { + var options = new ForwardedHeadersOptions + { + ForwardedHeaders = ForwardedHeaders.XForwardedFor, + ForwardLimit = null, + }; + + foreach (var knownProxy in knownProxies.Split(new string[] { "," }, StringSplitOptions.RemoveEmptyEntries)) + { + var proxy = IPAddress.Parse(knownProxy); + options.KnownProxies.Add(proxy); + } + foreach (var knownNetwork in knownNetworks.Split(new string[] { "," }, options:StringSplitOptions.RemoveEmptyEntries)) + { + var knownNetworkParts = knownNetwork.Split('/'); + var networkIp = IPAddress.Parse(knownNetworkParts[0]); + var prefixLength = int.Parse(knownNetworkParts[1]); + options.KnownNetworks.Add(new IPNetwork(networkIp, prefixLength)); + } + + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseForwardedHeaders(options); + }); + var server = new TestServer(builder); + + var context = await server.SendAsync(c => + { + c.Request.Headers["X-Forwarded-For"] = forHeader; + }); + + Assert.Equal(expectedRemoteIp, context.Connection.RemoteIpAddress.ToString()); + } } } diff --git a/src/Middleware/HttpsPolicy/sample/HttpsPolicySample.csproj b/src/Middleware/HttpsPolicy/sample/HttpsPolicySample.csproj index 3bbe805f9f..80a9169649 100644 --- a/src/Middleware/HttpsPolicy/sample/HttpsPolicySample.csproj +++ b/src/Middleware/HttpsPolicy/sample/HttpsPolicySample.csproj @@ -1,7 +1,7 @@  - net461;netcoreapp2.0 + netcoreapp2.2;net461 diff --git a/src/Middleware/HttpsPolicy/src/HstsMiddleware.cs b/src/Middleware/HttpsPolicy/src/HstsMiddleware.cs index 252ae44c1e..d192785064 100644 --- a/src/Middleware/HttpsPolicy/src/HstsMiddleware.cs +++ b/src/Middleware/HttpsPolicy/src/HstsMiddleware.cs @@ -6,6 +6,9 @@ using System.Collections.Generic; using System.Globalization; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.HttpsPolicy.Internal; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; @@ -24,13 +27,15 @@ namespace Microsoft.AspNetCore.HttpsPolicy private readonly RequestDelegate _next; private readonly StringValues _strictTransportSecurityValue; private readonly IList _excludedHosts; + private readonly ILogger _logger; /// /// Initialize the HSTS middleware. /// /// /// - public HstsMiddleware(RequestDelegate next, IOptions options) + /// + public HstsMiddleware(RequestDelegate next, IOptions options, ILoggerFactory loggerFactory) { if (options == null) { @@ -46,8 +51,17 @@ namespace Microsoft.AspNetCore.HttpsPolicy var preload = hstsOptions.Preload ? Preload : StringSegment.Empty; _strictTransportSecurityValue = new StringValues($"max-age={maxAge}{includeSubdomains}{preload}"); _excludedHosts = hstsOptions.ExcludedHosts; + _logger = loggerFactory.CreateLogger(); } + /// + /// Initialize the HSTS middleware. + /// + /// + /// + public HstsMiddleware(RequestDelegate next, IOptions options) + : this(next, options, NullLoggerFactory.Instance) { } + /// /// Invoke the middleware. /// @@ -55,11 +69,21 @@ namespace Microsoft.AspNetCore.HttpsPolicy /// public Task Invoke(HttpContext context) { - if (context.Request.IsHttps && !IsHostExcluded(context.Request.Host.Host)) + if (!context.Request.IsHttps) { - context.Response.Headers[HeaderNames.StrictTransportSecurity] = _strictTransportSecurityValue; + _logger.SkippingInsecure(); + return _next(context); } + if (IsHostExcluded(context.Request.Host.Host)) + { + _logger.SkippingExcludedHost(context.Request.Host.Host); + return _next(context); + } + + context.Response.Headers[HeaderNames.StrictTransportSecurity] = _strictTransportSecurityValue; + _logger.AddingHstsHeader(); + return _next(context); } diff --git a/src/Middleware/HttpsPolicy/src/HttpsRedirectionOptions.cs b/src/Middleware/HttpsPolicy/src/HttpsRedirectionOptions.cs index cc46b0155a..07a166c162 100644 --- a/src/Middleware/HttpsPolicy/src/HttpsRedirectionOptions.cs +++ b/src/Middleware/HttpsPolicy/src/HttpsRedirectionOptions.cs @@ -22,7 +22,7 @@ namespace Microsoft.AspNetCore.HttpsPolicy /// If the HttpsPort is not set, we will try to get the HttpsPort from the following: /// 1. HTTPS_PORT environment variable /// 2. IServerAddressesFeature - /// 3. 443 (or not set) + /// If that fails then the middleware will log a warning and turn off. /// public int? HttpsPort { get; set; } } diff --git a/src/Middleware/HttpsPolicy/src/internal/HstsLoggingExtensions.cs b/src/Middleware/HttpsPolicy/src/internal/HstsLoggingExtensions.cs new file mode 100644 index 0000000000..5162ccb9f5 --- /dev/null +++ b/src/Middleware/HttpsPolicy/src/internal/HstsLoggingExtensions.cs @@ -0,0 +1,37 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.HttpsPolicy.Internal +{ + internal static class HstsLoggingExtensions + { + private static readonly Action _notSecure; + private static readonly Action _excludedHost; + private static readonly Action _addingHstsHeader; + + static HstsLoggingExtensions() + { + _notSecure = LoggerMessage.Define(LogLevel.Debug, 1, "The request is insecure. Skipping HSTS header."); + _excludedHost = LoggerMessage.Define(LogLevel.Debug, 2, "The host '{host}' is excluded. Skipping HSTS header."); + _addingHstsHeader = LoggerMessage.Define(LogLevel.Trace, 3, "Adding HSTS header to response."); + } + + public static void SkippingInsecure(this ILogger logger) + { + _notSecure(logger, null); + } + + public static void SkippingExcludedHost(this ILogger logger, string host) + { + _excludedHost(logger, host, null); + } + + public static void AddingHstsHeader(this ILogger logger) + { + _addingHstsHeader(logger, null); + } + } +} diff --git a/src/Middleware/HttpsPolicy/test/HstsMiddlewareTests.cs b/src/Middleware/HttpsPolicy/test/HstsMiddlewareTests.cs index 08df78f7c2..0cb5f5755c 100644 --- a/src/Middleware/HttpsPolicy/test/HstsMiddlewareTests.cs +++ b/src/Middleware/HttpsPolicy/test/HstsMiddlewareTests.cs @@ -12,6 +12,8 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; using Microsoft.Net.Http.Headers; using Xunit; @@ -131,7 +133,16 @@ namespace Microsoft.AspNetCore.HttpsPolicy.Tests [InlineData("[::1]")] public async Task DefaultExcludesCommonLocalhostDomains_DoesNotSetHstsHeader(string host) { + var sink = new TestSink( + TestSink.EnableWithTypeName, + TestSink.EnableWithTypeName); + var loggerFactory = new TestLoggerFactory(sink, enabled: true); + var builder = new WebHostBuilder() + .ConfigureServices(services => + { + services.AddSingleton(loggerFactory); + }) .Configure(app => { app.UseHsts(); @@ -149,6 +160,13 @@ namespace Microsoft.AspNetCore.HttpsPolicy.Tests Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Empty(response.Headers); + + var logMessages = sink.Writes.ToList(); + + Assert.Single(logMessages); + var message = logMessages.Single(); + Assert.Equal(LogLevel.Debug, message.LogLevel); + Assert.Equal($"The host '{host}' is excluded. Skipping HSTS header.", message.State.ToString(), ignoreCase: true); } [Theory] @@ -157,9 +175,16 @@ namespace Microsoft.AspNetCore.HttpsPolicy.Tests [InlineData("[::1]")] public async Task AllowLocalhostDomainsIfListIsReset_SetHstsHeader(string host) { + var sink = new TestSink( + TestSink.EnableWithTypeName, + TestSink.EnableWithTypeName); + var loggerFactory = new TestLoggerFactory(sink, enabled: true); + var builder = new WebHostBuilder() .ConfigureServices(services => { + services.AddSingleton(loggerFactory); + services.AddHsts(options => { options.ExcludedHosts.Clear(); @@ -182,6 +207,13 @@ namespace Microsoft.AspNetCore.HttpsPolicy.Tests Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Single(response.Headers); + + var logMessages = sink.Writes.ToList(); + + Assert.Single(logMessages); + var message = logMessages.Single(); + Assert.Equal(LogLevel.Trace, message.LogLevel); + Assert.Equal("Adding HSTS header to response.", message.State.ToString()); } [Theory] @@ -190,9 +222,16 @@ namespace Microsoft.AspNetCore.HttpsPolicy.Tests [InlineData("EXAMPLE.COM")] public async Task AddExcludedDomains_DoesNotAddHstsHeader(string host) { + var sink = new TestSink( + TestSink.EnableWithTypeName, + TestSink.EnableWithTypeName); + var loggerFactory = new TestLoggerFactory(sink, enabled: true); + var builder = new WebHostBuilder() .ConfigureServices(services => { + services.AddSingleton(loggerFactory); + services.AddHsts(options => { options.ExcludedHosts.Add(host); }); @@ -214,6 +253,91 @@ namespace Microsoft.AspNetCore.HttpsPolicy.Tests Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Empty(response.Headers); + + var logMessages = sink.Writes.ToList(); + + Assert.Single(logMessages); + var message = logMessages.Single(); + Assert.Equal(LogLevel.Debug, message.LogLevel); + Assert.Equal($"The host '{host}' is excluded. Skipping HSTS header.", message.State.ToString(), ignoreCase: true); + } + + [Fact] + public async Task WhenRequestIsInsecure_DoesNotAddHstsHeader() + { + var sink = new TestSink( + TestSink.EnableWithTypeName, + TestSink.EnableWithTypeName); + var loggerFactory = new TestLoggerFactory(sink, enabled: true); + + var builder = new WebHostBuilder() + .ConfigureServices(services => + { + services.AddSingleton(loggerFactory); + }) + .Configure(app => + { + app.UseHsts(); + app.Run(context => + { + return context.Response.WriteAsync("Hello world"); + }); + }); + var server = new TestServer(builder); + var client = server.CreateClient(); + client.BaseAddress = new Uri("http://example.com:5050"); + var request = new HttpRequestMessage(HttpMethod.Get, ""); + + var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Empty(response.Headers); + + var logMessages = sink.Writes.ToList(); + + Assert.Single(logMessages); + var message = logMessages.Single(); + Assert.Equal(LogLevel.Debug, message.LogLevel); + Assert.Equal("The request is insecure. Skipping HSTS header.", message.State.ToString()); + } + + [Fact] + public async Task WhenRequestIsSecure_AddsHstsHeader() + { + var sink = new TestSink( + TestSink.EnableWithTypeName, + TestSink.EnableWithTypeName); + var loggerFactory = new TestLoggerFactory(sink, enabled: true); + + var builder = new WebHostBuilder() + .ConfigureServices(services => + { + services.AddSingleton(loggerFactory); + }) + .Configure(app => + { + app.UseHsts(); + app.Run(context => + { + return context.Response.WriteAsync("Hello world"); + }); + }); + var server = new TestServer(builder); + var client = server.CreateClient(); + client.BaseAddress = new Uri("https://example.com:5050"); + var request = new HttpRequestMessage(HttpMethod.Get, ""); + + var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains(response.Headers, x => x.Key == HeaderNames.StrictTransportSecurity); + + var logMessages = sink.Writes.ToList(); + + Assert.Single(logMessages); + var message = logMessages.Single(); + Assert.Equal(LogLevel.Trace, message.LogLevel); + Assert.Equal("Adding HSTS header to response.", message.State.ToString()); } } } diff --git a/src/Middleware/HttpsPolicy/test/Microsoft.AspNetCore.HttpsPolicy.Tests.csproj b/src/Middleware/HttpsPolicy/test/Microsoft.AspNetCore.HttpsPolicy.Tests.csproj index c93a869b78..e6118f3e2c 100644 --- a/src/Middleware/HttpsPolicy/test/Microsoft.AspNetCore.HttpsPolicy.Tests.csproj +++ b/src/Middleware/HttpsPolicy/test/Microsoft.AspNetCore.HttpsPolicy.Tests.csproj @@ -1,8 +1,7 @@ - + - netcoreapp2.1 - $(TargetFrameworks);netcoreapp2.0 + netcoreapp2.2 diff --git a/src/Middleware/ResponseCompression/perf/AssemblyInfo.cs b/src/Middleware/ResponseCompression/perf/AssemblyInfo.cs new file mode 100644 index 0000000000..409fcf814a --- /dev/null +++ b/src/Middleware/ResponseCompression/perf/AssemblyInfo.cs @@ -0,0 +1,4 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +[assembly: BenchmarkDotNet.Attributes.AspNetCoreBenchmark] diff --git a/src/Middleware/ResponseCompression/perf/Microsoft.AspNetCore.ResponseCompression.Benchmarks.csproj b/src/Middleware/ResponseCompression/perf/Microsoft.AspNetCore.ResponseCompression.Benchmarks.csproj new file mode 100644 index 0000000000..452fab3f2c --- /dev/null +++ b/src/Middleware/ResponseCompression/perf/Microsoft.AspNetCore.ResponseCompression.Benchmarks.csproj @@ -0,0 +1,16 @@ + + + + Exe + netcoreapp2.2 + + + + + + + + + + + diff --git a/src/Middleware/ResponseCompression/perf/ResponseCompressionProviderBenchmark.cs b/src/Middleware/ResponseCompression/perf/ResponseCompressionProviderBenchmark.cs new file mode 100644 index 0000000000..4e4b78fcb7 --- /dev/null +++ b/src/Middleware/ResponseCompression/perf/ResponseCompressionProviderBenchmark.cs @@ -0,0 +1,57 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using BenchmarkDotNet.Attributes; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.ResponseCompression.Benchmarks +{ + public class ResponseCompressionProviderBenchmark + { + [GlobalSetup] + public void GlobalSetup() + { + var services = new ServiceCollection() + .AddOptions() + .AddResponseCompression() + .BuildServiceProvider(); + + var options = new ResponseCompressionOptions(); + + Provider = new ResponseCompressionProvider(services, Options.Create(options)); + } + + [ParamsSource(nameof(EncodingStrings))] + public string AcceptEncoding { get; set; } + + public static IEnumerable EncodingStrings() + { + return new[] + { + "gzip;q=0.8, compress;q=0.6, br;q=0.4", + "gzip, compress, br", + "br, compress, gzip", + "gzip, compress", + "identity", + "*" + }; + } + + public ResponseCompressionProvider Provider { get; set; } + + [Benchmark] + public ICompressionProvider GetCompressionProvider() + { + var context = new DefaultHttpContext(); + + context.Request.Headers[HeaderNames.AcceptEncoding] = AcceptEncoding; + + return Provider.GetCompressionProvider(context); + } + } +} \ No newline at end of file diff --git a/src/Middleware/ResponseCompression/sample/ResponseCompressionSample.csproj b/src/Middleware/ResponseCompression/sample/ResponseCompressionSample.csproj index c9aa982896..e73d9312ed 100644 --- a/src/Middleware/ResponseCompression/sample/ResponseCompressionSample.csproj +++ b/src/Middleware/ResponseCompression/sample/ResponseCompressionSample.csproj @@ -1,7 +1,7 @@  - netcoreapp2.1;net461 + netcoreapp2.2;net461 diff --git a/src/Middleware/ResponseCompression/sample/Startup.cs b/src/Middleware/ResponseCompression/sample/Startup.cs index e10baad03c..8489864aea 100644 --- a/src/Middleware/ResponseCompression/sample/Startup.cs +++ b/src/Middleware/ResponseCompression/sample/Startup.cs @@ -26,6 +26,11 @@ namespace ResponseCompressionSample options.Providers.Add(); // .Append(TItem) is only available on Core. options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(new[] { "image/svg+xml" }); + + ////Example of using excluded and wildcard MIME types: + ////Compress all MIME types except various media types, but do compress SVG images. + //options.MimeTypes = new[] { "*/*", "image/svg+xml" }; + //options.ExcludedMimeTypes = new[] { "image/*", "audio/*", "video/*" }; }); } diff --git a/src/Middleware/ResponseCompression/src/BrotliCompressionProvider.cs b/src/Middleware/ResponseCompression/src/BrotliCompressionProvider.cs new file mode 100644 index 0000000000..20a88dd30a --- /dev/null +++ b/src/Middleware/ResponseCompression/src/BrotliCompressionProvider.cs @@ -0,0 +1,51 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.IO.Compression; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.ResponseCompression +{ + /// + /// Brotli compression provider. + /// + public class BrotliCompressionProvider : ICompressionProvider + { + /// + /// Creates a new instance of with options. + /// + /// + public BrotliCompressionProvider(IOptions options) + { + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + Options = options.Value; + } + + private BrotliCompressionProviderOptions Options { get; } + + /// + public string EncodingName => "br"; + + /// + public bool SupportsFlush => true; + + /// + public Stream CreateStream(Stream outputStream) + { +#if NETCOREAPP2_1 + return new BrotliStream(outputStream, Options.Level, leaveOpen: true); +#elif NET461 || NETSTANDARD2_0 + // Brotli is only supported in .NET Core 2.1+ + throw new PlatformNotSupportedException(); +#else +#error Target frameworks need to be updated. +#endif + } + } +} diff --git a/src/Middleware/ResponseCompression/src/BrotliCompressionProviderOptions.cs b/src/Middleware/ResponseCompression/src/BrotliCompressionProviderOptions.cs new file mode 100644 index 0000000000..029f22b854 --- /dev/null +++ b/src/Middleware/ResponseCompression/src/BrotliCompressionProviderOptions.cs @@ -0,0 +1,22 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.IO.Compression; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.ResponseCompression +{ + /// + /// Options for the + /// + public class BrotliCompressionProviderOptions : IOptions + { + /// + /// What level of compression to use for the stream. The default is . + /// + public CompressionLevel Level { get; set; } = CompressionLevel.Fastest; + + /// + BrotliCompressionProviderOptions IOptions.Value => this; + } +} diff --git a/src/Middleware/ResponseCompression/src/GzipCompressionProvider.cs b/src/Middleware/ResponseCompression/src/GzipCompressionProvider.cs index e825b37976..474f255111 100644 --- a/src/Middleware/ResponseCompression/src/GzipCompressionProvider.cs +++ b/src/Middleware/ResponseCompression/src/GzipCompressionProvider.cs @@ -39,7 +39,7 @@ namespace Microsoft.AspNetCore.ResponseCompression { #if NET461 return false; -#elif NETSTANDARD2_0 +#elif NETSTANDARD2_0 || NETCOREAPP2_1 return true; #else #error target frameworks need to be updated diff --git a/src/Middleware/ResponseCompression/src/Microsoft.AspNetCore.ResponseCompression.csproj b/src/Middleware/ResponseCompression/src/Microsoft.AspNetCore.ResponseCompression.csproj index 1b20b7b4a9..ef07d6b64b 100644 --- a/src/Middleware/ResponseCompression/src/Microsoft.AspNetCore.ResponseCompression.csproj +++ b/src/Middleware/ResponseCompression/src/Microsoft.AspNetCore.ResponseCompression.csproj @@ -2,14 +2,20 @@ ASP.NET Core middleware for HTTP Response compression. - net461;netstandard2.0 + net461;netstandard2.0;netcoreapp2.1 true aspnetcore +<<<<<<< HEAD:src/Microsoft.AspNetCore.ResponseCompression/Microsoft.AspNetCore.ResponseCompression.csproj + + + +======= +>>>>>>> m21:src/Middleware/ResponseCompression/src/Microsoft.AspNetCore.ResponseCompression.csproj diff --git a/src/Middleware/ResponseCompression/src/ResponseCompressionDefaults.cs b/src/Middleware/ResponseCompression/src/ResponseCompressionDefaults.cs index 510d18786e..95b7827bb0 100644 --- a/src/Middleware/ResponseCompression/src/ResponseCompressionDefaults.cs +++ b/src/Middleware/ResponseCompression/src/ResponseCompressionDefaults.cs @@ -27,6 +27,8 @@ namespace Microsoft.AspNetCore.ResponseCompression "text/xml", "application/json", "text/json", + // WebAssembly + "application/wasm", }; } } diff --git a/src/Middleware/ResponseCompression/src/ResponseCompressionOptions.cs b/src/Middleware/ResponseCompression/src/ResponseCompressionOptions.cs index c6b33c9737..45168d04d5 100644 --- a/src/Middleware/ResponseCompression/src/ResponseCompressionOptions.cs +++ b/src/Middleware/ResponseCompression/src/ResponseCompressionOptions.cs @@ -15,14 +15,20 @@ namespace Microsoft.AspNetCore.ResponseCompression /// public IEnumerable MimeTypes { get; set; } + /// + /// Response Content-Type MIME types to not compress. + /// + public IEnumerable ExcludedMimeTypes { get; set; } + /// /// Indicates if responses over HTTPS connections should be compressed. The default is 'false'. - /// Enable compression on HTTPS connections may expose security problems. + /// Enabling compression on HTTPS connections may expose security problems. /// public bool EnableForHttps { get; set; } = false; /// - /// The ICompressionProviders to use for responses. + /// The types to use for responses. + /// Providers are prioritized based on the order they are added. /// public CompressionProviderCollection Providers { get; } = new CompressionProviderCollection(); } diff --git a/src/Middleware/ResponseCompression/src/ResponseCompressionProvider.cs b/src/Middleware/ResponseCompression/src/ResponseCompressionProvider.cs index e5f577f00d..b63ff7380e 100644 --- a/src/Middleware/ResponseCompression/src/ResponseCompressionProvider.cs +++ b/src/Middleware/ResponseCompression/src/ResponseCompressionProvider.cs @@ -3,8 +3,12 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.ResponseCompression.Internal; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; @@ -16,7 +20,9 @@ namespace Microsoft.AspNetCore.ResponseCompression { private readonly ICompressionProvider[] _providers; private readonly HashSet _mimeTypes; + private readonly HashSet _excludedMimeTypes; private readonly bool _enableForHttps; + private readonly ILogger _logger; /// /// If no compression providers are specified then GZip is used by default. @@ -34,11 +40,23 @@ namespace Microsoft.AspNetCore.ResponseCompression throw new ArgumentNullException(nameof(options)); } - _providers = options.Value.Providers.ToArray(); + var responseCompressionOptions = options.Value; + + _providers = responseCompressionOptions.Providers.ToArray(); if (_providers.Length == 0) { // Use the factory so it can resolve IOptions from DI. - _providers = new ICompressionProvider[] { new CompressionProviderFactory(typeof(GzipCompressionProvider)) }; + _providers = new ICompressionProvider[] + { +#if NETCOREAPP2_1 + new CompressionProviderFactory(typeof(BrotliCompressionProvider)), +#elif NET461 || NETSTANDARD2_0 + // Brotli is only supported in .NET Core 2.1+ +#else +#error Target frameworks need to be updated. +#endif + new CompressionProviderFactory(typeof(GzipCompressionProvider)), + }; } for (var i = 0; i < _providers.Length; i++) { @@ -49,58 +67,110 @@ namespace Microsoft.AspNetCore.ResponseCompression } } - var mimeTypes = options.Value.MimeTypes; + var mimeTypes = responseCompressionOptions.MimeTypes; if (mimeTypes == null || !mimeTypes.Any()) { mimeTypes = ResponseCompressionDefaults.MimeTypes; } + _mimeTypes = new HashSet(mimeTypes, StringComparer.OrdinalIgnoreCase); - _enableForHttps = options.Value.EnableForHttps; + _excludedMimeTypes = new HashSet( + responseCompressionOptions.ExcludedMimeTypes ?? Enumerable.Empty(), + StringComparer.OrdinalIgnoreCase + ); + + _enableForHttps = responseCompressionOptions.EnableForHttps; + + _logger = services.GetRequiredService>(); } /// public virtual ICompressionProvider GetCompressionProvider(HttpContext context) { - IList unsorted; - // e.g. Accept-Encoding: gzip, deflate, sdch var accept = context.Request.Headers[HeaderNames.AcceptEncoding]; - if (!StringValues.IsNullOrEmpty(accept) - && StringWithQualityHeaderValue.TryParseList(accept, out unsorted) - && unsorted != null && unsorted.Count > 0) + + // Note this is already checked in CheckRequestAcceptsCompression which _should_ prevent any of these other methods from being called. + if (StringValues.IsNullOrEmpty(accept)) { - // TODO PERF: clients don't usually include quality values so this sort will not have any effect. Fast-path? - var sorted = unsorted - .Where(s => s.Quality.GetValueOrDefault(1) > 0) - .OrderByDescending(s => s.Quality.GetValueOrDefault(1)); + Debug.Assert(false, "Duplicate check failed."); + _logger.NoAcceptEncoding(); + return null; + } - foreach (var encoding in sorted) + if (!StringWithQualityHeaderValue.TryParseList(accept, out var encodings) || !encodings.Any()) + { + _logger.NoAcceptEncoding(); + return null; + } + + var candidates = new HashSet(); + + foreach (var encoding in encodings) + { + var encodingName = encoding.Value; + var quality = encoding.Quality.GetValueOrDefault(1); + + if (quality < double.Epsilon) { - // There will rarely be more than three providers, and there's only one by default - foreach (var provider in _providers) + continue; + } + + for (int i = 0; i < _providers.Length; i++) + { + var provider = _providers[i]; + + if (StringSegment.Equals(provider.EncodingName, encodingName, StringComparison.OrdinalIgnoreCase)) { - if (StringSegment.Equals(provider.EncodingName, encoding.Value, StringComparison.OrdinalIgnoreCase)) - { - return provider; - } + candidates.Add(new ProviderCandidate(provider.EncodingName, quality, i, provider)); + } + } + + // Uncommon but valid options + if (StringSegment.Equals("*", encodingName, StringComparison.Ordinal)) + { + for (int i = 0; i < _providers.Length; i++) + { + var provider = _providers[i]; + + // Any provider is a candidate. + candidates.Add(new ProviderCandidate(provider.EncodingName, quality, i, provider)); } - // Uncommon but valid options - if (StringSegment.Equals("*", encoding.Value, StringComparison.Ordinal)) - { - // Any - return _providers[0]; - } - if (StringSegment.Equals("identity", encoding.Value, StringComparison.OrdinalIgnoreCase)) - { - // No compression - return null; - } + break; + } + + if (StringSegment.Equals("identity", encodingName, StringComparison.OrdinalIgnoreCase)) + { + // We add 'identity' to the list of "candidates" with a very low priority and no provider. + // This will allow it to be ordered based on its quality (and priority) later in the method. + candidates.Add(new ProviderCandidate(encodingName.Value, quality, priority: int.MaxValue, provider: null)); } } - return null; + ICompressionProvider selectedProvider = null; + if (candidates.Count <= 1) + { + selectedProvider = candidates.FirstOrDefault().Provider; + } + else + { + selectedProvider = candidates + .OrderByDescending(x => x.Quality) + .ThenBy(x => x.Priority) + .First().Provider; + } + + if (selectedProvider == null) + { + // "identity" would match as a candidate but not have a provider implementation + _logger.NoCompressionProvider(); + return null; + } + + _logger.CompressingWith(selectedProvider.EncodingName); + return selectedProvider; } /// @@ -108,6 +178,13 @@ namespace Microsoft.AspNetCore.ResponseCompression { if (context.Response.Headers.ContainsKey(HeaderNames.ContentRange)) { + _logger.NoCompressionDueToHeader(HeaderNames.ContentRange); + return false; + } + + if (context.Response.Headers.ContainsKey(HeaderNames.ContentEncoding)) + { + _logger.NoCompressionDueToHeader(HeaderNames.ContentEncoding); return false; } @@ -115,6 +192,7 @@ namespace Microsoft.AspNetCore.ResponseCompression if (string.IsNullOrEmpty(mimeType)) { + _logger.NoCompressionForContentType(mimeType); return false; } @@ -126,8 +204,18 @@ namespace Microsoft.AspNetCore.ResponseCompression mimeType = mimeType.Trim(); } - // TODO PERF: StringSegments? - return _mimeTypes.Contains(mimeType); + var shouldCompress = ShouldCompressExact(mimeType) //check exact match type/subtype + ?? ShouldCompressPartial(mimeType) //check partial match type/* + ?? _mimeTypes.Contains("*/*"); //check wildcard */* + + if (shouldCompress) + { + _logger.ShouldCompressResponse(); // Trace, there will be more logs + return true; + } + + _logger.NoCompressionForContentType(mimeType); + return false; } /// @@ -135,9 +223,81 @@ namespace Microsoft.AspNetCore.ResponseCompression { if (context.Request.IsHttps && !_enableForHttps) { + _logger.NoCompressionForHttps(); return false; } - return !string.IsNullOrEmpty(context.Request.Headers[HeaderNames.AcceptEncoding]); + + if (string.IsNullOrEmpty(context.Request.Headers[HeaderNames.AcceptEncoding])) + { + _logger.NoAcceptEncoding(); + return false; + } + + _logger.RequestAcceptsCompression(); // Trace, there will be more logs + return true; + } + + private bool? ShouldCompressExact(string mimeType) + { + //Check excluded MIME types first, then included + if (_excludedMimeTypes.Contains(mimeType)) + { + return false; + } + + if (_mimeTypes.Contains(mimeType)) + { + return true; + } + + return null; + } + + private bool? ShouldCompressPartial(string mimeType) + { + int? slashPos = mimeType?.IndexOf('/'); + + if (slashPos >= 0) + { + string partialMimeType = mimeType.Substring(0, slashPos.Value) + "/*"; + return ShouldCompressExact(partialMimeType); + } + + return null; + } + + private readonly struct ProviderCandidate : IEquatable + { + public ProviderCandidate(string encodingName, double quality, int priority, ICompressionProvider provider) + { + EncodingName = encodingName; + Quality = quality; + Priority = priority; + Provider = provider; + } + + public string EncodingName { get; } + + public double Quality { get; } + + public int Priority { get; } + + public ICompressionProvider Provider { get; } + + public bool Equals(ProviderCandidate other) + { + return string.Equals(EncodingName, other.EncodingName, StringComparison.OrdinalIgnoreCase); + } + + public override bool Equals(object obj) + { + return obj is ProviderCandidate candidate && Equals(candidate); + } + + public override int GetHashCode() + { + return StringComparer.OrdinalIgnoreCase.GetHashCode(EncodingName); + } } } } diff --git a/src/Middleware/ResponseCompression/src/internal/ResponseCompressionLoggingExtensions.cs b/src/Middleware/ResponseCompression/src/internal/ResponseCompressionLoggingExtensions.cs new file mode 100644 index 0000000000..75f3d07fd3 --- /dev/null +++ b/src/Middleware/ResponseCompression/src/internal/ResponseCompressionLoggingExtensions.cs @@ -0,0 +1,72 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.ResponseCompression.Internal +{ + internal static class ResponseCompressionLoggingExtensions + { + private static readonly Action _noAcceptEncoding; + private static readonly Action _noCompressionForHttps; + private static readonly Action _requestAcceptsCompression; + private static readonly Action _noCompressionDueToHeader; + private static readonly Action _noCompressionForContentType; + private static readonly Action _shouldCompressResponse; + private static readonly Action _noCompressionProvider; + private static readonly Action _compressWith; + + static ResponseCompressionLoggingExtensions() + { + _noAcceptEncoding = LoggerMessage.Define(LogLevel.Debug, 1, "No response compression available, the Accept-Encoding header is missing or invalid."); + _noCompressionForHttps = LoggerMessage.Define(LogLevel.Debug, 2, "No response compression available for HTTPS requests. See ResponseCompressionOptions.EnableForHttps."); + _requestAcceptsCompression = LoggerMessage.Define(LogLevel.Trace, 3, "This request accepts compression."); + _noCompressionDueToHeader = LoggerMessage.Define(LogLevel.Debug, 4, "Response compression disabled due to the {header} header."); + _noCompressionForContentType = LoggerMessage.Define(LogLevel.Debug, 5, "Response compression is not enabled for the Content-Type '{header}'."); + _shouldCompressResponse = LoggerMessage.Define(LogLevel.Trace, 6, "Response compression is available for this Content-Type."); + _noCompressionProvider = LoggerMessage.Define(LogLevel.Debug, 7, "No matching response compression provider found."); + _compressWith = LoggerMessage.Define(LogLevel.Debug, 8, "The response will be compressed with '{provider}'."); + } + + public static void NoAcceptEncoding(this ILogger logger) + { + _noAcceptEncoding(logger, null); + } + + public static void NoCompressionForHttps(this ILogger logger) + { + _noCompressionForHttps(logger, null); + } + + public static void RequestAcceptsCompression(this ILogger logger) + { + _requestAcceptsCompression(logger, null); + } + + public static void NoCompressionDueToHeader(this ILogger logger, string header) + { + _noCompressionDueToHeader(logger, header, null); + } + + public static void NoCompressionForContentType(this ILogger logger, string header) + { + _noCompressionForContentType(logger, header, null); + } + + public static void ShouldCompressResponse(this ILogger logger) + { + _shouldCompressResponse(logger, null); + } + + public static void NoCompressionProvider(this ILogger logger) + { + _noCompressionProvider(logger, null); + } + + public static void CompressingWith(this ILogger logger, string provider) + { + _compressWith(logger, provider, null); + } + } +} diff --git a/src/Middleware/ResponseCompression/test/Microsoft.AspNetCore.ResponseCompression.Tests.csproj b/src/Middleware/ResponseCompression/test/Microsoft.AspNetCore.ResponseCompression.Tests.csproj index 762a9a1ddd..cce4750f61 100644 --- a/src/Middleware/ResponseCompression/test/Microsoft.AspNetCore.ResponseCompression.Tests.csproj +++ b/src/Middleware/ResponseCompression/test/Microsoft.AspNetCore.ResponseCompression.Tests.csproj @@ -12,6 +12,7 @@ + diff --git a/src/Middleware/ResponseCompression/test/ResponseCompressionMiddlewareTest.cs b/src/Middleware/ResponseCompression/test/ResponseCompressionMiddlewareTest.cs index 213a4c8fd7..8cf9d3bf1e 100644 --- a/src/Middleware/ResponseCompression/test/ResponseCompressionMiddlewareTest.cs +++ b/src/Middleware/ResponseCompression/test/ResponseCompressionMiddlewareTest.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.IO; using System.IO.Compression; +using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -14,6 +15,8 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; using Microsoft.Net.Http.Headers; using Xunit; @@ -23,6 +26,26 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests { private const string TextPlain = "text/plain"; + public static IEnumerable SupportedEncodings => + TestData.Select(x => new object[] { x.EncodingName }); + + public static IEnumerable SupportedEncodingsWithBodyLength => + TestData.Select(x => new object[] { x.EncodingName, x.ExpectedBodyLength }); + + private static IEnumerable TestData + { + get + { + yield return new EncodingTestData("gzip", expectedBodyLength: 24); +#if NETCOREAPP2_2 + yield return new EncodingTestData("br", expectedBodyLength: 20); +#elif NET461 +#else +#error Target frameworks need to be updated. +#endif + } + } + [Fact] public void Options_HttpsDisabledByDefault() { @@ -34,25 +57,86 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests [Fact] public async Task Request_NoAcceptEncoding_Uncompressed() { - var response = await InvokeMiddleware(100, requestAcceptEncodings: null, responseType: TextPlain); + var (response, logMessages) = await InvokeMiddleware(100, requestAcceptEncodings: null, responseType: TextPlain); CheckResponseNotCompressed(response, expectedBodyLength: 100, sendVaryHeader: false); + AssertLog(logMessages.Single(), LogLevel.Debug, "No response compression available, the Accept-Encoding header is missing or invalid."); } [Fact] public async Task Request_AcceptGzipDeflate_CompressedGzip() { - var response = await InvokeMiddleware(100, requestAcceptEncodings: new string[] { "gzip", "deflate" }, responseType: TextPlain); + var (response, logMessages) = await InvokeMiddleware(100, requestAcceptEncodings: new[] { "gzip", "deflate" }, responseType: TextPlain); - CheckResponseCompressed(response, expectedBodyLength: 24); + CheckResponseCompressed(response, expectedBodyLength: 24, expectedEncoding: "gzip"); + AssertCompressedWithLog(logMessages, "gzip"); } + [Fact] + public async Task Request_AcceptBrotli_CompressedBrotli() + { + var (response, logMessages) = await InvokeMiddleware(100, requestAcceptEncodings: new[] { "br" }, responseType: TextPlain); + +#if NET461 + CheckResponseNotCompressed(response, expectedBodyLength: 100, sendVaryHeader: true); +#elif NETCOREAPP2_2 + CheckResponseCompressed(response, expectedBodyLength: 20, expectedEncoding: "br"); + AssertCompressedWithLog(logMessages, "br"); +#else +#error Target frameworks need to be updated. +#endif + } + + [Theory] + [InlineData("gzip", "br")] + [InlineData("br", "gzip")] + public async Task Request_AcceptMixed_CompressedBrotli(string encoding1, string encoding2) + { + var (response, logMessages) = await InvokeMiddleware(100, new[] { encoding1, encoding2 }, responseType: TextPlain); + +#if NET461 + CheckResponseCompressed(response, expectedBodyLength: 24, expectedEncoding: "gzip"); + AssertCompressedWithLog(logMessages, "gzip"); +#elif NETCOREAPP2_2 + CheckResponseCompressed(response, expectedBodyLength: 20, expectedEncoding: "br"); + AssertCompressedWithLog(logMessages, "br"); +#else +#error Target frameworks need to be updated. +#endif + } + +#if NETCOREAPP2_2 + [Theory] + [InlineData("gzip", "br")] + [InlineData("br", "gzip")] + public async Task Request_AcceptMixed_ConfiguredOrder_CompressedGzip(string encoding1, string encoding2) + { + void Configure(ResponseCompressionOptions options) + { + options.Providers.Add(); + options.Providers.Add(); + } + + var (response, logMessages) = await InvokeMiddleware(100, new[] { encoding1, encoding2 }, responseType: TextPlain, configure: Configure); + + CheckResponseCompressed(response, expectedBodyLength: 24, expectedEncoding: "gzip"); + AssertCompressedWithLog(logMessages, "gzip"); + } +#elif NET461 +#else +#error Target frameworks need to be updated. +#endif + [Fact] public async Task Request_AcceptUnknown_NotCompressed() { - var response = await InvokeMiddleware(100, requestAcceptEncodings: new string[] { "unknown" }, responseType: TextPlain); + var (response, logMessages) = await InvokeMiddleware(100, requestAcceptEncodings: new[] { "unknown" }, responseType: TextPlain); CheckResponseNotCompressed(response, expectedBodyLength: 100, sendVaryHeader: true); + Assert.Equal(3, logMessages.Count); + AssertLog(logMessages.First(), LogLevel.Trace, "This request accepts compression."); + AssertLog(logMessages.Skip(1).First(), LogLevel.Trace, "Response compression is available for this Content-Type."); + AssertLog(logMessages.Skip(2).First(), LogLevel.Debug, "No matching response compression provider found."); } [Theory] @@ -62,31 +146,10 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests [InlineData("text/plain ; charset=ISO-8859-4")] public async Task ContentType_WithCharset_Compress(string contentType) { - var builder = new WebHostBuilder() - .ConfigureServices(services => - { - services.AddResponseCompression(); - }) - .Configure(app => - { - app.UseResponseCompression(); - app.Run(context => - { - context.Response.Headers[HeaderNames.ContentMD5] = "MD5"; - context.Response.ContentType = contentType; - return context.Response.WriteAsync(new string('a', 100)); - }); - }); + var (response, logMessages) = await InvokeMiddleware(uncompressedBodyLength: 100, requestAcceptEncodings: new[] { "gzip" }, contentType); - var server = new TestServer(builder); - var client = server.CreateClient(); - - var request = new HttpRequestMessage(HttpMethod.Get, ""); - request.Headers.AcceptEncoding.ParseAdd("gzip"); - - var response = await client.SendAsync(request); - - CheckResponseCompressed(response, expectedBodyLength: 24); + CheckResponseCompressed(response, expectedBodyLength: 24, expectedEncoding: "gzip"); + AssertCompressedWithLog(logMessages, "gzip"); } [Fact] @@ -117,7 +180,7 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests var response = await client.SendAsync(request); - CheckResponseCompressed(response, expectedBodyLength: 123); + CheckResponseCompressed(response, expectedBodyLength: 123, expectedEncoding: "gzip"); } [Theory] @@ -125,31 +188,114 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests [InlineData("text/plain2")] public async Task MimeTypes_OtherContentTypes_NoMatch(string contentType) { - var builder = new WebHostBuilder() - .ConfigureServices(services => - { - services.AddResponseCompression(); - }) - .Configure(app => - { - app.UseResponseCompression(); - app.Run(context => - { - context.Response.Headers[HeaderNames.ContentMD5] = "MD5"; - context.Response.ContentType = contentType; - return context.Response.WriteAsync(new string('a', 100)); - }); - }); - - var server = new TestServer(builder); - var client = server.CreateClient(); - - var request = new HttpRequestMessage(HttpMethod.Get, ""); - request.Headers.AcceptEncoding.ParseAdd("gzip"); - - var response = await client.SendAsync(request); + var (response, logMessages) = await InvokeMiddleware(uncompressedBodyLength: 100, requestAcceptEncodings: new[] { "gzip" }, contentType); CheckResponseNotCompressed(response, expectedBodyLength: 100, sendVaryHeader: false); + Assert.Equal(2, logMessages.Count); + AssertLog(logMessages.First(), LogLevel.Trace, "This request accepts compression."); + var expected = string.IsNullOrEmpty(contentType) ? "(null)" : contentType; + AssertLog(logMessages.Skip(1).First(), LogLevel.Debug, $"Response compression is not enabled for the Content-Type '{expected}'."); + } + + [Theory] + [InlineData(null, null, "text/plain", true)] + [InlineData(null, new string[0], "text/plain", true)] + [InlineData(null, new[] { "TEXT/plain" }, "text/plain", false)] + [InlineData(null, new[] { "TEXT/*" }, "text/plain", true)] + [InlineData(null, new[] { "*/*" }, "text/plain", true)] + + [InlineData(new string[0], null, "text/plain", true)] + [InlineData(new string[0], new string[0], "text/plain", true)] + [InlineData(new string[0], new[] { "TEXT/plain" }, "text/plain", false)] + [InlineData(new string[0], new[] { "TEXT/*" }, "text/plain", true)] + [InlineData(new string[0], new[] { "*/*" }, "text/plain", true)] + + [InlineData(new[] { "TEXT/plain" }, null, "text/plain", true)] + [InlineData(new[] { "TEXT/plain" }, new string[0], "text/plain", true)] + [InlineData(new[] { "TEXT/plain" }, new[] { "TEXT/plain" }, "text/plain", false)] + [InlineData(new[] { "TEXT/plain" }, new[] { "TEXT/*" }, "text/plain", true)] + [InlineData(new[] { "TEXT/plain" }, new[] { "*/*" }, "text/plain", true)] + + [InlineData(new[] { "TEXT/*" }, null, "text/plain", true)] + [InlineData(new[] { "TEXT/*" }, new string[0], "text/plain", true)] + [InlineData(new[] { "TEXT/*" }, new[] { "TEXT/plain" }, "text/plain", false)] + [InlineData(new[] { "TEXT/*" }, new[] { "TEXT/*" }, "text/plain", false)] + [InlineData(new[] { "TEXT/*" }, new[] { "*/*" }, "text/plain", true)] + + [InlineData(new[] { "*/*" }, null, "text/plain", true)] + [InlineData(new[] { "*/*" }, new string[0], "text/plain", true)] + [InlineData(new[] { "*/*" }, new[] { "TEXT/plain" }, "text/plain", false)] + [InlineData(new[] { "*/*" }, new[] { "TEXT/*" }, "text/plain", false)] + [InlineData(new[] { "*/*" }, new[] { "*/*" }, "text/plain", true)] + + [InlineData(null, null, "text/plain2", false)] + [InlineData(null, new string[0], "text/plain2", false)] + [InlineData(null, new[] { "TEXT/plain" }, "text/plain2", false)] + [InlineData(null, new[] { "TEXT/*" }, "text/plain2", false)] + [InlineData(null, new[] { "*/*" }, "text/plain2", false)] + + [InlineData(new string[0], null, "text/plain2", false)] + [InlineData(new string[0], new string[0], "text/plain2", false)] + [InlineData(new string[0], new[] { "TEXT/plain" }, "text/plain2", false)] + [InlineData(new string[0], new[] { "TEXT/*" }, "text/plain2", false)] + [InlineData(new string[0], new[] { "*/*" }, "text/plain2", false)] + + [InlineData(new[] { "TEXT/plain" }, null, "text/plain2", false)] + [InlineData(new[] { "TEXT/plain" }, new string[0], "text/plain2", false)] + [InlineData(new[] { "TEXT/plain" }, new[] { "TEXT/plain" }, "text/plain2", false)] + [InlineData(new[] { "TEXT/plain" }, new[] { "TEXT/*" }, "text/plain2", false)] + [InlineData(new[] { "TEXT/plain" }, new[] { "*/*" }, "text/plain2", false)] + + [InlineData(new[] { "TEXT/*" }, null, "text/plain2", true)] + [InlineData(new[] { "TEXT/*" }, new string[0], "text/plain2", true)] + [InlineData(new[] { "TEXT/*" }, new[] { "TEXT/plain" }, "text/plain2", true)] + [InlineData(new[] { "TEXT/*" }, new[] { "TEXT/*" }, "text/plain2", false)] + [InlineData(new[] { "TEXT/*" }, new[] { "*/*" }, "text/plain2", true)] + + [InlineData(new[] { "*/*" }, null, "text/plain2", true)] + [InlineData(new[] { "*/*" }, new string[0], "text/plain2", true)] + [InlineData(new[] { "*/*" }, new[] { "TEXT/plain" }, "text/plain2", true)] + [InlineData(new[] { "*/*" }, new[] { "TEXT/*" }, "text/plain2", false)] + [InlineData(new[] { "*/*" }, new[] { "*/*" }, "text/plain2", true)] + public async Task MimeTypes_IncludedAndExcluded( + string[] mimeTypes, + string[] excludedMimeTypes, + string contentType, + bool compress + ) + { + var (response, logMessages) = await InvokeMiddleware(uncompressedBodyLength: 100, requestAcceptEncodings: new[] { "gzip" }, contentType, + configure: options => + { + options.MimeTypes = mimeTypes; + options.ExcludedMimeTypes = excludedMimeTypes; + }); + + if (compress) + { + CheckResponseCompressed(response, expectedBodyLength: 24, expectedEncoding: "gzip"); + AssertCompressedWithLog(logMessages, "gzip"); + } + else + { + CheckResponseNotCompressed(response, expectedBodyLength: 100, sendVaryHeader: false); + Assert.Equal(2, logMessages.Count); + AssertLog(logMessages.First(), LogLevel.Trace, "This request accepts compression."); + AssertLog(logMessages.Skip(1).First(), LogLevel.Debug, $"Response compression is not enabled for the Content-Type '{contentType}'."); + } + } + + [Fact] + public async Task NoIncludedMimeTypes_UseDefaults() + { + var (response, logMessages) = await InvokeMiddleware(uncompressedBodyLength: 100, requestAcceptEncodings: new[] { "gzip" }, TextPlain, + configure: options => + { + options.ExcludedMimeTypes = new[] { "text/*" }; + }); + + CheckResponseCompressed(response, expectedBodyLength: 24, expectedEncoding: "gzip"); + AssertCompressedWithLog(logMessages, "gzip"); } [Theory] @@ -191,71 +337,98 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests [Fact] public async Task Request_AcceptStar_Compressed() { - var response = await InvokeMiddleware(100, requestAcceptEncodings: new string[] { "*" }, responseType: TextPlain); + var (response, logMessages) = await InvokeMiddleware(100, requestAcceptEncodings: new[] { "*" }, responseType: TextPlain); - CheckResponseCompressed(response, expectedBodyLength: 24); +#if NET461 + CheckResponseCompressed(response, expectedBodyLength: 24, expectedEncoding: "gzip"); + AssertCompressedWithLog(logMessages, "gzip"); +#elif NETCOREAPP2_2 + CheckResponseCompressed(response, expectedBodyLength: 20, expectedEncoding: "br"); + AssertCompressedWithLog(logMessages, "br"); +#else +#error Target frameworks need to be updated. +#endif } [Fact] public async Task Request_AcceptIdentity_NotCompressed() { - var response = await InvokeMiddleware(100, requestAcceptEncodings: new string[] { "identity" }, responseType: TextPlain); + var (response, logMessages) = await InvokeMiddleware(100, requestAcceptEncodings: new[] { "identity" }, responseType: TextPlain); CheckResponseNotCompressed(response, expectedBodyLength: 100, sendVaryHeader: true); + Assert.Equal(3, logMessages.Count); + AssertLog(logMessages.First(), LogLevel.Trace, "This request accepts compression."); + AssertLog(logMessages.Skip(1).First(), LogLevel.Trace, "Response compression is available for this Content-Type."); + AssertLog(logMessages.Skip(2).First(), LogLevel.Debug, "No matching response compression provider found."); } [Theory] - [InlineData(new string[] { "identity;q=0.5", "gzip;q=1" }, 24)] - [InlineData(new string[] { "identity;q=0", "gzip;q=0.8" }, 24)] - [InlineData(new string[] { "identity;q=0.5", "gzip" }, 24)] + [InlineData(new[] { "identity;q=0.5", "gzip;q=1" }, 24)] + [InlineData(new[] { "identity;q=0", "gzip;q=0.8" }, 24)] + [InlineData(new[] { "identity;q=0.5", "gzip" }, 24)] public async Task Request_AcceptWithHigherCompressionQuality_Compressed(string[] acceptEncodings, int expectedBodyLength) { - var response = await InvokeMiddleware(100, requestAcceptEncodings: acceptEncodings, responseType: TextPlain); + var (response, logMessages) = await InvokeMiddleware(100, requestAcceptEncodings: acceptEncodings, responseType: TextPlain); - CheckResponseCompressed(response, expectedBodyLength: expectedBodyLength); + CheckResponseCompressed(response, expectedBodyLength: expectedBodyLength, expectedEncoding: "gzip"); + AssertCompressedWithLog(logMessages, "gzip"); } [Theory] - [InlineData(new string[] { "gzip;q=0.5", "identity;q=0.8" }, 100)] + [InlineData(new[] { "gzip;q=0.5", "identity;q=0.8" }, 100)] public async Task Request_AcceptWithhigherIdentityQuality_NotCompressed(string[] acceptEncodings, int expectedBodyLength) { - var response = await InvokeMiddleware(100, requestAcceptEncodings: acceptEncodings, responseType: TextPlain); + var (response, logMessages) = await InvokeMiddleware(100, requestAcceptEncodings: acceptEncodings, responseType: TextPlain); CheckResponseNotCompressed(response, expectedBodyLength: expectedBodyLength, sendVaryHeader: true); + Assert.Equal(3, logMessages.Count); + AssertLog(logMessages.First(), LogLevel.Trace, "This request accepts compression."); + AssertLog(logMessages.Skip(1).First(), LogLevel.Trace, "Response compression is available for this Content-Type."); + AssertLog(logMessages.Skip(2).First(), LogLevel.Debug, "No matching response compression provider found."); } [Fact] public async Task Response_UnknownMimeType_NotCompressed() { - var response = await InvokeMiddleware(100, requestAcceptEncodings: new string[] { "gzip" }, responseType: "text/custom"); + var (response, logMessages) = await InvokeMiddleware(100, requestAcceptEncodings: new[] { "gzip" }, responseType: "text/custom"); CheckResponseNotCompressed(response, expectedBodyLength: 100, sendVaryHeader: false); + Assert.Equal(2, logMessages.Count); + AssertLog(logMessages.First(), LogLevel.Trace, "This request accepts compression."); + AssertLog(logMessages.Skip(1).First(), LogLevel.Debug, "Response compression is not enabled for the Content-Type 'text/custom'."); } [Fact] public async Task Response_WithContentRange_NotCompressed() { - var response = await InvokeMiddleware(50, requestAcceptEncodings: new string[] { "gzip" }, responseType: TextPlain, addResponseAction: (r) => + var (response, logMessages) = await InvokeMiddleware(50, requestAcceptEncodings: new[] { "gzip" }, responseType: TextPlain, addResponseAction: (r) => { r.Headers[HeaderNames.ContentRange] = "1-2/*"; }); CheckResponseNotCompressed(response, expectedBodyLength: 50, sendVaryHeader: false); + Assert.Equal(2, logMessages.Count); + AssertLog(logMessages.First(), LogLevel.Trace, "This request accepts compression."); + AssertLog(logMessages.Skip(1).First(), LogLevel.Debug, "Response compression disabled due to the Content-Range header."); } + [Fact] - public async Task Response_WithContentEncodingAlreadySet_Stacked() + public async Task Response_WithContentEncodingAlreadySet_NotReCompressed() { var otherContentEncoding = "something"; - var response = await InvokeMiddleware(50, requestAcceptEncodings: new string[] { "gzip" }, responseType: TextPlain, addResponseAction: (r) => + var (response, logMessages) = await InvokeMiddleware(50, requestAcceptEncodings: new[] { "gzip" }, responseType: TextPlain, addResponseAction: (r) => { r.Headers[HeaderNames.ContentEncoding] = otherContentEncoding; }); Assert.True(response.Content.Headers.ContentEncoding.Contains(otherContentEncoding)); - Assert.True(response.Content.Headers.ContentEncoding.Contains("gzip")); - Assert.Equal(24, response.Content.Headers.ContentLength); + Assert.False(response.Content.Headers.ContentEncoding.Contains("gzip")); + Assert.Equal(50, response.Content.Headers.ContentLength); + Assert.Equal(2, logMessages.Count); + AssertLog(logMessages.First(), LogLevel.Trace, "This request accepts compression."); + AssertLog(logMessages.Skip(1).First(), LogLevel.Debug, "Response compression disabled due to the Content-Encoding header."); } [Theory] @@ -263,9 +436,15 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests [InlineData(true, 24)] public async Task Request_Https_CompressedIfEnabled(bool enableHttps, int expectedLength) { + var sink = new TestSink( + TestSink.EnableWithTypeName, + TestSink.EnableWithTypeName); + var loggerFactory = new TestLoggerFactory(sink, enabled: true); + var builder = new WebHostBuilder() .ConfigureServices(services => { + services.AddSingleton(loggerFactory); services.AddResponseCompression(options => { options.EnableForHttps = enableHttps; @@ -282,8 +461,11 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests }); }); - var server = new TestServer(builder); - server.BaseAddress = new Uri("https://localhost/"); + var server = new TestServer(builder) + { + BaseAddress = new Uri("https://localhost/") + }; + var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, ""); @@ -292,10 +474,21 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests var response = await client.SendAsync(request); Assert.Equal(expectedLength, response.Content.ReadAsByteArrayAsync().Result.Length); + + var logMessages = sink.Writes.ToList(); + if (enableHttps) + { + AssertCompressedWithLog(logMessages, "gzip"); + } + else + { + AssertLog(logMessages.Single(), LogLevel.Debug, "No response compression available for HTTPS requests. See ResponseCompressionOptions.EnableForHttps."); + } } - [Fact] - public async Task FlushHeaders_SendsHeaders_Compresses() + [Theory] + [MemberData(nameof(SupportedEncodingsWithBodyLength))] + public async Task FlushHeaders_SendsHeaders_Compresses(string encoding, int expectedBodyLength) { var responseReceived = new ManualResetEvent(false); @@ -321,18 +514,19 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, ""); - request.Headers.AcceptEncoding.ParseAdd("gzip"); + request.Headers.AcceptEncoding.ParseAdd(encoding); var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); responseReceived.Set(); await response.Content.LoadIntoBufferAsync(); - CheckResponseCompressed(response, expectedBodyLength: 24); + CheckResponseCompressed(response, expectedBodyLength, encoding); } - [Fact] - public async Task FlushAsyncHeaders_SendsHeaders_Compresses() + [Theory] + [MemberData(nameof(SupportedEncodingsWithBodyLength))] + public async Task FlushAsyncHeaders_SendsHeaders_Compresses(string encoding, int expectedBodyLength) { var responseReceived = new ManualResetEvent(false); @@ -358,18 +552,19 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, ""); - request.Headers.AcceptEncoding.ParseAdd("gzip"); + request.Headers.AcceptEncoding.ParseAdd(encoding); var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); responseReceived.Set(); await response.Content.LoadIntoBufferAsync(); - CheckResponseCompressed(response, expectedBodyLength: 24); + CheckResponseCompressed(response, expectedBodyLength, encoding); } - [Fact] - public async Task FlushBody_CompressesAndFlushes() + [Theory] + [MemberData(nameof(SupportedEncodings))] + public async Task FlushBody_CompressesAndFlushes(string encoding) { var responseReceived = new ManualResetEvent(false); @@ -397,13 +592,12 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, ""); - request.Headers.AcceptEncoding.ParseAdd("gzip"); + request.Headers.AcceptEncoding.ParseAdd(encoding); var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); - IEnumerable contentMD5 = null; - Assert.False(response.Content.Headers.TryGetValues(HeaderNames.ContentMD5, out contentMD5)); - Assert.Single(response.Content.Headers.ContentEncoding, "gzip"); + Assert.False(response.Content.Headers.TryGetValues(HeaderNames.ContentMD5, out _)); + Assert.Single(response.Content.Headers.ContentEncoding, encoding); var body = await response.Content.ReadAsStreamAsync(); var read = await body.ReadAsync(new byte[100], 0, 100); @@ -415,8 +609,9 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests Assert.True(read > 0); } - [Fact] - public async Task FlushAsyncBody_CompressesAndFlushes() + [Theory] + [MemberData(nameof(SupportedEncodings))] + public async Task FlushAsyncBody_CompressesAndFlushes(string encoding) { var responseReceived = new ManualResetEvent(false); @@ -443,13 +638,12 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, ""); - request.Headers.AcceptEncoding.ParseAdd("gzip"); + request.Headers.AcceptEncoding.ParseAdd(encoding); var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); - IEnumerable contentMD5 = null; - Assert.False(response.Content.Headers.TryGetValues(HeaderNames.ContentMD5, out contentMD5)); - Assert.Single(response.Content.Headers.ContentEncoding, "gzip"); + Assert.False(response.Content.Headers.TryGetValues(HeaderNames.ContentMD5, out _)); + Assert.Single(response.Content.Headers.ContentEncoding, encoding); var body = await response.Content.ReadAsStreamAsync(); var read = await body.ReadAsync(new byte[100], 0, 100); @@ -461,8 +655,9 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests Assert.True(read > 0); } - [Fact] - public async Task TrickleWriteAndFlush_FlushesEachWrite() + [Theory] + [MemberData(nameof(SupportedEncodings))] + public async Task TrickleWriteAndFlush_FlushesEachWrite(string encoding) { var responseReceived = new[] { @@ -501,17 +696,16 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, ""); - request.Headers.AcceptEncoding.ParseAdd("gzip"); + request.Headers.AcceptEncoding.ParseAdd(encoding); var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); #if NET461 // Flush not supported, compression disabled Assert.NotNull(response.Content.Headers.GetValues(HeaderNames.ContentMD5)); Assert.Empty(response.Content.Headers.ContentEncoding); -#elif NETCOREAPP2_0 || NETCOREAPP2_1 // Flush supported, compression enabled - IEnumerable contentMD5 = null; - Assert.False(response.Content.Headers.TryGetValues(HeaderNames.ContentMD5, out contentMD5)); - Assert.Single(response.Content.Headers.ContentEncoding, "gzip"); +#elif NETCOREAPP2_2 // Flush supported, compression enabled + Assert.False(response.Content.Headers.TryGetValues(HeaderNames.ContentMD5, out _)); + Assert.Single(response.Content.Headers.ContentEncoding, encoding); #else #error Target frameworks need to be updated. #endif @@ -527,8 +721,9 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests } } - [Fact] - public async Task TrickleWriteAndFlushAsync_FlushesEachWrite() + [Theory] + [MemberData(nameof(SupportedEncodings))] + public async Task TrickleWriteAndFlushAsync_FlushesEachWrite(string encoding) { var responseReceived = new[] { @@ -566,17 +761,16 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, ""); - request.Headers.AcceptEncoding.ParseAdd("gzip"); + request.Headers.AcceptEncoding.ParseAdd(encoding); var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); #if NET461 // Flush not supported, compression disabled Assert.NotNull(response.Content.Headers.GetValues(HeaderNames.ContentMD5)); Assert.Empty(response.Content.Headers.ContentEncoding); -#elif NETCOREAPP2_0 || NETCOREAPP2_1 // Flush supported, compression enabled - IEnumerable contentMD5 = null; - Assert.False(response.Content.Headers.TryGetValues(HeaderNames.ContentMD5, out contentMD5)); - Assert.Single(response.Content.Headers.ContentEncoding, "gzip"); +#elif NETCOREAPP2_2 // Flush supported, compression enabled + Assert.False(response.Content.Headers.TryGetValues(HeaderNames.ContentMD5, out _)); + Assert.Single(response.Content.Headers.ContentEncoding, encoding); #else #error Target framework needs to be updated #endif @@ -705,7 +899,7 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests var response = await client.SendAsync(request); - CheckResponseCompressed(response, expectedBodyLength: 34); + CheckResponseCompressed(response, expectedBodyLength: 34, expectedEncoding: "gzip"); Assert.False(fakeSendFile.Invoked); } @@ -749,17 +943,28 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests var response = await client.SendAsync(request); - CheckResponseCompressed(response, expectedBodyLength: 40); + CheckResponseCompressed(response, expectedBodyLength: 40, expectedEncoding: "gzip"); Assert.False(fakeSendFile.Invoked); } - private Task InvokeMiddleware(int uncompressedBodyLength, string[] requestAcceptEncodings, string responseType, Action addResponseAction = null) + private async Task<(HttpResponseMessage, List)> InvokeMiddleware( + int uncompressedBodyLength, + string[] requestAcceptEncodings, + string responseType, + Action addResponseAction = null, + Action configure = null) { + var sink = new TestSink( + TestSink.EnableWithTypeName, + TestSink.EnableWithTypeName); + var loggerFactory = new TestLoggerFactory(sink, enabled: true); + var builder = new WebHostBuilder() .ConfigureServices(services => { - services.AddResponseCompression(); + services.AddResponseCompression(configure ?? (_ => { })); + services.AddSingleton(loggerFactory); }) .Configure(app => { @@ -769,10 +974,7 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests context.Response.Headers[HeaderNames.ContentMD5] = "MD5"; context.Response.ContentType = responseType; Assert.Null(context.Features.Get()); - if (addResponseAction != null) - { - addResponseAction(context.Response); - } + addResponseAction?.Invoke(context.Response); return context.Response.WriteAsync(new string('a', uncompressedBodyLength)); }); }); @@ -786,13 +988,11 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests request.Headers.AcceptEncoding.Add(System.Net.Http.Headers.StringWithQualityHeaderValue.Parse(requestAcceptEncodings[i])); } - return client.SendAsync(request); + return (await client.SendAsync(request), sink.Writes.ToList()); } - private void CheckResponseCompressed(HttpResponseMessage response, int expectedBodyLength) + private void CheckResponseCompressed(HttpResponseMessage response, int expectedBodyLength, string expectedEncoding) { - IEnumerable contentMD5 = null; - var containsVaryAcceptEncoding = false; foreach (var value in response.Headers.GetValues(HeaderNames.Vary)) { @@ -803,8 +1003,8 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests } } Assert.True(containsVaryAcceptEncoding); - Assert.False(response.Content.Headers.TryGetValues(HeaderNames.ContentMD5, out contentMD5)); - Assert.Single(response.Content.Headers.ContentEncoding, "gzip"); + Assert.False(response.Content.Headers.TryGetValues(HeaderNames.ContentMD5, out _)); + Assert.Single(response.Content.Headers.ContentEncoding, expectedEncoding); Assert.Equal(expectedBodyLength, response.Content.Headers.ContentLength); } @@ -832,6 +1032,20 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests Assert.Equal(expectedBodyLength, response.Content.Headers.ContentLength); } + private void AssertLog(WriteContext log, LogLevel level, string message) + { + Assert.Equal(level, log.LogLevel); + Assert.Equal(message, log.State.ToString()); + } + + private void AssertCompressedWithLog(List logMessages, string provider) + { + Assert.Equal(3, logMessages.Count); + AssertLog(logMessages.First(), LogLevel.Trace, "This request accepts compression."); + AssertLog(logMessages.Skip(1).First(), LogLevel.Trace, "Response compression is available for this Content-Type."); + AssertLog(logMessages.Skip(2).First(), LogLevel.Debug, $"The response will be compressed with '{provider}'."); + } + private class FakeSendFileFeature : IHttpSendFileFeature { private readonly Stream _innerBody; @@ -858,5 +1072,18 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests } } } + + private readonly struct EncodingTestData + { + public EncodingTestData(string encodingName, int expectedBodyLength) + { + EncodingName = encodingName; + ExpectedBodyLength = expectedBodyLength; + } + + public string EncodingName { get; } + + public int ExpectedBodyLength { get; } + } } } diff --git a/src/Middleware/Rewrite/sample/RewriteSample.csproj b/src/Middleware/Rewrite/sample/RewriteSample.csproj index 310e7d676a..9716ca1172 100644 --- a/src/Middleware/Rewrite/sample/RewriteSample.csproj +++ b/src/Middleware/Rewrite/sample/RewriteSample.csproj @@ -1,7 +1,7 @@  - netcoreapp2.1;net461 + netcoreapp2.2;net461 diff --git a/src/Middleware/Rewrite/src/Internal/ApacheModRewrite/ConditionPatternParser.cs b/src/Middleware/Rewrite/src/Internal/ApacheModRewrite/ConditionPatternParser.cs index 9523b897df..53d841b0c7 100644 --- a/src/Middleware/Rewrite/src/Internal/ApacheModRewrite/ConditionPatternParser.cs +++ b/src/Middleware/Rewrite/src/Internal/ApacheModRewrite/ConditionPatternParser.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Globalization; namespace Microsoft.AspNetCore.Rewrite.Internal.ApacheModRewrite { @@ -216,7 +217,7 @@ namespace Microsoft.AspNetCore.Rewrite.Internal.ApacheModRewrite { // If the type is an integer, verify operand is actually an int int res; - if (!int.TryParse(results.Operand, out res)) + if (!int.TryParse(results.Operand, NumberStyles.None, CultureInfo.InvariantCulture, out res)) { return false; } diff --git a/src/Middleware/Rewrite/src/Internal/IISUrlRewrite/InputParser.cs b/src/Middleware/Rewrite/src/Internal/IISUrlRewrite/InputParser.cs index 7dc3f8d638..53c63dfb6d 100644 --- a/src/Middleware/Rewrite/src/Internal/IISUrlRewrite/InputParser.cs +++ b/src/Middleware/Rewrite/src/Internal/IISUrlRewrite/InputParser.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Globalization; using Microsoft.AspNetCore.Rewrite.Internal.PatternSegments; namespace Microsoft.AspNetCore.Rewrite.Internal.IISUrlRewrite @@ -181,7 +182,7 @@ namespace Microsoft.AspNetCore.Rewrite.Internal.IISUrlRewrite var res = context.Capture(); int index; - if (!int.TryParse(res, out index)) + if (!int.TryParse(res, NumberStyles.None, CultureInfo.InvariantCulture, out index)) { throw new FormatException(Resources.FormatError_InputParserInvalidInteger(res, context.Index)); } diff --git a/src/Middleware/Rewrite/src/Internal/IISUrlRewrite/UrlRewriteFileParser.cs b/src/Middleware/Rewrite/src/Internal/IISUrlRewrite/UrlRewriteFileParser.cs index 2a5b0f24a9..6bb08f41f1 100644 --- a/src/Middleware/Rewrite/src/Internal/IISUrlRewrite/UrlRewriteFileParser.cs +++ b/src/Middleware/Rewrite/src/Internal/IISUrlRewrite/UrlRewriteFileParser.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Xml.Linq; @@ -219,7 +220,7 @@ namespace Microsoft.AspNetCore.Rewrite.Internal.IISUrlRewrite break; case ActionType.CustomResponse: int statusCode; - if (!int.TryParse(urlAction.Attribute(RewriteTags.StatusCode)?.Value, out statusCode)) + if (!int.TryParse(urlAction.Attribute(RewriteTags.StatusCode)?.Value, NumberStyles.None, CultureInfo.InvariantCulture, out statusCode)) { throw new InvalidUrlRewriteFormatException(urlAction, "A valid status code is required"); }