Skip to content
This repository has been archived by the owner on Nov 20, 2018. It is now read-only.

Add ByteRangeHelper #807

Closed
wants to merge 14 commits into from
138 changes: 138 additions & 0 deletions src/Microsoft.AspNetCore.WebUtilities/ByteRangeHelper.cs
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)
Copy link
Member

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.

{
if (ranges.Count == 0)
{
return Array.Empty<RangeItemHeaderValue>();
}

if (length == 0)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why doesn't this return Array.Empty<RangeItemHeaderValue>(); too?

{
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.
Copy link
Member

Choose a reason for hiding this comment

The 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?
https://github.com/aspnet/StaticFiles/blob/f73dc5cebea6c08485662f1703d2aba1fc0cd408/src/Microsoft.AspNetCore.StaticFiles/Infrastructure/RangeHelpers.cs#L34

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)
Copy link
Member

Choose a reason for hiding this comment

The 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.

Copy link
Member

Choose a reason for hiding this comment

The 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
Expand Up @@ -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" />
Copy link
Member

Choose a reason for hiding this comment

The 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>
Expand Down
233 changes: 233 additions & 0 deletions test/Microsoft.AspNetCore.WebUtilities.Tests/ByteRangeHelperTest.cs
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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

System.* goes first

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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.AspNetCore.Http\Microsoft.AspNetCore.Http.csproj" />
<ProjectReference Include="..\..\src\Microsoft.AspNetCore.WebUtilities\Microsoft.AspNetCore.WebUtilities.csproj" />
</ItemGroup>

Expand Down