diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/FileResultExecutorBase.cs b/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/FileResultExecutorBase.cs index 19e5b8d6c7..c632b2f150 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/FileResultExecutorBase.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/Infrastructure/FileResultExecutorBase.cs @@ -64,20 +64,16 @@ protected virtual (RangeItemHeaderValue range, long rangeLength, bool serveBody) var response = context.HttpContext.Response; SetLastModifiedAndEtagHeaders(response, lastModified, etag); - var serveBody = !HttpMethods.IsHead(request.Method); - // Short circuit if the preconditional headers process to 304 (NotModified) or 412 (PreconditionFailed) if (preconditionState == PreconditionState.NotModified) { - serveBody = false; response.StatusCode = StatusCodes.Status304NotModified; - return (range: null, rangeLength: 0, serveBody); + return (range: null, rangeLength: 0, serveBody: false); } else if (preconditionState == PreconditionState.PreconditionFailed) { - serveBody = false; response.StatusCode = StatusCodes.Status412PreconditionFailed; - return (range: null, rangeLength: 0, serveBody); + return (range: null, rangeLength: 0, serveBody: false); } if (fileLength.HasValue) @@ -86,10 +82,8 @@ protected virtual (RangeItemHeaderValue range, long rangeLength, bool serveBody) // the length of the entire file. // If the request is a valid range request, this header is overwritten with the length of the range as part of the // range processing (see method SetContentLength). - if (serveBody) - { - response.ContentLength = fileLength.Value; - } + + response.ContentLength = fileLength.Value; // Handle range request if (enableRangeProcessing) @@ -111,7 +105,7 @@ protected virtual (RangeItemHeaderValue range, long rangeLength, bool serveBody) } } - return (range: null, rangeLength: 0, serveBody); + return (range: null, rangeLength: 0, serveBody: !HttpMethods.IsHead(request.Method)); } private static void SetContentType(ActionContext context, FileResult result) @@ -295,6 +289,7 @@ private static PreconditionState GetMaxPreconditionState(params PreconditionStat { var response = context.HttpContext.Response; var httpResponseHeaders = response.GetTypedHeaders(); + var serveBody = !HttpMethods.IsHead(context.HttpContext.Request.Method); // Range may be null for empty range header, invalid ranges, parsing errors, multiple ranges // and when the file length is zero. @@ -306,7 +301,7 @@ private static PreconditionState GetMaxPreconditionState(params PreconditionStat if (!isRangeRequest) { - return (range: null, rangeLength: 0, serveBody: true); + return (range: null, rangeLength: 0, serveBody); } // Requested range is not satisfiable @@ -330,7 +325,7 @@ private static PreconditionState GetMaxPreconditionState(params PreconditionStat // Overwrite the Content-Length header for valid range requests with the range length. var rangeLength = SetContentLength(response, range); - return (range, rangeLength, serveBody: true); + return (range, rangeLength, serveBody); } private static long SetContentLength(HttpResponse response, RangeItemHeaderValue range) diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/FileResultTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/FileResultTest.cs index 366380abd5..c169414a63 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/FileResultTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/FileResultTest.cs @@ -7,13 +7,11 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.Infrastructure; -using Microsoft.AspNetCore.Mvc.Internal; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Testing.xunit; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Logging.Testing; using Microsoft.Net.Http.Headers; using Moq; using Xunit; diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/FileResultTests.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/FileResultTests.cs index e5733a558e..150e490239 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/FileResultTests.cs +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/FileResultTests.cs @@ -1,12 +1,10 @@ // 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.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Threading.Tasks; -using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Testing.xunit; using Xunit; @@ -189,11 +187,13 @@ public async Task FileFromDisk_ReturnsFileWithFileName() Assert.Equal("attachment; filename=downloadName.txt; filename*=UTF-8''downloadName.txt", contentDisposition); } - [Fact] - public async Task FileFromDisk_ReturnsFileWithFileName_RangeProcessingNotEnabled_RangeRequestedIgnored() + [Theory] + [InlineData("GET", "This is a sample text file")] + [InlineData("HEAD", "")] + public async Task FileFromDisk_ReturnsFileWithFileName_RangeProcessingNotEnabled_RangeRequestedIgnored(string httpMethod, string expectedBody) { // Arrange - var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://localhost/DownloadFiles/DownloadFromDiskWithFileName"); + var httpRequestMessage = new HttpRequestMessage(new HttpMethod(httpMethod), "http://localhost/DownloadFiles/DownloadFromDiskWithFileName"); httpRequestMessage.Headers.Range = new RangeHeaderValue(0, 6); // Act @@ -204,7 +204,7 @@ public async Task FileFromDisk_ReturnsFileWithFileName_RangeProcessingNotEnabled Assert.NotNull(response.Content.Headers.ContentType); Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); var body = await response.Content.ReadAsStringAsync(); - Assert.Equal("This is a sample text file", body); + Assert.Equal(expectedBody, body); } [Fact] @@ -246,6 +246,44 @@ public async Task FileFromDisk_ReturnsFileWithFileName_IfRangeHeaderInvalid_Rang Assert.Equal("This is a sample text file", body); } + [Theory] + [InlineData("", HttpStatusCode.OK, 26)] + [InlineData("bytes = 0-6", HttpStatusCode.PartialContent, 7)] + [InlineData("bytes = 17-25", HttpStatusCode.PartialContent, 9)] + [InlineData("bytes = 0-50", HttpStatusCode.PartialContent, 26)] + [InlineData("0-6", HttpStatusCode.OK, 26)] + [InlineData("bytes = ", HttpStatusCode.OK, 26)] + [InlineData("bytes = 1-4, 5-11", HttpStatusCode.OK, 26)] + [InlineData("bytes = 35-36", HttpStatusCode.RequestedRangeNotSatisfiable, 26)] + [InlineData("bytes = -0", HttpStatusCode.RequestedRangeNotSatisfiable, 26)] + public async Task FileFromDisk_ReturnsFileWithFileName_DoesNotServeBody_ForHeadRequest_WithLastModifiedAndEtag(string rangeString, HttpStatusCode httpStatusCode, int expectedContentLength) + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Head, "http://localhost/DownloadFiles/DownloadFromDiskWithFileName_WithLastModifiedAndEtag"); + httpRequestMessage.Headers.TryAddWithoutValidation("Range", rangeString); + httpRequestMessage.Headers.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue("\"Etag\"")); + + // Act + var response = await Client.SendAsync(httpRequestMessage); + + // Assert + Assert.Equal(httpStatusCode, response.StatusCode); + + Assert.NotNull(response.Content.Headers.ContentType); + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + + var body = await response.Content.ReadAsStringAsync(); + Assert.NotNull(body); + Assert.Equal(string.Empty, body); + + var contentLength = response.Content.Headers.ContentLength; + Assert.Equal(expectedContentLength, contentLength); + + var contentDisposition = response.Content.Headers.ContentDisposition.ToString(); + Assert.NotNull(contentDisposition); + Assert.Equal("attachment; filename=downloadName.txt; filename*=UTF-8''downloadName.txt", contentDisposition); + } + [Fact] public async Task FileFromStream_ReturnsFile() { @@ -347,11 +385,13 @@ public async Task FileFromStream_ReturnsFileWithFileName() Assert.Equal("attachment; filename=downloadName.txt; filename*=UTF-8''downloadName.txt", contentDisposition); } - [Fact] - public async Task FileFromStream_ReturnsFileWithFileName_RangeProcessingNotEnabled_RangeRequestedIgnored() + [Theory] + [InlineData("GET", "This is sample text from a stream")] + [InlineData("HEAD", "")] + public async Task FileFromStream_ReturnsFileWithFileName_RangeProcessingNotEnabled_RangeRequestedIgnored(string httpMethod, string expectedBody) { // Arrange - var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://localhost/DownloadFiles/DownloadFromStreamWithFileName"); + var httpRequestMessage = new HttpRequestMessage(new HttpMethod(httpMethod), "http://localhost/DownloadFiles/DownloadFromStreamWithFileName"); httpRequestMessage.Headers.Range = new RangeHeaderValue(0, 6); // Act @@ -362,7 +402,7 @@ public async Task FileFromStream_ReturnsFileWithFileName_RangeProcessingNotEnabl Assert.NotNull(response.Content.Headers.ContentType); Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); var body = await response.Content.ReadAsStringAsync(); - Assert.Equal("This is sample text from a stream", body); + Assert.Equal(expectedBody, body); } [Fact] @@ -402,6 +442,44 @@ public async Task FileFromStream_ReturnsFileWithFileName_IfRangeHeaderInvalid_Ra Assert.Equal("This is sample text from a stream", body); } + [Theory] + [InlineData("", HttpStatusCode.OK, 33)] + [InlineData("bytes = 0-6", HttpStatusCode.PartialContent, 7)] + [InlineData("bytes = 17-25", HttpStatusCode.PartialContent, 9)] + [InlineData("bytes = 0-50", HttpStatusCode.PartialContent, 33)] + [InlineData("0-6", HttpStatusCode.OK, 33)] + [InlineData("bytes = ", HttpStatusCode.OK, 33)] + [InlineData("bytes = 1-4, 5-11", HttpStatusCode.OK, 33)] + [InlineData("bytes = 35-36", HttpStatusCode.RequestedRangeNotSatisfiable, 33)] + [InlineData("bytes = -0", HttpStatusCode.RequestedRangeNotSatisfiable, 33)] + public async Task FileFromStream_ReturnsFileWithFileName_DoesNotServeBody_ForHeadRequest(string rangeString, HttpStatusCode httpStatusCode, int expectedContentLength) + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Head, "http://localhost/DownloadFiles/DownloadFromStreamWithFileName_WithEtag"); + httpRequestMessage.Headers.TryAddWithoutValidation("Range", rangeString); + httpRequestMessage.Headers.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue("\"Etag\"")); + + // Act + var response = await Client.SendAsync(httpRequestMessage); + + // Assert + Assert.Equal(httpStatusCode, response.StatusCode); + + Assert.NotNull(response.Content.Headers.ContentType); + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + + var body = await response.Content.ReadAsStringAsync(); + Assert.NotNull(body); + Assert.Equal(string.Empty, body); + + var contentLength = response.Content.Headers.ContentLength; + Assert.Equal(expectedContentLength, contentLength); + + var contentDisposition = response.Content.Headers.ContentDisposition.ToString(); + Assert.NotNull(contentDisposition); + Assert.Equal("attachment; filename=downloadName.txt; filename*=UTF-8''downloadName.txt", contentDisposition); + } + [Fact] public async Task FileFromBinaryData_ReturnsFile() { @@ -506,11 +584,13 @@ public async Task FileFromBinaryData_ReturnsFileWithFileName() Assert.Equal("attachment; filename=downloadName.txt; filename*=UTF-8''downloadName.txt", contentDisposition); } - [Fact] - public async Task FileFromBinaryData_ReturnsFileWithFileName_RangeProcessingNotEnabled_RangeRequestedIgnored() + [Theory] + [InlineData("GET", "This is a sample text from a binary array")] + [InlineData("HEAD", "")] + public async Task FileFromBinaryData_ReturnsFileWithFileName_RangeProcessingNotEnabled_RangeRequestedIgnored(string httpMethod, string expectedBody) { // Arrange - var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://localhost/DownloadFiles/DownloadFromBinaryDataWithFileName"); + var httpRequestMessage = new HttpRequestMessage(new HttpMethod(httpMethod), "http://localhost/DownloadFiles/DownloadFromBinaryDataWithFileName"); httpRequestMessage.Headers.Range = new RangeHeaderValue(0, 6); // Act @@ -521,7 +601,7 @@ public async Task FileFromBinaryData_ReturnsFileWithFileName_RangeProcessingNotE Assert.NotNull(response.Content.Headers.ContentType); Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); var body = await response.Content.ReadAsStringAsync(); - Assert.Equal("This is a sample text from a binary array", body); + Assert.Equal(expectedBody, body); } [Fact] @@ -563,6 +643,44 @@ public async Task FileFromBinaryData_ReturnsFileWithFileName_IfRangeHeaderInvali Assert.Equal("This is a sample text from a binary array", body); } + [Theory] + [InlineData("", HttpStatusCode.OK, 41)] + [InlineData("bytes = 0-6", HttpStatusCode.PartialContent, 7)] + [InlineData("bytes = 17-25", HttpStatusCode.PartialContent, 9)] + [InlineData("bytes = 0-50", HttpStatusCode.PartialContent, 41)] + [InlineData("0-6", HttpStatusCode.OK, 41)] + [InlineData("bytes = ", HttpStatusCode.OK, 41)] + [InlineData("bytes = 1-4, 5-11", HttpStatusCode.OK, 41)] + [InlineData("bytes = 45-46", HttpStatusCode.RequestedRangeNotSatisfiable, 41)] + [InlineData("bytes = -0", HttpStatusCode.RequestedRangeNotSatisfiable, 41)] + public async Task FileFromBinaryData_ReturnsFileWithFileName_DoesNotServeBody_ForHeadRequest(string rangeString, HttpStatusCode httpStatusCode, int expectedContentLength) + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Head, "http://localhost/DownloadFiles/DownloadFromBinaryDataWithFileName_WithEtag"); + httpRequestMessage.Headers.TryAddWithoutValidation("Range", rangeString); + httpRequestMessage.Headers.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue("\"Etag\"")); + + // Act + var response = await Client.SendAsync(httpRequestMessage); + + // Assert + Assert.Equal(httpStatusCode, response.StatusCode); + + Assert.NotNull(response.Content.Headers.ContentType); + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + + var body = await response.Content.ReadAsStringAsync(); + Assert.NotNull(body); + Assert.Equal(string.Empty, body); + + var contentLength = response.Content.Headers.ContentLength; + Assert.Equal(expectedContentLength, contentLength); + + var contentDisposition = response.Content.Headers.ContentDisposition.ToString(); + Assert.NotNull(contentDisposition); + Assert.Equal("attachment; filename=downloadName.txt; filename*=UTF-8''downloadName.txt", contentDisposition); + } + [Fact] public async Task FileFromEmbeddedResources_ReturnsFileWithFileName() { @@ -612,11 +730,13 @@ public async Task FileFromEmbeddedResources_ReturnsFileWithFileName_RangeRequest Assert.Equal("attachment; filename=downloadName.txt; filename*=UTF-8''downloadName.txt", contentDisposition); } - [Fact] - public async Task FileFromEmbeddedResources_ReturnsFileWithFileName_RangeProcessingNotEnabled_RangeRequestedIgnored() + [Theory] + [InlineData("GET", "Sample text file as embedded resource.")] + [InlineData("HEAD", "")] + public async Task FileFromEmbeddedResources_ReturnsFileWithFileName_RangeProcessingNotEnabled_RangeRequestedIgnored(string httpMethod, string expectedBody) { // Arrange - var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://localhost/EmbeddedFiles/DownloadFileWithFileName_RangeProcessingNotEnabled"); + var httpRequestMessage = new HttpRequestMessage(new HttpMethod(httpMethod), "http://localhost/EmbeddedFiles/DownloadFileWithFileName_RangeProcessingNotEnabled"); httpRequestMessage.Headers.Range = new RangeHeaderValue(0, 6); // Act @@ -627,7 +747,7 @@ public async Task FileFromEmbeddedResources_ReturnsFileWithFileName_RangeProcess Assert.NotNull(response.Content.Headers.ContentType); Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); var body = await response.Content.ReadAsStringAsync(); - Assert.Equal("Sample text file as embedded resource.", body); + Assert.Equal(expectedBody, body); } [Fact] @@ -721,5 +841,43 @@ public async Task FileFromEmbeddedResources_ReturnsFileWithFileName_RangeRequest Assert.NotNull(contentDisposition); Assert.Equal("attachment; filename=downloadName.txt; filename*=UTF-8''downloadName.txt", contentDisposition); } + + [Theory] + [InlineData("", HttpStatusCode.OK, 38)] + [InlineData("bytes = 0-6", HttpStatusCode.PartialContent, 7)] + [InlineData("bytes = 17-25", HttpStatusCode.PartialContent, 9)] + [InlineData("bytes = 0-50", HttpStatusCode.PartialContent, 38)] + [InlineData("0-6", HttpStatusCode.OK, 38)] + [InlineData("bytes = ", HttpStatusCode.OK, 38)] + [InlineData("bytes = 1-4, 5-11", HttpStatusCode.OK, 38)] + [InlineData("bytes = 45-46", HttpStatusCode.RequestedRangeNotSatisfiable, 38)] + [InlineData("bytes = -0", HttpStatusCode.RequestedRangeNotSatisfiable, 38)] + public async Task FileFromEmbeddedResources_ReturnsFileWithFileName_DoesNotServeBody_ForHeadRequest(string rangeString, HttpStatusCode httpStatusCode, int expectedContentLength) + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Head, "http://localhost/EmbeddedFiles/DownloadFileWithFileName"); + httpRequestMessage.Headers.TryAddWithoutValidation("Range", rangeString); + httpRequestMessage.Headers.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue("\"Etag\"")); + + // Act + var response = await Client.SendAsync(httpRequestMessage); + + // Assert + Assert.Equal(httpStatusCode, response.StatusCode); + + Assert.NotNull(response.Content.Headers.ContentType); + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + + var body = await response.Content.ReadAsStringAsync(); + Assert.NotNull(body); + Assert.Equal(string.Empty, body); + + var contentLength = response.Content.Headers.ContentLength; + Assert.Equal(expectedContentLength, contentLength); + + var contentDisposition = response.Content.Headers.ContentDisposition.ToString(); + Assert.NotNull(contentDisposition); + Assert.Equal("attachment; filename=downloadName.txt; filename*=UTF-8''downloadName.txt", contentDisposition); + } } }