diff --git a/Light.TemporaryStreams.sln.DotSettings b/Light.TemporaryStreams.sln.DotSettings
index bb2d7f1..0a92497 100644
--- a/Light.TemporaryStreams.sln.DotSettings
+++ b/Light.TemporaryStreams.sln.DotSettings
@@ -163,4 +163,5 @@
True
True
True
+ True
diff --git a/src/Light.TemporaryStreams.Core/Hashing/CopyToHashCalculator.cs b/src/Light.TemporaryStreams.Core/Hashing/CopyToHashCalculator.cs
index 5c9fd69..c4a03d7 100644
--- a/src/Light.TemporaryStreams.Core/Hashing/CopyToHashCalculator.cs
+++ b/src/Light.TemporaryStreams.Core/Hashing/CopyToHashCalculator.cs
@@ -22,15 +22,11 @@ public sealed class CopyToHashCalculator : IAsyncDisposable
/// The hash algorithm to use.
/// The enum value identifying how hash byte arrays are converted to strings.
/// A name that uniquely identifies the hash algorithm.
- public CopyToHashCalculator(
- HashAlgorithm hashAlgorithm,
- HashConversionMethod conversionMethod,
- string? name = null
- )
+ public CopyToHashCalculator(HashAlgorithm hashAlgorithm, HashConversionMethod conversionMethod, string? name = null)
{
HashAlgorithm = hashAlgorithm.MustNotBeNull();
ConversionMethod = conversionMethod.MustBeValidEnumValue();
- Name = name ?? hashAlgorithm.GetType().Name;
+ Name = name ?? DetermineDefaultName(hashAlgorithm);
}
///
@@ -54,12 +50,21 @@ public CopyToHashCalculator(
///
/// Thrown when has not been called yet.
///
- public string Hash =>
- _hash.MustNotBeNull(
- () => new InvalidOperationException(
- $"ObtainHashFromAlgorithm must be called before accessing the {nameof(Hash)} property."
- )
- );
+ public string Hash
+ {
+ get
+ {
+ var hash = _hash;
+ if (hash is null)
+ {
+ throw new InvalidOperationException(
+ $"ObtainHashFromAlgorithm must be called before accessing the {nameof(Hash)} property"
+ );
+ }
+
+ return hash;
+ }
+ }
///
/// The calculated hash in byte array representation.
@@ -67,12 +72,21 @@ public CopyToHashCalculator(
///
/// Thrown when has not been called yet.
///
- public byte[] HashArray =>
- _hashArray.MustNotBeNull(
- () => new InvalidOperationException(
- $"ObtainHashFromAlgorithm must be called before accessing the {nameof(HashArray)} property."
- )
- );
+ public byte[] HashArray
+ {
+ get
+ {
+ var hashArray = _hashArray;
+ if (hashArray is null)
+ {
+ throw new InvalidOperationException(
+ $"ObtainHashFromAlgorithm must be called before accessing the {nameof(HashArray)} property"
+ );
+ }
+
+ return hashArray;
+ }
+ }
///
/// Asynchronously disposes the resources used by the current instance, including the CryptoStream and the hash algorithm.
@@ -90,6 +104,23 @@ public async ValueTask DisposeAsync()
HashAlgorithm.Dispose();
}
+ private static string DetermineDefaultName(HashAlgorithm hashAlgorithm)
+ {
+ /* Some of the .NET hash algorithms (like .SHA1, MD5) are actually just abstract base classes. They have a
+ * nested type called implementation which can be instantiated, but this type is not publicly visible. GetType()
+ * will likely return the implementation type, which is not what we want as callers would want the name of the
+ * base type instead. This is why we check the name of the type for ".Implementation" and if found, we return
+ * the name of the base type instead.
+ *
+ * Other types like HMACSHA256 are public non-abstract types and can be used as expected. */
+ var type = hashAlgorithm.GetType();
+ var name = type.Name;
+ var baseTypeName = type.BaseType?.Name;
+ return name.Equals("Implementation", StringComparison.Ordinal) && !baseTypeName.IsNullOrWhiteSpace() ?
+ baseTypeName :
+ name;
+ }
+
///
/// Creates a CryptoStream wrapped around the specified stream. The CryptoStream
/// is configured to calculate a hash using the hash algorithm provided by the
@@ -127,22 +158,13 @@ public void ObtainHashFromAlgorithm()
.Hash
.MustNotBeNull(
() => new InvalidOperationException(
- "The crypto stream was not written to - no hash was calculated."
+ "The crypto stream was not written to - no hash was calculated"
)
);
- _hash = ConvertHashToString(_hashArray);
+ _hash = HashConverter.ConvertHashToString(_hashArray, ConversionMethod);
}
- private string ConvertHashToString(byte[] hashArray) =>
- ConversionMethod switch
- {
- HashConversionMethod.Base64 => Convert.ToBase64String(hashArray),
- HashConversionMethod.UpperHexadecimal => Convert.ToHexString(hashArray),
- HashConversionMethod.None => "",
- _ => throw new InvalidDataException($"{nameof(ConversionMethod)} has an invalid value")
- };
-
///
/// Converts a to a using the default settings
/// (hash array is converted to a Base64 string, name is identical to the type name).
diff --git a/src/Light.TemporaryStreams.Core/Hashing/HashConverter.cs b/src/Light.TemporaryStreams.Core/Hashing/HashConverter.cs
new file mode 100644
index 0000000..b2d665e
--- /dev/null
+++ b/src/Light.TemporaryStreams.Core/Hashing/HashConverter.cs
@@ -0,0 +1,31 @@
+using System;
+using System.IO;
+
+namespace Light.TemporaryStreams.Hashing;
+
+///
+/// Converts hash byte arrays to strings.
+///
+public static class HashConverter
+{
+ ///
+ /// Converts the hash byte array to a string based on the specified .
+ ///
+ /// The hash byte array to convert.
+ /// The enum value specifying how the hash byte array should be converted to a string.
+ /// The hash as a string.
+ ///
+ /// Thrown if has an invalid value.
+ ///
+ public static string ConvertHashToString(byte[] hashArray, HashConversionMethod conversionMethod) =>
+ conversionMethod switch
+ {
+ HashConversionMethod.Base64 => Convert.ToBase64String(hashArray),
+ HashConversionMethod.UpperHexadecimal => Convert.ToHexString(hashArray),
+ HashConversionMethod.None => "",
+ _ => throw new ArgumentOutOfRangeException(
+ nameof(conversionMethod),
+ $"{nameof(conversionMethod)} has an invalid value '{conversionMethod}'"
+ )
+ };
+}
diff --git a/src/Light.TemporaryStreams.Core/Hashing/HashingPlugin.cs b/src/Light.TemporaryStreams.Core/Hashing/HashingPlugin.cs
index 534165d..6e4257f 100644
--- a/src/Light.TemporaryStreams.Core/Hashing/HashingPlugin.cs
+++ b/src/Light.TemporaryStreams.Core/Hashing/HashingPlugin.cs
@@ -74,18 +74,18 @@ public async ValueTask DisposeAsync()
/// The inner stream to be wrapped by the hash calculators.
/// The optional token to cancel the asynchronous operation.
/// The outermost CryptoStream.
+ /// Thrown when is null.
public ValueTask SetUpAsync(Stream innerStream, CancellationToken cancellationToken = default)
{
innerStream.MustNotBeNull();
- CryptoStream outermostStream = null!; // In the constructor, we ensure that the immutable array is not empty
+ Stream currentStream = innerStream;
for (var i = 0; i < HashCalculators.Length; i++)
{
- outermostStream =
- HashCalculators[i].CreateWrappingCryptoStream(innerStream, leaveWrappedStreamOpen: true);
+ currentStream = HashCalculators[i].CreateWrappingCryptoStream(currentStream, leaveWrappedStreamOpen: true);
}
- _outermostCryptoStream = outermostStream;
- return new ValueTask(outermostStream);
+ _outermostCryptoStream = (CryptoStream)currentStream;
+ return new ValueTask(currentStream);
}
///
diff --git a/src/Light.TemporaryStreams.Core/TemporaryStreamServiceExtensions.cs b/src/Light.TemporaryStreams.Core/TemporaryStreamServiceExtensions.cs
index 8010220..e600e79 100644
--- a/src/Light.TemporaryStreams.Core/TemporaryStreamServiceExtensions.cs
+++ b/src/Light.TemporaryStreams.Core/TemporaryStreamServiceExtensions.cs
@@ -38,7 +38,7 @@ public static class TemporaryStreamServiceExtensions
/// Thrown when or are null.
/// Thrown when has a value that is less than 0.
public static async Task CopyToTemporaryStreamAsync(
- this TemporaryStreamService temporaryStreamService,
+ this ITemporaryStreamService temporaryStreamService,
Stream source,
string? filePath = null,
TemporaryStreamServiceOptions? options = null,
@@ -100,7 +100,7 @@ await source
/// Thrown when has a value that is less than 0.
/// Thrown when is empty or the default instance.
public static async Task CopyToTemporaryStreamAsync(
- this TemporaryStreamService temporaryStreamService,
+ this ITemporaryStreamService temporaryStreamService,
Stream source,
ImmutableArray plugins,
string? filePath = null,
@@ -123,7 +123,7 @@ public static async Task CopyToTemporaryStreamAsync(
{
for (var i = 0; i < plugins.Length; i++)
{
- outermostStream = await plugins[i].SetUpAsync(source, cancellationToken).ConfigureAwait(false);
+ outermostStream = await plugins[i].SetUpAsync(outermostStream, cancellationToken).ConfigureAwait(false);
}
if (copyBufferSize.HasValue)
diff --git a/tests/Light.TemporaryStreams.Core.Tests/CopyToTemporaryStreamTests.cs b/tests/Light.TemporaryStreams.Core.Tests/CopyToTemporaryStreamTests.cs
new file mode 100644
index 0000000..65da05d
--- /dev/null
+++ b/tests/Light.TemporaryStreams.Core.Tests/CopyToTemporaryStreamTests.cs
@@ -0,0 +1,527 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.IO;
+using System.Security.Cryptography;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Light.GuardClauses.Exceptions;
+using Light.TemporaryStreams.Hashing;
+using Xunit;
+using XunitException = Xunit.Sdk.XunitException;
+
+namespace Light.TemporaryStreams;
+
+public static class CopyToTemporaryStreamTests
+{
+ [Theory]
+ [InlineData(TemporaryStreamServiceOptions.DefaultFileThresholdInBytes - 1)]
+ [InlineData(12_000)]
+ public static async Task CopyToTemporaryStreamAsync_ShouldCreateMemoryStream_WhenSourceIsSmall(int bufferSize)
+ {
+ // Arrange
+ var (service, sourceData, sourceStream) = CreateTestSetup(bufferSize);
+ var cancellationToken = TestContext.Current.CancellationToken;
+
+ // Act
+ await using var temporaryStream = await service.CopyToTemporaryStreamAsync(
+ sourceStream,
+ cancellationToken: cancellationToken
+ );
+
+ // Assert
+ await AssertTemporaryStreamContentsMatchAsync(
+ temporaryStream,
+ sourceData,
+ expectFileBased: false,
+ cancellationToken
+ );
+ }
+
+ [Theory]
+ [InlineData(TemporaryStreamServiceOptions.DefaultFileThresholdInBytes)]
+ [InlineData(100_000)]
+ public static async Task CopyToTemporaryStreamAsync_ShouldCreateFileStream_WhenSourceIsLarge(int bufferSize)
+ {
+ // Arrange
+ var (service, sourceData, sourceStream) = CreateTestSetup(bufferSize);
+ var cancellationToken = TestContext.Current.CancellationToken;
+
+ // Act
+ await using var temporaryStream = await service.CopyToTemporaryStreamAsync(
+ sourceStream,
+ cancellationToken: cancellationToken
+ );
+
+ // Assert
+ await AssertTemporaryStreamContentsMatchAsync(
+ temporaryStream,
+ sourceData,
+ expectFileBased: true,
+ cancellationToken
+ );
+ }
+
+ [Fact]
+ public static async Task CopyToTemporaryStreamAsync_ShouldForwardBufferSize()
+ {
+ // Arrange
+ var (service, sourceData, sourceStream) = CreateTestSetup(40_000);
+ var cancellationToken = TestContext.Current.CancellationToken;
+
+ // Act
+ await using var temporaryStream = await service.CopyToTemporaryStreamAsync(
+ sourceStream,
+ copyBufferSize: 24 * 1024,
+ cancellationToken: cancellationToken
+ );
+
+ // Assert
+ await AssertTemporaryStreamContentsMatchAsync(
+ temporaryStream,
+ sourceData,
+ expectFileBased: false,
+ cancellationToken
+ );
+ }
+
+ [Fact]
+ public static async Task CopyToTemporaryStreamAsync_ShouldUseCopyBufferSize_WhenSpecified()
+ {
+ // Arrange
+ var (service, sourceData, sourceStream) = CreateTestSetup(100_000);
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var filePath = Path.GetFullPath("test.txt");
+
+ // Act
+ await using var temporaryStream = await service.CopyToTemporaryStreamAsync(
+ sourceStream,
+ filePath: filePath,
+ cancellationToken: cancellationToken
+ );
+
+ // Assert
+ await AssertTemporaryStreamContentsMatchAsync(
+ temporaryStream,
+ sourceData,
+ expectFileBased: true,
+ cancellationToken
+ );
+ }
+
+ [Fact]
+ public static async Task CopyToTemporaryStreamAsync_ShouldUseCustomOptions_WhenProvided()
+ {
+ // Arrange
+ var (service, sourceData, sourceStream) = CreateTestSetup(50_000); // Would normally use MemoryStream
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var customOptions = new TemporaryStreamServiceOptions
+ {
+ FileThresholdInBytes = 30_000 // Force FileStream usage
+ };
+
+ // Act
+ await using var temporaryStream = await service.CopyToTemporaryStreamAsync(
+ sourceStream,
+ options: customOptions,
+ cancellationToken: cancellationToken
+ );
+
+ // Assert
+ await AssertTemporaryStreamContentsMatchAsync(
+ temporaryStream,
+ sourceData,
+ expectFileBased: true,
+ cancellationToken
+ );
+ }
+
+ [Fact]
+ public static async Task CopyToTemporaryStreamAsync_ShouldForwardFilePath()
+ {
+ // Arrange
+ var (service, sourceData, sourceStream) = CreateTestSetup(100_000);
+ var cancellationToken = TestContext.Current.CancellationToken;
+
+ // Act
+ await using var temporaryStream = await service.CopyToTemporaryStreamAsync(
+ sourceStream,
+ filePath: "test.txt",
+ cancellationToken: cancellationToken
+ );
+
+ // Assert
+ await AssertTemporaryStreamContentsMatchAsync(
+ temporaryStream,
+ sourceData,
+ expectFileBased: true,
+ cancellationToken
+ );
+ }
+
+ [Fact]
+ public static async Task CopyToTemporaryStreamAsync_ShouldDisposeTemporaryStream_WhenAnExceptionIsThrown()
+ {
+ // Arrange
+ var (service, sourceData, sourceStream) = CreateTestSetup(100_000, useErroneousSourceStream: true);
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var serviceSpy = new TemporaryStreamServiceSpy(service);
+
+ // Act
+ var act = () => serviceSpy.CopyToTemporaryStreamAsync(sourceStream, cancellationToken: cancellationToken);
+
+ // Assert
+ (await act.Should().ThrowAsync())
+ .Which.Message.Should().Be("Intentional exception to test error handling");
+ serviceSpy.CapturedTemporaryStream.IsDisposed.Should().BeTrue();
+ }
+
+ [Theory]
+ [InlineData(HashConversionMethod.Base64)]
+ [InlineData(HashConversionMethod.UpperHexadecimal)]
+ public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldCalculateCorrectHash_WhenSourceIsSmall(
+ HashConversionMethod hashConversionMethod
+ )
+ {
+ // Arrange
+ var (service, sourceData, sourceStream) = CreateTestSetup(40_000); // Less than 80KB threshold
+ var cancellationToken = TestContext.Current.CancellationToken;
+ await using var hashingPlugin =
+ new HashingPlugin([new CopyToHashCalculator(SHA1.Create(), hashConversionMethod)]);
+
+ // Act
+ await using var temporaryStream = await service.CopyToTemporaryStreamAsync(
+ sourceStream,
+ [hashingPlugin],
+ cancellationToken: cancellationToken
+ );
+
+ // Assert
+ await AssertTemporaryStreamContentsMatchAsync(
+ temporaryStream,
+ sourceData,
+ expectFileBased: false,
+ cancellationToken
+ );
+ hashingPlugin.GetHashArray(nameof(SHA1)).Should().Equal(SHA1.HashData(sourceData));
+ }
+
+ [Theory]
+ [InlineData(HashConversionMethod.Base64)]
+ [InlineData(HashConversionMethod.UpperHexadecimal)]
+ public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldCalculateCorrectHash_WhenSourceIsLarge(
+ HashConversionMethod hashConversionMethod
+ )
+ {
+ // Arrange
+ var (service, sourceData, sourceStream) = CreateTestSetup(100_000); // More than 80KB threshold
+ var cancellationToken = TestContext.Current.CancellationToken;
+ await using var hashingPlugin =
+ new HashingPlugin([new CopyToHashCalculator(MD5.Create(), hashConversionMethod)]);
+
+ // Act
+ await using var temporaryStream = await service.CopyToTemporaryStreamAsync(
+ sourceStream,
+ ImmutableArray.Create(hashingPlugin),
+ cancellationToken: cancellationToken
+ );
+
+ // Assert
+ await AssertTemporaryStreamContentsMatchAsync(
+ temporaryStream,
+ sourceData,
+ expectFileBased: true,
+ cancellationToken
+ );
+ hashingPlugin.GetHash(nameof(MD5)).Should().Be(
+ HashConverter.ConvertHashToString(MD5.HashData(sourceData), hashConversionMethod)
+ );
+ }
+
+ [Fact]
+ public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldSupportMultipleHashAlgorithms()
+ {
+ // Arrange
+ var (service, sourceData, sourceStream) = CreateTestSetup(50_000);
+ var cancellationToken = TestContext.Current.CancellationToken;
+
+ var sha1Calculator = new CopyToHashCalculator(SHA1.Create(), HashConversionMethod.UpperHexadecimal);
+ var sha256Calculator = new CopyToHashCalculator(SHA256.Create(), HashConversionMethod.Base64);
+
+ var expectedSha1Hash = HashConverter.ConvertHashToString(
+ SHA1.HashData(sourceData),
+ HashConversionMethod.UpperHexadecimal
+ );
+ var expectedSha256Hash = HashConverter.ConvertHashToString(
+ SHA256.HashData(sourceData),
+ HashConversionMethod.Base64
+ );
+
+ await using var hashingPlugin = new HashingPlugin([sha1Calculator, sha256Calculator]);
+
+ // Act
+ await using var temporaryStream = await service.CopyToTemporaryStreamAsync(
+ sourceStream,
+ [hashingPlugin],
+ cancellationToken: cancellationToken
+ );
+
+ // Assert
+ hashingPlugin.GetHash(nameof(SHA1)).Should().Be(expectedSha1Hash);
+ hashingPlugin.GetHash(nameof(SHA256)).Should().Be(expectedSha256Hash);
+ }
+
+ [Fact]
+ public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldUseCustomBufferSize()
+ {
+ // Arrange
+ var (service, sourceData, sourceStream) = CreateTestSetup(60_000);
+ var cancellationToken = TestContext.Current.CancellationToken;
+ await using var hashingPlugin = new HashingPlugin([SHA1.Create()]);
+
+ // Act
+ await using var temporaryStream = await service.CopyToTemporaryStreamAsync(
+ sourceStream,
+ [hashingPlugin],
+ copyBufferSize: 4096,
+ cancellationToken: cancellationToken
+ );
+
+ // Assert
+ temporaryStream.Should().NotBeNull();
+ temporaryStream.Length.Should().Be(sourceData.Length);
+ hashingPlugin.GetHash(nameof(SHA1)).Should().Be(Convert.ToBase64String(SHA1.HashData(sourceData)));
+ }
+
+ [Fact]
+ public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldForwardFilePath()
+ {
+ // Arrange
+ var (service, sourceData, sourceStream) = CreateTestSetup(100_000);
+ var cancellationToken = TestContext.Current.CancellationToken;
+ await using var hashingPlugin = new HashingPlugin([SHA1.Create()]);
+ var filePath = Path.GetFullPath("test.txt");
+
+ // Act
+ await using var temporaryStream = await service.CopyToTemporaryStreamAsync(
+ sourceStream,
+ [hashingPlugin],
+ filePath: filePath,
+ cancellationToken: cancellationToken
+ );
+
+ // Assert
+ temporaryStream.Should().NotBeNull();
+ temporaryStream.IsFileBased.Should().BeTrue();
+ temporaryStream.Length.Should().Be(sourceData.Length);
+ temporaryStream.GetUnderlyingFilePath().Should().Be(filePath);
+ hashingPlugin.GetHash(nameof(SHA1)).Should().Be(Convert.ToBase64String(SHA1.HashData(sourceData)));
+ }
+
+ [Fact]
+ public static async Task CopyToTemporaryStreamAsync_ShouldThrow_WhenPluginArrayIsEmpty()
+ {
+ var (service, _, sourceStream) = CreateTestSetup(10);
+
+ var act = () => service.CopyToTemporaryStreamAsync(
+ sourceStream,
+ ImmutableArray.Empty
+ );
+
+ (await act.Should().ThrowAsync())
+ .Which.ParamName.Should().Be("plugins");
+ }
+
+ [Fact]
+ public static async Task CopyToTemporaryStreamAsync_ShouldThrow_WhenPluginArrayIsDefaultInstance()
+ {
+ var (service, _, sourceStream) = CreateTestSetup(10);
+
+ var act = () => service.CopyToTemporaryStreamAsync(
+ sourceStream,
+ plugins: default
+ );
+
+ (await act.Should().ThrowAsync())
+ .Which.ParamName.Should().Be("plugins");
+ }
+
+ [Fact]
+ public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldThrowWhenHashNotFound()
+ {
+ // Arrange
+ var (service, _, sourceStream) = CreateTestSetup(20_000);
+ var cancellationToken = TestContext.Current.CancellationToken;
+ await using var hashPlugin = new HashingPlugin([SHA1.Create()]);
+
+ // Act
+ await using var temporaryStream = await service.CopyToTemporaryStreamAsync(
+ sourceStream,
+ [hashPlugin],
+ cancellationToken: cancellationToken
+ );
+ // ReSharper disable once AccessToDisposedClosure -- delegate called before hashPlugin is disposed of
+ var act = () => hashPlugin.GetHash(nameof(SHA256));
+
+ // Arrange
+ act.Should()
+ .Throw()
+ .Where(x => x.Message.StartsWith("There is no hash calculator with the name 'SHA256'"));
+ }
+
+ [Fact]
+ public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldThrowWhenHashArrayNotFound()
+ {
+ // Arrange
+ var (service, _, sourceStream) = CreateTestSetup(20_000);
+ var cancellationToken = TestContext.Current.CancellationToken;
+ await using var hashPlugin =
+ new HashingPlugin([new CopyToHashCalculator(SHA256.Create(), HashConversionMethod.None)]);
+
+ // Act
+ await using var temporaryStream = await service.CopyToTemporaryStreamAsync(
+ sourceStream,
+ [hashPlugin],
+ cancellationToken: cancellationToken
+ );
+ // ReSharper disable once AccessToDisposedClosure -- delegate called before hashPlugin is disposed of
+ var act = () => hashPlugin.GetHashArray(nameof(SHA3_512));
+
+ // Arrange
+ act.Should()
+ .Throw()
+ .Where(x => x.Message.StartsWith("There is no hash calculator with the name 'SHA3_512'"));
+ }
+
+ [Fact]
+ public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldReturnHashArray()
+ {
+ // Arrange
+ var (service, sourceData, sourceStream) = CreateTestSetup(20_000);
+ var cancellationToken = TestContext.Current.CancellationToken;
+ await using var hashPlugin =
+ new HashingPlugin([new CopyToHashCalculator(SHA256.Create(), HashConversionMethod.None)]);
+
+ // Act
+ await using var temporaryStream = await service.CopyToTemporaryStreamAsync(
+ sourceStream,
+ [hashPlugin],
+ cancellationToken: cancellationToken
+ );
+
+ // Assert
+ var hashArray = hashPlugin.GetHashArray(nameof(SHA256));
+ var expectedHash = SHA256.HashData(sourceData);
+ hashArray.Should().Equal(expectedHash);
+ }
+
+ [Fact]
+ public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldDisposeTemporaryStreamDuringException()
+ {
+ // Arrange
+ var (service, _, sourceStream) = CreateTestSetup(20_000);
+ var serviceSpy = new TemporaryStreamServiceSpy(service);
+ var cancellationToken = TestContext.Current.CancellationToken;
+
+ // Act
+ var act = () => serviceSpy.CopyToTemporaryStreamAsync(
+ sourceStream,
+ [new ErroneousPlugin()],
+ cancellationToken: cancellationToken
+ );
+
+ // Assert
+ (await act.Should().ThrowAsync())
+ .Which.Message.Should().Be("Intentional exception to test error handling");
+ serviceSpy.CapturedTemporaryStream.IsDisposed.Should().BeTrue();
+ }
+
+ private static (TemporaryStreamService service, byte[] sourceData, MemoryStream sourceStream) CreateTestSetup(
+ int dataSize,
+ bool useErroneousSourceStream = false
+ )
+ {
+ var service = CreateDefaultService();
+ var sourceData = CreateTestData(dataSize);
+ var sourceStream = useErroneousSourceStream ? new ErroneousMemoryStream() : new MemoryStream(sourceData);
+
+ return (service, sourceData, sourceStream);
+ }
+
+ private static async Task AssertTemporaryStreamContentsMatchAsync(
+ TemporaryStream temporaryStream,
+ byte[] expectedData,
+ bool expectFileBased,
+ CancellationToken cancellationToken
+ )
+ {
+ temporaryStream.Should().NotBeNull();
+ temporaryStream.IsFileBased.Should().Be(expectFileBased);
+ temporaryStream.Length.Should().Be(expectedData.Length);
+
+ temporaryStream.Position = 0;
+ var copiedData = new byte[expectedData.Length];
+ await temporaryStream.ReadExactlyAsync(copiedData, cancellationToken);
+ copiedData.Should().Equal(expectedData);
+ }
+
+ private static TemporaryStreamService CreateDefaultService() =>
+ new (
+ new TemporaryStreamServiceOptions(),
+ new TemporaryStreamErrorHandlerProvider(
+ (_, exception) => { TestContext.Current.TestOutputHelper?.WriteLine(exception.ToString()); }
+ )
+ );
+
+ private static byte[] CreateTestData(int size)
+ {
+ var data = new byte[size];
+ var random = new Random(42); // Fixed seed for reproducible tests
+ random.NextBytes(data);
+ return data;
+ }
+
+ private sealed class ErroneousPlugin : ICopyToTemporaryStreamPlugin
+ {
+ public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+
+ public ValueTask SetUpAsync(Stream innerStream, CancellationToken cancellationToken = default) =>
+ new (innerStream);
+
+ public ValueTask AfterCopyAsync(CancellationToken cancellationToken = default)
+ {
+ throw new XunitException("Intentional exception to test error handling");
+ }
+ }
+
+ private sealed class TemporaryStreamServiceSpy : ITemporaryStreamService
+ {
+ private readonly ITemporaryStreamService _wrappedService;
+ private TemporaryStream? _capturedTemporaryStream;
+
+ public TemporaryStreamServiceSpy(ITemporaryStreamService wrappedService) => _wrappedService = wrappedService;
+
+ public TemporaryStream CapturedTemporaryStream =>
+ _capturedTemporaryStream ?? throw new InvalidOperationException("No temporary stream captured");
+
+ public TemporaryStream CreateTemporaryStream(
+ long expectedLengthInBytes,
+ string? filePath = null,
+ TemporaryStreamServiceOptions? options = null
+ )
+ {
+ var temporaryStream = _wrappedService.CreateTemporaryStream(expectedLengthInBytes, filePath, options);
+ _capturedTemporaryStream = temporaryStream;
+ return temporaryStream;
+ }
+ }
+
+ private sealed class ErroneousMemoryStream : MemoryStream
+ {
+ public override Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken)
+ {
+ throw new XunitException("Intentional exception to test error handling");
+ }
+ }
+}
diff --git a/tests/Light.TemporaryStreams.Core.Tests/Hashing/CopyToHashCalculatorTests.cs b/tests/Light.TemporaryStreams.Core.Tests/Hashing/CopyToHashCalculatorTests.cs
new file mode 100644
index 0000000..9593db8
--- /dev/null
+++ b/tests/Light.TemporaryStreams.Core.Tests/Hashing/CopyToHashCalculatorTests.cs
@@ -0,0 +1,46 @@
+using System;
+using System.Security.Cryptography;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Xunit;
+
+namespace Light.TemporaryStreams.Hashing;
+
+public static class CopyToHashCalculatorTests
+{
+ [Fact]
+ public static async Task Hash_ShouldThrow_WhenObtainHashFromAlgorithmWasNotCalledBeforehand()
+ {
+ await using CopyToHashCalculator calculator = SHA1.Create();
+
+ // ReSharper disable once AccessToDisposedClosure -- delegate called before calculator is disposed of
+ var act = () => calculator.Hash;
+
+ act.Should().Throw()
+ .Which.Message.Should().Be("ObtainHashFromAlgorithm must be called before accessing the Hash property");
+ }
+
+ [Fact]
+ public static async Task HashArray_ShouldThrow_WhenObtainHashFromAlgorithmWasNotCalledBeforehand()
+ {
+ await using CopyToHashCalculator calculator = SHA256.Create();
+
+ // ReSharper disable once AccessToDisposedClosure -- delegate called before calculator is disposed of
+ var act = () => calculator.HashArray;
+
+ act.Should().Throw()
+ .Which.Message.Should().Be("ObtainHashFromAlgorithm must be called before accessing the HashArray property");
+ }
+
+ [Fact]
+ public static async Task ObtainHashFromAlgorithm_ShouldThrow_WhenNothingWasWrittenToUnderlyingCryptoStream()
+ {
+ await using CopyToHashCalculator calculator = SHA3_512.Create();
+
+ // ReSharper disable once AccessToDisposedClosure -- delegate called before calculator is disposed of
+ var act = () => calculator.ObtainHashFromAlgorithm();
+
+ act.Should().Throw()
+ .Which.Message.Should().Be("The crypto stream was not written to - no hash was calculated");
+ }
+}
diff --git a/tests/Light.TemporaryStreams.Core.Tests/Hashing/HashConverterTests.cs b/tests/Light.TemporaryStreams.Core.Tests/Hashing/HashConverterTests.cs
new file mode 100644
index 0000000..804dc94
--- /dev/null
+++ b/tests/Light.TemporaryStreams.Core.Tests/Hashing/HashConverterTests.cs
@@ -0,0 +1,72 @@
+using System;
+using FluentAssertions;
+using Xunit;
+
+namespace Light.TemporaryStreams.Hashing;
+
+public static class HashConverterTests
+{
+ [Fact]
+ public static void ConvertHashToString_Base64()
+ {
+ byte[] hash = [0x01, 0x02, 0x03, 0x04, 0x05];
+
+ var result = HashConverter.ConvertHashToString(hash, HashConversionMethod.Base64);
+
+ result.Should().Be("AQIDBAU=");
+ }
+
+ [Fact]
+ public static void ConvertHashToString_UpperHexadecimal()
+ {
+ byte[] hash = [0x01, 0x02, 0x03, 0x04, 0x05];
+
+ var result = HashConverter.ConvertHashToString(hash, HashConversionMethod.UpperHexadecimal);
+
+ result.Should().Be("0102030405");
+ }
+
+ [Fact]
+ public static void ConvertHashToString_None_ReturnsEmptyString()
+ {
+ byte[] hash = [0x01, 0x02, 0x03, 0x04, 0x05];
+
+ var result = HashConverter.ConvertHashToString(hash, HashConversionMethod.None);
+
+ result.Should().BeEmpty();
+ }
+
+ [Fact]
+ public static void ConvertHashToString_InvalidMethod_ThrowsArgumentOutOfRangeException()
+ {
+ byte[] hash = [0x01, 0x02, 0x03, 0x04, 0x05];
+ const HashConversionMethod method = (HashConversionMethod) 999;
+
+ Action act = () => HashConverter.ConvertHashToString(hash, method);
+
+ act.Should()
+ .Throw()
+ .Where(
+ x => x.ParamName == "conversionMethod" &&
+ x.Message == $"conversionMethod has an invalid value '{method}' (Parameter 'conversionMethod')"
+ );
+ }
+
+ [Fact]
+ public static void ConvertHashToString_EmptyArray_ReturnsCorrectResult()
+ {
+ var emptyHash = Array.Empty();
+
+ HashConverter.ConvertHashToString(emptyHash, HashConversionMethod.Base64).Should().BeEmpty();
+ HashConverter.ConvertHashToString(emptyHash, HashConversionMethod.UpperHexadecimal).Should().BeEmpty();
+ HashConverter.ConvertHashToString(emptyHash, HashConversionMethod.None).Should().BeEmpty();
+ }
+
+ [Fact]
+ public static void ConvertHashToString_NullArray_ThrowsArgumentNullException()
+ {
+ Action act = () => HashConverter.ConvertHashToString(null!, HashConversionMethod.Base64);
+
+ act.Should().Throw();
+ }
+}
diff --git a/tests/Light.TemporaryStreams.Core.Tests/Hashing/HashingPluginTests.cs b/tests/Light.TemporaryStreams.Core.Tests/Hashing/HashingPluginTests.cs
new file mode 100644
index 0000000..b7d620c
--- /dev/null
+++ b/tests/Light.TemporaryStreams.Core.Tests/Hashing/HashingPluginTests.cs
@@ -0,0 +1,51 @@
+using System;
+using System.Collections.Immutable;
+using System.Security.Cryptography;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Light.GuardClauses.Exceptions;
+using Xunit;
+
+namespace Light.TemporaryStreams.Hashing;
+
+public static class HashingPluginTests
+{
+ [Fact]
+ public static void Constructor_ThrowsEmptyCollectionException_WhenImmutableArrayIsEmpty()
+ {
+ var hashCalculators = ImmutableArray.Empty;
+
+ var act = () => new HashingPlugin(hashCalculators);
+
+ act.Should().Throw().Which.ParamName.Should().Be("hashCalculators");
+ }
+
+ [Fact]
+ public static void Constructor_ThrowsEmptyCollectionException_WhenImmutableArrayIsDefault()
+ {
+ var hashCalculators = default(ImmutableArray);
+
+ var act = () => new HashingPlugin(hashCalculators);
+
+ act.Should().Throw().Which.ParamName.Should().Be("hashCalculators");
+ }
+
+ [Fact]
+ public static async Task DisposeAsync_DoesNotDisposeCalculators_WhenDisposeCalculatorsIsFalse()
+ {
+ using var sha1 = SHA1.Create();
+ await using var hashingPlugin = new HashingPlugin([sha1], disposeCalculators: false);
+ }
+
+ [Fact]
+ public static async Task AfterCopyAsync_ShouldThrow_WhenSetUpAsyncWasNotCalledBeforehand()
+ {
+ await using var hashingPlugin = new HashingPlugin([SHA1.Create()]);
+
+ // ReSharper disable once AccessToDisposedClosure -- delegate called before hashingPlugin is disposed of
+ var act = () => hashingPlugin.AfterCopyAsync().AsTask();
+
+ (await act.Should().ThrowAsync())
+ .Which.Message.Should().Be("SetUpAsync must be called before AfterCopyAsync");
+ }
+}
diff --git a/tests/Light.TemporaryStreams.Core.Tests/TemporaryStreamServiceTests.cs b/tests/Light.TemporaryStreams.Core.Tests/TemporaryStreamServiceTests.cs
index f16af90..aa10afe 100644
--- a/tests/Light.TemporaryStreams.Core.Tests/TemporaryStreamServiceTests.cs
+++ b/tests/Light.TemporaryStreams.Core.Tests/TemporaryStreamServiceTests.cs
@@ -127,16 +127,9 @@ public static void CreateTemporaryStream_ShouldUseOverrideOptions_WhenOptionsAre
using var temporaryStream = service.CreateTemporaryStream(750, options: overrideOptions);
- try
- {
- temporaryStream.Should().NotBeNull();
- temporaryStream.IsFileBased.Should().BeTrue(); // 750 > 500 (override threshold)
- temporaryStream.DisposeBehavior.Should().Be(TemporaryStreamDisposeBehavior.CloseUnderlyingStreamOnly);
- }
- finally
- {
- File.Delete(temporaryStream.GetUnderlyingFilePath());
- }
+ temporaryStream.Should().NotBeNull();
+ temporaryStream.IsFileBased.Should().BeTrue(); // 750 > 500 (override threshold)
+ temporaryStream.DisposeBehavior.Should().Be(TemporaryStreamDisposeBehavior.CloseUnderlyingStreamOnly);
}
[Fact]