Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,7 @@ dotnet_naming_style.pascal_case.word_separator =
dotnet_naming_style.pascal_case.capitalization = pascal_case

# C++ files
[*.cpp,*.h,*.hpp,*.cc,*.hh,*.cxx,*.hxx]
[*.{cpp,h,hpp,cc,hh,cxx,hxx}]
indent_style = tab

# Naming convention rules (note: currently need to be ordered from more to less specific)
Expand Down
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ All provider interfaces follow a consistent three-tier pattern:

1. **Core Try\* methods**: Buffer-based methods over `Span<byte>` or `Stream`. Span overloads are `bool TryX(source, destination, out int bytesWritten)`, paired with a `GetMax…Length` bound per category so callers can size buffers. These are the only methods implementers must provide.
2. **Convenience methods**: Self-allocating methods that call Try\* methods and manage buffers automatically. Provided via default interface implementations.
3. **Async variants**: Task-based async versions with `CancellationToken` support. The stream paths of the compression providers and of `AesEncryptionProvider`, along with `IHashProvider.TryHashAsync(Stream, ...)` and `IKeyedHashProvider.TryHashAsync(ReadOnlyMemory<byte>, Stream, ...)`, are genuinely asynchronous — real `ReadAsync`/`WriteAsync`, no thread held. The rest are still `Task.Run` wrappers over synchronous work via `ProviderHelpers.RunAsync()`; see issue #8. A provider makes its stream paths genuine by declaring the two `Try…Async(Stream, Stream, ...)` primitives itself, which replaces the default implementation; the four derived stream defaults compose over those primitives, so overriding two members converts all six. Span-destination async overloads do not exist — an `out` parameter cannot cross an async boundary.
3. **Async variants**: Task-based async versions with `CancellationToken` support. The stream paths of the compression providers, of `AesEncryptionProvider` and of both encoding providers, along with `IHashProvider.TryHashAsync(Stream, ...)` and `IKeyedHashProvider.TryHashAsync(ReadOnlyMemory<byte>, Stream, ...)`, are genuinely asynchronous — real `ReadAsync`/`WriteAsync`, no thread held. The rest — obfuscation, serialization, and everything operating on buffers already in memory — are still `Task.Run` wrappers over synchronous work via `ProviderHelpers.RunAsync()`; see issue #8.

The two encoding providers each mirror their own synchronous memory profile rather than sharing one shape. `HexEncodingProvider` transforms a chunk at a time, because hex maps one byte to two and a chunk boundary never splits a pair; its decoder tops each read up to an even length first, since a `ReadAsync` may legally return fewer bytes than asked for and treating that as end-of-stream would truncate. `Base64EncodingProvider` buffers the whole input, because Base64 maps three bytes to four and a chunked transform would have to carry a partial group across every boundary — which is what its synchronous path does too. A provider makes its stream paths genuine by declaring the two `Try…Async(Stream, Stream, ...)` primitives itself, which replaces the default implementation; the four derived stream defaults compose over those primitives, so overriding two members converts all six. Span-destination async overloads do not exist — an `out` parameter cannot cross an async boundary.

`ICommandExecutor` is the one interface with the mirror-image concern: synchronous methods layered over an
asynchronous one. It declares a synchronous primitive, `Execute(string, IReadOnlyDictionary<string, string>?,
Expand Down
97 changes: 97 additions & 0 deletions Essentials.EncodingProviders.Base64/Base64EncodingProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ namespace ktsu.Essentials.EncodingProviders.Base64;
using System;
using System.Buffers;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using SysBase64 = System.Buffers.Text.Base64;

/// <summary>
Expand Down Expand Up @@ -126,4 +128,99 @@ public bool TryDecode(Stream encodedData, Stream destination)
return false;
}
}

/// <inheritdoc/>
/// <remarks>
/// Genuinely asynchronous: the source is read and the result written with <c>ReadAsync</c> and
/// <c>WriteAsync</c>, so no thread is held for the duration of the I/O. Declaring this member and
/// its decoding counterpart replaces the interface's <c>Task.Run</c> defaults and converts every
/// stream path derived from them.
/// <para>
/// The whole input is buffered before the transform, which is what the synchronous path does too:
/// Base64 encodes three bytes to four, so a chunked transform would have to carry a partial group
/// across every boundary. The transform itself is CPU work on a buffer already in memory and is
/// deliberately not offloaded — that is the caller's decision to make, not this provider's.
/// </para>
/// </remarks>
public async Task<bool> TryEncodeAsync(Stream data, Stream destination, CancellationToken cancellationToken = default)
{
if (data is null || destination is null)
{
return false;
}

try
{
byte[] source = await ReadAllAsync(data, cancellationToken).ConfigureAwait(false);
byte[] encoded = new byte[GetMaxEncodedLength(source.Length)];

if (!TryEncode(source, encoded, out int bytesWritten))
{
return false;
}

await destination.WriteAsync(encoded.AsMemory(0, bytesWritten), cancellationToken).ConfigureAwait(false);
return true;
}
catch (IOException)
{
return false;
}
catch (ObjectDisposedException)
{
return false;
}
}

/// <inheritdoc/>
/// <remarks>
/// Genuinely asynchronous, on the same terms as <see cref="TryEncodeAsync(Stream, Stream, CancellationToken)"/>.
/// </remarks>
public async Task<bool> TryDecodeAsync(Stream encodedData, Stream destination, CancellationToken cancellationToken = default)
{
if (encodedData is null || destination is null)
{
return false;
}

try
{
byte[] source = await ReadAllAsync(encodedData, cancellationToken).ConfigureAwait(false);
byte[] decoded = new byte[GetMaxDecodedLength(source.Length)];

if (!TryDecode(source, decoded, out int bytesWritten))
{
return false;
}

await destination.WriteAsync(decoded.AsMemory(0, bytesWritten), cancellationToken).ConfigureAwait(false);
return true;
}
catch (IOException)
{
return false;
}
catch (ObjectDisposedException)
{
return false;
}
}

/// <summary>
/// Reads a stream to its end without holding a thread.
/// </summary>
/// <param name="source">The stream to read.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Everything the stream had left.</returns>
private static async Task<byte[]> ReadAllAsync(Stream source, CancellationToken cancellationToken)
{
using MemoryStream buffer = new();
await source.CopyToAsync(buffer, CopyBufferSize, cancellationToken).ConfigureAwait(false);
return buffer.ToArray();
}

/// <summary>
/// The chunk size used when reading a source stream, matching the framework's own default.
/// </summary>
private const int CopyBufferSize = 81920;
}
147 changes: 147 additions & 0 deletions Essentials.EncodingProviders.Hex/HexEncodingProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ namespace ktsu.Essentials.EncodingProviders.Hex;
using ktsu.Essentials;
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;

/// <summary>
/// An encoding provider that uses hexadecimal encoding for data encoding and decoding.
Expand Down Expand Up @@ -148,4 +150,149 @@ private static bool TryParseNibble(byte character, out int value)

return value >= 0;
}

/// <inheritdoc/>
/// <remarks>
/// Genuinely asynchronous: the source is read and the result written with <c>ReadAsync</c> and
/// <c>WriteAsync</c>, so no thread is held for the duration of the I/O. Declaring this member and
/// its decoding counterpart replaces the interface's <c>Task.Run</c> defaults and converts every
/// stream path derived from them.
/// <para>
/// It transforms a chunk at a time rather than buffering the whole stream, which keeps the
/// synchronous path's constant memory use. Hex encodes one byte to two, so a chunk boundary never
/// splits a group and no state has to be carried across one.
/// </para>
/// </remarks>
public async Task<bool> TryEncodeAsync(Stream data, Stream destination, CancellationToken cancellationToken = default)
{
if (data is null || destination is null)
{
return false;
}

byte[] source = new byte[ChunkSize];
byte[] encoded = new byte[ChunkSize * 2];

try
{
int read;
while ((read = await data.ReadAsync(source.AsMemory(0, ChunkSize), cancellationToken).ConfigureAwait(false)) > 0)
{
for (int i = 0; i < read; i++)
{
encoded[i * 2] = (byte)HexDigits[source[i] >> 4];
encoded[(i * 2) + 1] = (byte)HexDigits[source[i] & 0x0F];
}

await destination.WriteAsync(encoded.AsMemory(0, read * 2), cancellationToken).ConfigureAwait(false);
}

return true;
}
catch (IOException)
{
return false;
}
catch (ObjectDisposedException)
{
return false;
}
}

/// <inheritdoc/>
/// <remarks>
/// Genuinely asynchronous, and chunked, on the same terms as
/// <see cref="TryEncodeAsync(Stream, Stream, CancellationToken)"/>.
/// <para>
/// Decoding reads two characters per byte, so a chunk can end mid-pair. A read is therefore
/// topped up until it holds an even number of characters, or the stream ends — in which case a
/// leftover character means the input was truncated, and this reports failure exactly as the
/// synchronous path does.
/// </para>
/// </remarks>
public async Task<bool> TryDecodeAsync(Stream encodedData, Stream destination, CancellationToken cancellationToken = default)
{
if (encodedData is null || destination is null)
{
return false;
}

byte[] source = new byte[ChunkSize];
byte[] decoded = new byte[ChunkSize / 2];

try
{
while (true)
{
int filled = await ReadPairsAsync(encodedData, source, cancellationToken).ConfigureAwait(false);
if (filled == 0)
{
return true;
}

if (filled < 0)
{
// An odd number of characters: the last byte has no partner, so the input is truncated.
return false;
}

for (int i = 0; i < filled; i += 2)
{
if (!TryParseNibble(source[i], out int high) || !TryParseNibble(source[i + 1], out int low))
{
return false;
}

decoded[i / 2] = (byte)((high << 4) | low);
}

await destination.WriteAsync(decoded.AsMemory(0, filled / 2), cancellationToken).ConfigureAwait(false);
}
}
catch (IOException)
{
return false;
}
catch (ObjectDisposedException)
{
return false;
}
}

/// <summary>
/// Fills a buffer with an even number of characters, so no character pair straddles a chunk.
/// </summary>
/// <param name="source">The stream to read.</param>
/// <param name="buffer">The buffer to fill.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>
/// How many characters were read, zero at a clean end of stream, or -1 if the stream ended on an
/// unpaired character.
/// </returns>
/// <remarks>
/// A single <c>ReadAsync</c> may return fewer bytes than asked for at any time — that is the
/// stream contract, not an end-of-stream signal — so this keeps reading until the buffer is full
/// or the stream really has ended.
/// </remarks>
private static async Task<int> ReadPairsAsync(Stream source, byte[] buffer, CancellationToken cancellationToken)
{
int filled = 0;
while (filled < buffer.Length)
{
int read = await source.ReadAsync(buffer.AsMemory(filled, buffer.Length - filled), cancellationToken).ConfigureAwait(false);
if (read == 0)
{
break;
}

filled += read;
}

return filled % 2 == 0 ? filled : -1;
}

/// <summary>
/// The number of characters transformed per chunk. Even, so a pair is never split.
/// </summary>
private const int ChunkSize = 8192;
}
Loading