diff --git a/src/Microsoft.AspNet.Mvc.Razor/Compilation/CompilerCache.cs b/src/Microsoft.AspNet.Mvc.Razor/Compilation/CompilerCache.cs index 0035ec8a48..b11d3784fd 100644 --- a/src/Microsoft.AspNet.Mvc.Razor/Compilation/CompilerCache.cs +++ b/src/Microsoft.AspNet.Mvc.Razor/Compilation/CompilerCache.cs @@ -134,7 +134,9 @@ namespace Microsoft.AspNet.Mvc.Razor // Timestamp doesn't match but it might be because of deployment, compare the hash. if (cacheEntry.IsPreCompiled && - string.Equals(cacheEntry.Hash, RazorFileHash.GetHash(fileInfo), StringComparison.Ordinal)) + string.Equals(cacheEntry.Hash, + RazorFileHash.GetHash(fileInfo, cacheEntry.HashAlgorithmVersion), + StringComparison.Ordinal)) { // Cache hit, but we need to update the entry. // Assigning to LastModified is an atomic operation and will result in a safe race if it is @@ -174,7 +176,7 @@ namespace Microsoft.AspNet.Mvc.Razor var viewStartEntry = GetCompositeViewStartEntry(entry.RelativePath, compile); return entry.AssociatedViewStartEntry != viewStartEntry; } - + // Returns the entry for the nearest _ViewStart that the file inherits directives from. Since _ViewStart // entries are affected by other _ViewStart entries that are in the path hierarchy, the returned value // represents the composite result of performing a cache check on individual _ViewStart entries. diff --git a/src/Microsoft.AspNet.Mvc.Razor/Compilation/CompilerCacheEntry.cs b/src/Microsoft.AspNet.Mvc.Razor/Compilation/CompilerCacheEntry.cs index bd6f73102c..598e23412a 100644 --- a/src/Microsoft.AspNet.Mvc.Razor/Compilation/CompilerCacheEntry.cs +++ b/src/Microsoft.AspNet.Mvc.Razor/Compilation/CompilerCacheEntry.cs @@ -22,6 +22,8 @@ namespace Microsoft.AspNet.Mvc.Razor Length = info.Length; LastModified = info.LastModified; Hash = info.Hash; + HashAlgorithmVersion = info.HashAlgorithmVersion; + IsPreCompiled = true; } /// @@ -40,17 +42,17 @@ namespace Microsoft.AspNet.Mvc.Razor /// /// Gets the produced as a result of compilation. /// - public Type CompiledType { get; private set; } + public Type CompiledType { get; } /// /// Gets the path of the compiled file relative to the root of the application. /// - public string RelativePath { get; private set; } + public string RelativePath { get; } /// /// Gets the size of file (in bytes) on disk. /// - public long Length { get; private set; } + public long Length { get; } /// /// Gets or sets the last modified for the file at the time of compilation. @@ -60,18 +62,20 @@ namespace Microsoft.AspNet.Mvc.Razor /// /// Gets the file hash, should only be available for pre compiled files. /// - public string Hash { get; private set; } + public string Hash { get; } + + /// + /// Gets the version of the hash algorithm used to generate . + /// + /// + /// This value is only initialized for precompiled views. + /// + public int HashAlgorithmVersion { get; } /// /// Gets a flag that indicates if the file is precompiled. /// - public bool IsPreCompiled - { - get - { - return Hash != null; - } - } + public bool IsPreCompiled { get; } /// /// Gets or sets the for the nearest ViewStart that the compiled type diff --git a/src/Microsoft.AspNet.Mvc.Razor/Properties/Resources.Designer.cs b/src/Microsoft.AspNet.Mvc.Razor/Properties/Resources.Designer.cs index 800c443b7b..e6822d8316 100644 --- a/src/Microsoft.AspNet.Mvc.Razor/Properties/Resources.Designer.cs +++ b/src/Microsoft.AspNet.Mvc.Razor/Properties/Resources.Designer.cs @@ -362,6 +362,22 @@ namespace Microsoft.AspNet.Mvc.Razor return string.Format(CultureInfo.CurrentCulture, GetString("ViewMustBeContextualized"), p0, p1); } + /// + /// Unsupported hash algorithm. + /// + internal static string RazorHash_UnsupportedHashAlgorithm + { + get { return GetString("RazorHash_UnsupportedHashAlgorithm"); } + } + + /// + /// Unsupported hash algorithm. + /// + internal static string FormatRazorHash_UnsupportedHashAlgorithm() + { + return GetString("RazorHash_UnsupportedHashAlgorithm"); + } + private static string GetString(string name, params string[] formatterNames) { var value = _resourceManager.GetString(name); diff --git a/src/Microsoft.AspNet.Mvc.Razor/Razor/Crc32.cs b/src/Microsoft.AspNet.Mvc.Razor/Razor/Crc32.cs new file mode 100644 index 0000000000..c17d037db3 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Razor/Razor/Crc32.cs @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.IO; + +namespace Microsoft.AspNet.Mvc.Razor +{ + internal static class Crc32 + { + private static readonly int BufferSize = 4 * 1024; + + private static readonly uint[] _crcTable = new uint[256] + { + 0x00000000u, 0x77073096u, 0xee0e612cu, 0x990951bau, 0x076dc419u, + 0x706af48fu, 0xe963a535u, 0x9e6495a3u, 0x0edb8832u, 0x79dcb8a4u, + 0xe0d5e91eu, 0x97d2d988u, 0x09b64c2bu, 0x7eb17cbdu, 0xe7b82d07u, + 0x90bf1d91u, 0x1db71064u, 0x6ab020f2u, 0xf3b97148u, 0x84be41deu, + 0x1adad47du, 0x6ddde4ebu, 0xf4d4b551u, 0x83d385c7u, 0x136c9856u, + 0x646ba8c0u, 0xfd62f97au, 0x8a65c9ecu, 0x14015c4fu, 0x63066cd9u, + 0xfa0f3d63u, 0x8d080df5u, 0x3b6e20c8u, 0x4c69105eu, 0xd56041e4u, + 0xa2677172u, 0x3c03e4d1u, 0x4b04d447u, 0xd20d85fdu, 0xa50ab56bu, + 0x35b5a8fau, 0x42b2986cu, 0xdbbbc9d6u, 0xacbcf940u, 0x32d86ce3u, + 0x45df5c75u, 0xdcd60dcfu, 0xabd13d59u, 0x26d930acu, 0x51de003au, + 0xc8d75180u, 0xbfd06116u, 0x21b4f4b5u, 0x56b3c423u, 0xcfba9599u, + 0xb8bda50fu, 0x2802b89eu, 0x5f058808u, 0xc60cd9b2u, 0xb10be924u, + 0x2f6f7c87u, 0x58684c11u, 0xc1611dabu, 0xb6662d3du, 0x76dc4190u, + 0x01db7106u, 0x98d220bcu, 0xefd5102au, 0x71b18589u, 0x06b6b51fu, + 0x9fbfe4a5u, 0xe8b8d433u, 0x7807c9a2u, 0x0f00f934u, 0x9609a88eu, + 0xe10e9818u, 0x7f6a0dbbu, 0x086d3d2du, 0x91646c97u, 0xe6635c01u, + 0x6b6b51f4u, 0x1c6c6162u, 0x856530d8u, 0xf262004eu, 0x6c0695edu, + 0x1b01a57bu, 0x8208f4c1u, 0xf50fc457u, 0x65b0d9c6u, 0x12b7e950u, + 0x8bbeb8eau, 0xfcb9887cu, 0x62dd1ddfu, 0x15da2d49u, 0x8cd37cf3u, + 0xfbd44c65u, 0x4db26158u, 0x3ab551ceu, 0xa3bc0074u, 0xd4bb30e2u, + 0x4adfa541u, 0x3dd895d7u, 0xa4d1c46du, 0xd3d6f4fbu, 0x4369e96au, + 0x346ed9fcu, 0xad678846u, 0xda60b8d0u, 0x44042d73u, 0x33031de5u, + 0xaa0a4c5fu, 0xdd0d7cc9u, 0x5005713cu, 0x270241aau, 0xbe0b1010u, + 0xc90c2086u, 0x5768b525u, 0x206f85b3u, 0xb966d409u, 0xce61e49fu, + 0x5edef90eu, 0x29d9c998u, 0xb0d09822u, 0xc7d7a8b4u, 0x59b33d17u, + 0x2eb40d81u, 0xb7bd5c3bu, 0xc0ba6cadu, 0xedb88320u, 0x9abfb3b6u, + 0x03b6e20cu, 0x74b1d29au, 0xead54739u, 0x9dd277afu, 0x04db2615u, + 0x73dc1683u, 0xe3630b12u, 0x94643b84u, 0x0d6d6a3eu, 0x7a6a5aa8u, + 0xe40ecf0bu, 0x9309ff9du, 0x0a00ae27u, 0x7d079eb1u, 0xf00f9344u, + 0x8708a3d2u, 0x1e01f268u, 0x6906c2feu, 0xf762575du, 0x806567cbu, + 0x196c3671u, 0x6e6b06e7u, 0xfed41b76u, 0x89d32be0u, 0x10da7a5au, + 0x67dd4accu, 0xf9b9df6fu, 0x8ebeeff9u, 0x17b7be43u, 0x60b08ed5u, + 0xd6d6a3e8u, 0xa1d1937eu, 0x38d8c2c4u, 0x4fdff252u, 0xd1bb67f1u, + 0xa6bc5767u, 0x3fb506ddu, 0x48b2364bu, 0xd80d2bdau, 0xaf0a1b4cu, + 0x36034af6u, 0x41047a60u, 0xdf60efc3u, 0xa867df55u, 0x316e8eefu, + 0x4669be79u, 0xcb61b38cu, 0xbc66831au, 0x256fd2a0u, 0x5268e236u, + 0xcc0c7795u, 0xbb0b4703u, 0x220216b9u, 0x5505262fu, 0xc5ba3bbeu, + 0xb2bd0b28u, 0x2bb45a92u, 0x5cb36a04u, 0xc2d7ffa7u, 0xb5d0cf31u, + 0x2cd99e8bu, 0x5bdeae1du, 0x9b64c2b0u, 0xec63f226u, 0x756aa39cu, + 0x026d930au, 0x9c0906a9u, 0xeb0e363fu, 0x72076785u, 0x05005713u, + 0x95bf4a82u, 0xe2b87a14u, 0x7bb12baeu, 0x0cb61b38u, 0x92d28e9bu, + 0xe5d5be0du, 0x7cdcefb7u, 0x0bdbdf21u, 0x86d3d2d4u, 0xf1d4e242u, + 0x68ddb3f8u, 0x1fda836eu, 0x81be16cdu, 0xf6b9265bu, 0x6fb077e1u, + 0x18b74777u, 0x88085ae6u, 0xff0f6a70u, 0x66063bcau, 0x11010b5cu, + 0x8f659effu, 0xf862ae69u, 0x616bffd3u, 0x166ccf45u, 0xa00ae278u, + 0xd70dd2eeu, 0x4e048354u, 0x3903b3c2u, 0xa7672661u, 0xd06016f7u, + 0x4969474du, 0x3e6e77dbu, 0xaed16a4au, 0xd9d65adcu, 0x40df0b66u, + 0x37d83bf0u, 0xa9bcae53u, 0xdebb9ec5u, 0x47b2cf7fu, 0x30b5ffe9u, + 0xbdbdf21cu, 0xcabac28au, 0x53b39330u, 0x24b4a3a6u, 0xbad03605u, + 0xcdd70693u, 0x54de5729u, 0x23d967bfu, 0xb3667a2eu, 0xc4614ab8u, + 0x5d681b02u, 0x2a6f2b94u, 0xb40bbe37u, 0xc30c8ea1u, 0x5a05df1bu, + 0x2d02ef8du + }; + + public static uint Calculate(Stream stream) + { + var buffer = new byte[BufferSize]; + var crc32 = 0xffffffffU; + while (true) + { + var read = stream.Read(buffer, 0, BufferSize); + if (read == 0) + { + break; + } + + var offset = 0; + while (offset < read) + { + crc32 = _crcTable[(crc32 ^ buffer[offset++]) & 0xFF] ^ (crc32 >> 8); + } + } + + return crc32 ^ 0xffffffffU; + } + } +} + diff --git a/src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RazorFileInfo.cs b/src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RazorFileInfo.cs index e19113b0a1..f31459696b 100644 --- a/src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RazorFileInfo.cs +++ b/src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RazorFileInfo.cs @@ -31,5 +31,10 @@ namespace Microsoft.AspNet.Mvc.Razor /// A hash of the file content. /// public string Hash { get; set; } + + /// + /// The version of hash algorithm used to generate . + /// + public int HashAlgorithmVersion { get; set; } } } \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RazorFileInfoCollectionGenerator.cs b/src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RazorFileInfoCollectionGenerator.cs index 4db7acad5d..a09a252830 100644 --- a/src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RazorFileInfoCollectionGenerator.cs +++ b/src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RazorFileInfoCollectionGenerator.cs @@ -51,7 +51,8 @@ namespace Microsoft.AspNet.Mvc.Razor fileInfo.Length, fileInfo.RelativePath, fileInfo.FullTypeName, - fileInfo.Hash); + fileInfo.Hash, + fileInfo.HashAlgorithmVersion); } protected virtual string Top @@ -103,7 +104,8 @@ namespace __ASP_ASSEMBLY " + nameof(RazorFileInfo.Length) + @" = {1:D}, " + nameof(RazorFileInfo.RelativePath) + @" = @""{2}"", " + nameof(RazorFileInfo.FullTypeName) + @" = @""{3}"", - " + nameof(RazorFileInfo.Hash) + @" = @""{4}"", + " + nameof(RazorFileInfo.Hash) + @" = ""{4}"", + " + nameof(RazorFileInfo.HashAlgorithmVersion) + @" = {5}, }}; fileInfos.Add(info); "; diff --git a/src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RazorPreCompiler.cs b/src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RazorPreCompiler.cs index df11e09345..e4451ef060 100644 --- a/src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RazorPreCompiler.cs +++ b/src/Microsoft.AspNet.Mvc.Razor/Razor/PreCompileViews/RazorPreCompiler.cs @@ -167,7 +167,7 @@ namespace Microsoft.AspNet.Mvc.Razor if (fullTypeName != null) { - var hash = RazorFileHash.GetHash(fileInfo.FileInfo); + var hash = RazorFileHash.GetHash(fileInfo.FileInfo, RazorFileHash.HashAlgorithmVersion1); var razorFileInfo = new RazorFileInfo { RelativePath = fileInfo.RelativePath, diff --git a/src/Microsoft.AspNet.Mvc.Razor/Razor/RazorFileHash.cs b/src/Microsoft.AspNet.Mvc.Razor/Razor/RazorFileHash.cs index 9f2176414d..864116d60c 100644 --- a/src/Microsoft.AspNet.Mvc.Razor/Razor/RazorFileHash.cs +++ b/src/Microsoft.AspNet.Mvc.Razor/Razor/RazorFileHash.cs @@ -2,36 +2,40 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Globalization; using System.IO; -using System.Security.Cryptography; using Microsoft.AspNet.FileProviders; namespace Microsoft.AspNet.Mvc.Razor { public static class RazorFileHash { - public static string GetHash([NotNull] IFileInfo file) + /// + /// Version 1 of the hash algorithm used for generating hashes of Razor files. + /// + public static readonly int HashAlgorithmVersion1 = 1; + + public static string GetHash([NotNull] IFileInfo file, int hashAlgorithmVersion) { + if (hashAlgorithmVersion != HashAlgorithmVersion1) + { + throw new ArgumentException(Resources.RazorHash_UnsupportedHashAlgorithm, + nameof(hashAlgorithmVersion)); + } + try { using (var stream = file.CreateReadStream()) { - return GetHash(stream); + return Crc32.Calculate(stream).ToString(CultureInfo.InvariantCulture); } } - catch (Exception) + catch (IOException) { // Don't throw if reading the file fails. - return string.Empty; } - } - internal static string GetHash(Stream stream) - { - using (var md5 = MD5.Create()) - { - return BitConverter.ToString(md5.ComputeHash(stream)); - } + return string.Empty; } } } \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Razor/Resources.resx b/src/Microsoft.AspNet.Mvc.Razor/Resources.resx index 214cb7ba56..b032e9039c 100644 --- a/src/Microsoft.AspNet.Mvc.Razor/Resources.resx +++ b/src/Microsoft.AspNet.Mvc.Razor/Resources.resx @@ -183,4 +183,7 @@ The '{0}' method must be called before '{1}' can be invoked. + + Unsupported hash algorithm. + \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.Razor.Test/Compilation/CompilerCacheTest.cs b/test/Microsoft.AspNet.Mvc.Razor.Test/Compilation/CompilerCacheTest.cs index 1b3f501d9d..9a7be03ae3 100644 --- a/test/Microsoft.AspNet.Mvc.Razor.Test/Compilation/CompilerCacheTest.cs +++ b/test/Microsoft.AspNet.Mvc.Razor.Test/Compilation/CompilerCacheTest.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Text; @@ -82,7 +83,8 @@ namespace Microsoft.AspNet.Mvc.Razor Add(new RazorFileInfo() { FullTypeName = typeof(PreCompile).FullName, - Hash = RazorFileHash.GetHash(GetMemoryStream(content)), + Hash = Crc32.Calculate(GetMemoryStream(content)).ToString(CultureInfo.InvariantCulture), + HashAlgorithmVersion = 1, LastModified = DateTime.FromFileTimeUtc(10000), Length = length, RelativePath = "ab", @@ -127,7 +129,8 @@ namespace Microsoft.AspNet.Mvc.Razor var razorFileInfo = new RazorFileInfo { FullTypeName = typeof(PreCompile).FullName, - Hash = RazorFileHash.GetHash(GetMemoryStream(precompiledContent)), + Hash = Crc32.Calculate(GetMemoryStream(precompiledContent)).ToString(CultureInfo.InvariantCulture), + HashAlgorithmVersion = 1, LastModified = DateTime.FromFileTimeUtc(10000), Length = Encoding.UTF8.GetByteCount(precompiledContent), RelativePath = "ab", @@ -169,7 +172,8 @@ namespace Microsoft.AspNet.Mvc.Razor var razorFileInfo = new RazorFileInfo { FullTypeName = typeof(PreCompile).FullName, - Hash = RazorFileHash.GetHash(GetMemoryStream(precompiledContent)), + Hash = Crc32.Calculate(GetMemoryStream(precompiledContent)).ToString(CultureInfo.InvariantCulture), + HashAlgorithmVersion = 1, LastModified = DateTime.FromFileTimeUtc(10000), Length = Encoding.UTF8.GetByteCount(precompiledContent), RelativePath = "ab", @@ -210,7 +214,8 @@ namespace Microsoft.AspNet.Mvc.Razor fileProvider.AddFile("_ViewStart.cshtml", viewStartFileInfo); var viewStartRazorFileInfo = new RazorFileInfo { - Hash = RazorFileHash.GetHash(GetMemoryStream(viewStartContent)), + Hash = Crc32.Calculate(GetMemoryStream(viewStartContent)).ToString(CultureInfo.InvariantCulture), + HashAlgorithmVersion = 1, LastModified = viewStartFileInfo.LastModified, Length = viewStartFileInfo.Length, RelativePath = "_ViewStart.cshtml", @@ -295,7 +300,8 @@ namespace Microsoft.AspNet.Mvc.Razor FullTypeName = typeof(RuntimeCompileIdentical).FullName, RelativePath = viewStartFileInfo.PhysicalPath, LastModified = viewStartFileInfo.LastModified, - Hash = RazorFileHash.GetHash(viewStartFileInfo), + Hash = RazorFileHash.GetHash(viewStartFileInfo, hashAlgorithmVersion: 1), + HashAlgorithmVersion = 1, Length = viewStartFileInfo.Length }; fileProvider.AddFile(viewStartFileInfo.PhysicalPath, viewStartFileInfo); @@ -332,7 +338,8 @@ namespace Microsoft.AspNet.Mvc.Razor var razorFileInfo = new RazorFileInfo { - Hash = RazorFileHash.GetHash(contentStream), + Hash = Crc32.Calculate(contentStream).ToString(CultureInfo.InvariantCulture), + HashAlgorithmVersion = 1, LastModified = lastModified, Length = length, RelativePath = path @@ -381,7 +388,8 @@ namespace Microsoft.AspNet.Mvc.Razor var razorFileInfo = new RazorFileInfo { FullTypeName = typeof(PreCompile).FullName, - Hash = RazorFileHash.GetHash(fileInfo), + Hash = RazorFileHash.GetHash(fileInfo, hashAlgorithmVersion: 1), + HashAlgorithmVersion = 1, LastModified = lastModified, Length = Encoding.UTF8.GetByteCount(content), RelativePath = fileInfo.PhysicalPath, diff --git a/test/Microsoft.AspNet.Mvc.Razor.Test/RazorFileHashTest.cs b/test/Microsoft.AspNet.Mvc.Razor.Test/RazorFileHashTest.cs new file mode 100644 index 0000000000..4e644f6acd --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.Razor.Test/RazorFileHashTest.cs @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Open Technologies, Inc. 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 System.IO; +using System.Linq; +using System.Text; +using Microsoft.AspNet.FileProviders; +using Microsoft.AspNet.Testing; +using Moq; +using Xunit; + +namespace Microsoft.AspNet.Mvc.Razor +{ + public class RazorFileHashTest + { + public static IEnumerable GetHashCodeData + { + get + { + var longString = string.Join(Environment.NewLine, + Enumerable.Repeat("Turn up for what", 14)); + + var stringWith4kChars = new string('a', 4096); + + var stringValues = new[] + { + new[] { "1", "2212294583" }, + new[] { "Hello world", "2346098258" }, + new[] { "hello world", "222957957" }, + new[] { "The quick brown fox jumped over the lazy dog", "2765681502" }, + new[] { longString, "1994223647" }, + new[] { stringWith4kChars.Substring(1), "2679155331" }, // 4095 chars + new[] { stringWith4kChars, "2627329139" }, + new[] { stringWith4kChars + "a", "556205849" }, // 4097 chars + new[] { stringWith4kChars + stringWith4kChars + "aa", "1595203983" }, // 8194 + }; + + var bytesToRead = new[] + { + 1, + 2, + 100, + 1024, + 4095, + 4096 + }; + + return from value in stringValues + from readBytes in bytesToRead + select new object[] { readBytes, value[0], value[1] }; + } + } + + [Theory] + [InlineData(-1)] + [InlineData(0)] + [InlineData(2)] + [InlineData(14)] + public void GetHash_ThrowsIfHashAlgorithmVersionIsUnknown(int hashAlgorithmVersion) + { + // Arrange + var file = new TestFileInfo(); + + // Act and Assert + ExceptionAssert.ThrowsArgument(() => RazorFileHash.GetHash(file, hashAlgorithmVersion), + "hashAlgorithmVersion", + "Unsupported hash algorithm."); + } + + [Theory] + [MemberData(nameof(GetHashCodeData))] + public void GetHash_CalculatesHashCodeForFile(int bytesToRead, string content, string expected) + { + // Arrange + var bytes = Encoding.UTF8.GetBytes(content); + var file = new Mock(); + file.Setup(f => f.CreateReadStream()) + .Returns(new SlowStream(bytes, bytesToRead)); + + // Act + var result = RazorFileHash.GetHash(file.Object, hashAlgorithmVersion: 1); + + // Assert + Assert.Equal(expected, result); + } + + private class SlowStream : MemoryStream + { + private readonly int _bytesToRead; + + public SlowStream(byte[] buffer, int bytesToRead) + : base(buffer) + { + _bytesToRead = bytesToRead; + } + + public override int Read(byte[] buffer, int offset, int count) + { + Debug.Assert(offset == 0 && count == 4096); + return base.Read(buffer, 0, _bytesToRead); + } + } + } +} \ No newline at end of file