-
Notifications
You must be signed in to change notification settings - Fork 191
Add ByteRangeHelper #807
Add ByteRangeHelper #807
Changes from 9 commits
4cad645
fa18958
8a7a8e3
f0294de
9f29dcf
a6b7e99
083279c
c6ce6eb
640e0ac
98021aa
efe8262
6507259
d67900c
02ff081
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,138 @@ | ||
// 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.Collections.Generic; | ||
using System.Diagnostics; | ||
using Microsoft.AspNetCore.Http; | ||
using Microsoft.Extensions.Primitives; | ||
using Microsoft.Net.Http.Headers; | ||
|
||
namespace Microsoft.AspNetCore.WebUtilities | ||
{ | ||
public static class ByteRangeHelper | ||
{ | ||
// 14.35.1 Byte Ranges - If a syntactically valid byte-range-set includes at least one byte-range-spec whose | ||
// first-byte-pos is less than the current length of the entity-body, or at least one suffix-byte-range-spec | ||
// with a non-zero suffix-length, then the byte-range-set is satisfiable. | ||
// Adjusts ranges to be absolute and within bounds. | ||
public static IList<RangeItemHeaderValue> NormalizeRanges(ICollection<RangeItemHeaderValue> ranges, long length) | ||
{ | ||
if (ranges.Count == 0) | ||
{ | ||
return Array.Empty<RangeItemHeaderValue>(); | ||
} | ||
|
||
if (length == 0) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why doesn't this return |
||
{ | ||
return Array.Empty<RangeItemHeaderValue>(); | ||
} | ||
|
||
var normalizedRanges = new List<RangeItemHeaderValue>(ranges.Count); | ||
foreach (var range in ranges) | ||
{ | ||
var normalizedRange = NormalizeRange(range, length); | ||
|
||
if (normalizedRange != null) | ||
{ | ||
normalizedRanges.Add(normalizedRange); | ||
} | ||
} | ||
|
||
return normalizedRanges; | ||
} | ||
|
||
public static RangeItemHeaderValue NormalizeRange(RangeItemHeaderValue range, long length) | ||
{ | ||
var start = range.From; | ||
var end = range.To; | ||
|
||
// X-[Y] | ||
if (start.HasValue) | ||
{ | ||
if (start.Value >= length) | ||
{ | ||
// Not satisfiable, skip/discard. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm, my idea to make this a sub method just regressed this bit. You removed the continue that would have skipped adding the result. The behavior is now quite different. Is there a test case for this condition? |
||
return null; | ||
} | ||
if (!end.HasValue || end.Value >= length) | ||
{ | ||
end = length - 1; | ||
} | ||
} | ||
else | ||
{ | ||
// suffix range "-X" e.g. the last X bytes, resolve | ||
if (end.Value == 0) | ||
{ | ||
// Not satisfiable, skip/discard. | ||
return null; | ||
} | ||
|
||
var bytes = Math.Min(end.Value, length); | ||
start = length - bytes; | ||
end = start + bytes - 1; | ||
} | ||
|
||
var normalizedRange = new RangeItemHeaderValue(start, end); | ||
return normalizedRange; | ||
} | ||
|
||
public static bool ParseRange(HttpContext context, DateTimeOffset lastModified, EntityTagHeaderValue etag) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is less efficient the the original in StaticFiles. GetTypedHeaders creates a new structure (which you call multiple times) and .Range parses the header again without any caching. If you want to call it ParseRange then at least return the Range object. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Doc comments |
||
{ | ||
var rawRangeHeader = context.Request.Headers[HeaderNames.Range]; | ||
if (StringValues.IsNullOrEmpty(rawRangeHeader)) | ||
{ | ||
return false; | ||
} | ||
|
||
// Perf: Check for a single entry before parsing it | ||
if (rawRangeHeader.Count > 1 || rawRangeHeader[0].IndexOf(',') >= 0) | ||
{ | ||
// The spec allows for multiple ranges but we choose not to support them because the client may request | ||
// very strange ranges (e.g. each byte separately, overlapping ranges, etc.) that could negatively | ||
// impact the server. Ignore the header and serve the response normally. | ||
return false; | ||
} | ||
|
||
var rangeHeader = context.Request.GetTypedHeaders().Range; | ||
if (rangeHeader == null) | ||
{ | ||
// Invalid | ||
return false; | ||
} | ||
|
||
// Already verified above | ||
Debug.Assert(rangeHeader.Ranges.Count == 1); | ||
|
||
// 14.27 If-Range | ||
var ifRangeHeader = context.Request.GetTypedHeaders().IfRange; | ||
if (ifRangeHeader != null) | ||
{ | ||
// If the validator given in the If-Range header field matches the | ||
// current validator for the selected representation of the target | ||
// resource, then the server SHOULD process the Range header field as | ||
// requested. If the validator does not match, the server MUST ignore | ||
// the Range header field. | ||
bool ignoreRangeHeader = false; | ||
if (ifRangeHeader.LastModified.HasValue) | ||
{ | ||
if (lastModified > ifRangeHeader.LastModified) | ||
{ | ||
ignoreRangeHeader = true; | ||
} | ||
} | ||
else if (ifRangeHeader.EntityTag != null && !ifRangeHeader.EntityTag.Compare(etag, useStrongComparison: true)) | ||
{ | ||
ignoreRangeHeader = true; | ||
} | ||
if (ignoreRangeHeader) | ||
{ | ||
return false; | ||
} | ||
} | ||
|
||
return true; | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -13,6 +13,8 @@ | |
|
||
<ItemGroup> | ||
<ProjectReference Include="..\Microsoft.Net.Http.Headers\Microsoft.Net.Http.Headers.csproj" /> | ||
<ProjectReference Include="..\Microsoft.AspNetCore.Http.Abstractions\Microsoft.AspNetCore.Http.Abstractions.csproj" /> | ||
<ProjectReference Include="..\Microsoft.AspNetCore.Http.Extensions\Microsoft.AspNetCore.Http.Extensions.csproj" /> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This layering is problematic. WebUtilities should not add these dependencies which makes it impossible to write your method here... I think you'll have to move it to Http.Extensions reduce the inputs. |
||
</ItemGroup> | ||
|
||
<ItemGroup> | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,233 @@ | ||
// 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.Collections.Generic; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
using Microsoft.AspNetCore.Http; | ||
using Microsoft.Net.Http.Headers; | ||
using Xunit; | ||
|
||
namespace Microsoft.AspNetCore.WebUtilities | ||
{ | ||
public class ByteRangeHelperTest | ||
{ | ||
[Fact] | ||
public void NormalizeRanges_ReturnsEmptyArrayWhenRangeCountZero() | ||
{ | ||
// Arrange | ||
var ranges = new List<RangeItemHeaderValue>(); | ||
|
||
// Act | ||
var normalizedRanges = ByteRangeHelper.NormalizeRanges(ranges, 2); | ||
|
||
// Assert | ||
Assert.Empty(normalizedRanges); | ||
} | ||
|
||
[Fact] | ||
public void NormalizeRanges_ReturnsEmptyArrayWhenLengthZero() | ||
{ | ||
// Arrange | ||
var ranges = new[] | ||
{ | ||
new RangeItemHeaderValue(0, 0), | ||
}; | ||
|
||
// Act | ||
var normalizedRanges = ByteRangeHelper.NormalizeRanges(ranges, 0); | ||
|
||
// Assert | ||
Assert.Empty(normalizedRanges); | ||
} | ||
|
||
[Theory] | ||
[InlineData(1, 2)] | ||
[InlineData(2, 3)] | ||
public void NormalizeRanges_SkipsItemWhenRangeStartEqualOrGreaterThanLength(long start, long end) | ||
{ | ||
// Arrange | ||
var ranges = new[] | ||
{ | ||
new RangeItemHeaderValue(start, end), | ||
}; | ||
|
||
// Act | ||
var normalizedRanges = ByteRangeHelper.NormalizeRanges(ranges, 1); | ||
|
||
// Assert | ||
Assert.Empty(normalizedRanges); | ||
} | ||
|
||
[Fact] | ||
public void NormalizeRanges_SkipsItemWhenRangeEndEqualsZero() | ||
{ | ||
// Arrange | ||
var ranges = new[] | ||
{ | ||
new RangeItemHeaderValue(null, 0), | ||
}; | ||
|
||
// Act | ||
var normalizedRanges = ByteRangeHelper.NormalizeRanges(ranges, 1); | ||
|
||
// Assert | ||
Assert.Empty(normalizedRanges); | ||
} | ||
|
||
[Theory] | ||
[InlineData(null, null)] | ||
[InlineData(null, 0)] | ||
[InlineData(0, null)] | ||
[InlineData(0, 0)] | ||
public void NormalizeRanges_ReturnsNormalizedRange(long start, long end) | ||
{ | ||
// Arrange | ||
var ranges = new[] | ||
{ | ||
new RangeItemHeaderValue(start, end), | ||
}; | ||
|
||
// Act | ||
var normalizedRanges = ByteRangeHelper.NormalizeRanges(ranges, 1); | ||
|
||
// Assert | ||
var range = Assert.Single(normalizedRanges); | ||
Assert.Equal(0, range.From); | ||
Assert.Equal(0, range.To); | ||
} | ||
|
||
[Fact] | ||
public void NormalizeRanges_ReturnsRangeWithNoChange() | ||
{ | ||
// Arrange | ||
var ranges = new[] | ||
{ | ||
new RangeItemHeaderValue(1, 3), | ||
}; | ||
|
||
// Act | ||
var normalizedRanges = ByteRangeHelper.NormalizeRanges(ranges, 4); | ||
|
||
// Assert | ||
var range = Assert.Single(normalizedRanges); | ||
Assert.Equal(1, range.From); | ||
Assert.Equal(3, range.To); | ||
} | ||
|
||
[Theory] | ||
[InlineData(null, null)] | ||
[InlineData(null, 0)] | ||
[InlineData(0, null)] | ||
[InlineData(0, 0)] | ||
public void NormalizeRanges_MultipleRanges_ReturnsNormalizedRange(long start, long end) | ||
{ | ||
// Arrange | ||
var ranges = new[] | ||
{ | ||
new RangeItemHeaderValue(start, end), | ||
new RangeItemHeaderValue(1, 2), | ||
}; | ||
|
||
// Act | ||
var normalizedRanges = ByteRangeHelper.NormalizeRanges(ranges, 3); | ||
|
||
// Assert | ||
Assert.Collection(normalizedRanges, | ||
range => | ||
{ | ||
Assert.Equal(0, range.From); | ||
Assert.Equal(0, range.To); | ||
}, | ||
range => | ||
{ | ||
Assert.Equal(1, range.From); | ||
Assert.Equal(2, range.To); | ||
}); | ||
} | ||
|
||
[Theory] | ||
[InlineData(null)] | ||
[InlineData("")] | ||
public void ParseRange_ReturnsFalseWhenRangeHeaderNotProvided(string range) | ||
{ | ||
// Arrange | ||
var httpContext = new DefaultHttpContext(); | ||
httpContext.Request.Headers[HeaderNames.Range] = range; | ||
|
||
// Act | ||
var parseRange = ByteRangeHelper.ParseRange(httpContext, new DateTimeOffset(), null); | ||
|
||
// Assert | ||
Assert.False(parseRange); | ||
} | ||
|
||
[Theory] | ||
[InlineData("1-2, 3-4")] | ||
[InlineData("1-2, ")] | ||
public void ParseRange_ReturnsFalseWhenMultipleRangesProvidedInRangeHeader(string range) | ||
{ | ||
// Arrange | ||
var httpContext = new DefaultHttpContext(); | ||
httpContext.Request.Headers[HeaderNames.Range] = range; | ||
|
||
// Act | ||
var parseRange = ByteRangeHelper.ParseRange(httpContext, new DateTimeOffset(), null); | ||
|
||
// Assert | ||
Assert.False(parseRange); | ||
} | ||
|
||
[Fact] | ||
public void ParseRange_ReturnsFalseWhenLastModifiedGreaterThanIfRangeHeaderLastModified() | ||
{ | ||
// Arrange | ||
var httpContext = new DefaultHttpContext(); | ||
var range = new RangeHeaderValue(1, 2); | ||
httpContext.Request.Headers[HeaderNames.Range] = range.ToString(); | ||
var lastModified = new RangeConditionHeaderValue(DateTime.Now); | ||
httpContext.Request.Headers[HeaderNames.IfRange] = lastModified.ToString(); | ||
|
||
// Act | ||
var parseRange = ByteRangeHelper.ParseRange(httpContext, DateTime.Now.AddMilliseconds(2), null); | ||
|
||
// Assert | ||
Assert.False(parseRange); | ||
} | ||
|
||
[Fact] | ||
public void ParseRange_ReturnsFalseWhenETagNotEqualToIfRangeHeaderEntityTag() | ||
{ | ||
// Arrange | ||
var httpContext = new DefaultHttpContext(); | ||
var range = new RangeHeaderValue(1, 2); | ||
httpContext.Request.Headers[HeaderNames.Range] = range.ToString(); | ||
var etag = new RangeConditionHeaderValue("\"tag\""); | ||
httpContext.Request.Headers[HeaderNames.IfRange] = etag.ToString(); | ||
|
||
// Act | ||
var parseRange = ByteRangeHelper.ParseRange(httpContext, DateTime.Now, new EntityTagHeaderValue("\"etag\"")); | ||
|
||
// Assert | ||
Assert.False(parseRange); | ||
} | ||
|
||
[Fact] | ||
public void ParseRange_ReturnsTrueWhenInputValid() | ||
{ | ||
// Arrange | ||
var httpContext = new DefaultHttpContext(); | ||
var range = new RangeHeaderValue(1, 2); | ||
httpContext.Request.Headers[HeaderNames.Range] = range.ToString(); | ||
var lastModified = new RangeConditionHeaderValue(DateTime.Now); | ||
httpContext.Request.Headers[HeaderNames.IfRange] = lastModified.ToString(); | ||
var etag = new RangeConditionHeaderValue("\"etag\""); | ||
httpContext.Request.Headers[HeaderNames.IfRange] = etag.ToString(); | ||
|
||
// Act | ||
var parseRange = ByteRangeHelper.ParseRange(httpContext, DateTime.Now, new EntityTagHeaderValue("\"etag\"")); | ||
|
||
// Assert | ||
Assert.True(parseRange); | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Split out a second method
public static RangeItemHeaderValue NormalizeRange(RangeItemHeaderValue range, long length)
. Most of your tests only apply to this subset of the functionality.