diff --git a/docs/SEVENZIP_ALLOCATION_PROFILING.md b/docs/SEVENZIP_ALLOCATION_PROFILING.md new file mode 100644 index 00000000..440261a6 --- /dev/null +++ b/docs/SEVENZIP_ALLOCATION_PROFILING.md @@ -0,0 +1,132 @@ +# 7z writer allocation follow-up + +## Scope + +Compared Release libraries from `fa96277b` (PR #1476) with the allocation fixes +on 2026-10-26. The follow-up changes three sites: + +- `AesEncoderStream.WriteByte` writes into the existing pending CBC block rather + than inheriting `Stream.WriteByte`'s per-byte array allocation. The synchronous + LZMA range encoder emits every compressed byte through this API; encrypted + LZMA headers use it too. LZMA2 uses it for framing bytes, not every payload byte. +- The encoder's 4 KiB ciphertext scratch is pooled and returned once on disposal, + including after failed or canceled output. Crypto initialization precedes renting; + a failed rent disposes the transform. Encryption batches use the logical 4 KiB + bound even if a rent is oversized or not AES-block-aligned. The private pending + plaintext block is cleared on disposal; keys and retained coder properties are + not pooled. +- The 7z compression-type lookup uses an indexed list scan instead of predicate + LINQ, avoiding enumerator boxing on .NET 8. The public property still caches + its result, so this saving applies once per entry, not every property access. + +## Measurement + +The comparison harness runs against matching `net8.0` and `net10.0` libraries in +separate processes, with tiered compilation disabled, two warmups and five +measured operations per scenario. .NET 8 runs before/after; .NET 10 reverses the +order. Library SHA-256 hashes are checked against the actual loaded assemblies. +No builds or tests run alongside the measurements. + +Allocation deltas use process-wide `GC.GetTotalAllocatedBytes(true)` around the +operation, including construction and disposal. Inputs and compiled reflection +delegates are created outside measurement. Each sample includes the harness's +40-byte stopwatch allocation. Results are medians, not peak memory. Pool warmth +is intentional: a cold pool still allocates a scratch array on its first miss. + +Isolated workloads: + +- 1 MiB of `WriteByte` calls through an AES encoder to `Stream.Null`. +- 1,000 AES encoder construction/disposal cycles without writes. +- 100,000 uncached compression-type lookups against an AES+LZMA2 folder. A compiled + delegate calls the actual private method without reflection invocation costs or + result boxing. This isolates lookup cost rather than measuring archive parsing. + +End-to-end workloads cover all 24 combinations of LZMA/LZMA2, sync/async, +unencrypted/data-encrypted/data-and-header-encrypted, and a single 256 KiB random +file versus 100 files of 128 bytes. Data is seeded with `1476`; dictionaries are +4 KiB. Each operation creates and finalizes a writer, including key derivation, +metadata and a preallocated 1 MiB output stream. The first warmup additionally +reopens the archive and checks every entry byte-for-byte, outside measurement. +These memory-backed async operations can complete synchronously; they do not +represent suspended file I/O or establish file-backed throughput improvements. + +The repository's `SevenZipEncryptionBenchmarks` provides the same workload matrix +for ongoing BenchmarkDotNet runs, with filenames/options prebuilt in setup: + +```sh +dotnet run --project tests/SharpCompress.Performance -c Release -- --filter '*SevenZipEncryptionBenchmarks*' +``` + +The controlled harness (which also includes per-archive filename/options creation), +before/after libraries, loaded-library hashes, all raw samples, and runner are in: + +```text +/private/var/folders/6c/j56r6kqn5yj7q1_c6vm5c0t80000gn/T/opencode/sevenzip-allocations/ +``` + +`Profile.csproj` / `Program.cs` build the harness; `run.py` creates isolated run +directories, selects the saved libraries, runs the comparisons sequentially, and +checks the hashes. Results are `before-net8.0.json`, `after-net8.0.json`, +`before-net10.0.json`, and `after-net10.0.json`; `hashes.json` records library identity. + +## Results + +Runtime versions were .NET 8.0.17 and .NET 10.0.13 on macOS ARM64. +Allocated bytes per operation, including the 40-byte measurement overhead: + +| Isolated workload | .NET 8 before | .NET 8 after | .NET 10 before | .NET 10 after | +| --- | ---: | ---: | ---: | ---: | +| AES byte writes, 1 MiB | 33,559,288 | 536 | 33,559,352 | 584 | +| AES construction/disposal, 1,000 encoders | 4,608,040 | 496,040 | 4,656,264 | 544,040 | +| Uncached codec lookup, 100,000 calls | 4,000,040 | 40 | 40 | 40 | + +The byte-write fix eliminates the input-sized stream of tiny arrays. Pooling saves +approximately 4,112 bytes per encoder lifetime (the new pool-reference field adds +8 bytes to the stream object). The old lookup boxes a 40-byte list enumerator on +.NET 8; .NET 10 already has a span-based LINQ fast path, so its allocation total +is unchanged. No assertion here treats the harness's allocation floor as library +allocation. + +Representative complete writer allocations on .NET 10: + +| Workload | Before bytes | After bytes | +| --- | ---: | ---: | +| LZMA, sync, 256 KiB, encrypted data | 9,670,408 | 1,163,128 | +| LZMA, sync, 256 KiB, encrypted data and header | 9,675,272 | 1,160,456 | +| LZMA, sync, 100 small files, encrypted data and header | 7,538,176 | 6,575,344 | +| LZMA, async, 100 small files, encrypted data and header | 6,990,992 | 6,575,456 | +| LZMA2, sync, 100 small files, encrypted data and header | 7,149,632 | 6,645,168 | +| LZMA2, async, 100 small files, encrypted data and header | 7,060,592 | 6,645,056 | + +All 16 encrypted workload medians allocate less on both runtimes. The eight +unencrypted .NET 10 cases are byte-for-byte unchanged in allocation totals. Six +of the eight .NET 8 unencrypted cases are unchanged; the two async large-file +cases differ by +536 bytes, consistent with small process-wide pool/runtime noise, +not a newly introduced unencrypted buffer. All 24 workloads round-trip correctly +with both libraries on each runtime. + +These are allocation results, not a general throughput claim. Isolated .NET 10 +byte writes improve from 13.406 to 4.452 ms, but complete archive timings include +SHA-256 key derivation, compression and machine-state effects. For example, the +.NET 8 synchronous encrypted LZMA small-file case regressed from 40.430 to +82.145 ms in this run even while allocations fell. That adverse result is retained +in the raw logs; file-backed profiling and additional repeated timing runs would +be needed before claiming end-to-end speedups. Unencrypted synchronous LZMA's +existing byte-write costs in downstream wrappers are outside these three fixes. + +## Regression coverage + +New tests cover allocation bounds for byte writes, repeated encoder construction, +and uncached codec lookup; mixed byte/array/async writes against independent AES-CBC +output; aligned/unaligned/empty inputs; oversized poisoned leases; sync and async +disposal; early disposal without final writes; failed crypto initialization; +output errors/cancellation; caller-owned stream preservation; and rejection of +post-disposal writes/finalization. Existing encrypted writer round-trips now also +cover async LZMA with and without encrypted headers. + +All six library target frameworks and the benchmark project build without +warnings. The net48 test project compiles; net48 tests are not executed on macOS. +The complete .NET 10 Release test suite passes: **3,766 tests, no failures or skips**. +All 24 repository benchmark cases were also smoke-tested by calling their setup +and sync/async methods directly and checking that finalization produced archive +output. Those smoke checks are not timing measurements. diff --git a/src/SharpCompress/Common/SevenZip/SevenZipFilePart.cs b/src/SharpCompress/Common/SevenZip/SevenZipFilePart.cs index 2ab660e6..8f9af7ea 100644 --- a/src/SharpCompress/Common/SevenZip/SevenZipFilePart.cs +++ b/src/SharpCompress/Common/SevenZip/SevenZipFilePart.cs @@ -1,5 +1,4 @@ using System.IO; -using System.Linq; using System.Threading; using System.Threading.Tasks; using SharpCompress.IO; @@ -121,17 +120,25 @@ internal class SevenZipFilePart : FilePart // 7zAES is a filter in front of the codec (7-Zip writes it as coder 0), so it says // nothing about the compression; report the first real coder instead. - var coder = - Folder.NotNull()._coders.FirstOrDefault(c => c._methodId._id != CMethodId.K_AES_ID) - ?? throw new InvalidFormatException(); - return coder._methodId._id switch + var coders = Folder.NotNull()._coders; + // Predicate-based LINQ boxes List's enumerator on older supported runtimes. + for (var i = 0; i < coders.Count; i++) { - K_COPY => CompressionType.None, - K_LZMA or K_LZMA2 => CompressionType.LZMA, - K_PPMD => CompressionType.PPMd, - K_B_ZIP2 => CompressionType.BZip2, - _ => throw new InvalidFormatException(), - }; + var methodId = coders[i]._methodId._id; + if (methodId == CMethodId.K_AES_ID) + { + continue; + } + return methodId switch + { + K_COPY => CompressionType.None, + K_LZMA or K_LZMA2 => CompressionType.LZMA, + K_PPMD => CompressionType.PPMd, + K_B_ZIP2 => CompressionType.BZip2, + _ => throw new InvalidFormatException(), + }; + } + throw new InvalidFormatException(); } internal bool IsEncrypted => !Header.IsDir && Folder?.IsEncrypted() == true; diff --git a/src/SharpCompress/Compressors/LZMA/AesEncoderStream.cs b/src/SharpCompress/Compressors/LZMA/AesEncoderStream.cs index 5f381b42..8129d1d0 100644 --- a/src/SharpCompress/Compressors/LZMA/AesEncoderStream.cs +++ b/src/SharpCompress/Compressors/LZMA/AesEncoderStream.cs @@ -1,4 +1,5 @@ using System; +using System.Buffers; using System.IO; using System.Security.Cryptography; using System.Threading; @@ -16,22 +17,38 @@ namespace SharpCompress.Compressors.LZMA; internal sealed class AesEncoderStream : Stream { private const int BlockSize = 16; + private const int BufferSize = 4 << 10; private readonly Stream _output; private readonly ICryptoTransform _encryptor; private readonly byte[] _pending = new byte[BlockSize]; - private readonly byte[] _buffer = new byte[4 << 10]; + private readonly byte[] _buffer; + private readonly ArrayPool _pool; private int _pendingCount; private bool _finished; private bool _isDisposed; public AesEncoderStream(Stream output, byte[] key, byte[] iv) + : this(output, key, iv, ArrayPool.Shared) { } + + internal AesEncoderStream(Stream output, byte[] key, byte[] iv, ArrayPool pool) { _output = output; + _pool = pool; using var aes = Aes.Create(); aes.Mode = CipherMode.CBC; aes.Padding = PaddingMode.None; _encryptor = aes.CreateEncryptor(key, iv); + try + { + // Rent only after crypto setup succeeds; failed construction must not leak a lease. + _buffer = pool.Rent(BufferSize); + } + catch + { + _encryptor.Dispose(); + throw; + } } /// @@ -43,7 +60,7 @@ internal sealed class AesEncoderStream : Stream public override bool CanSeek => false; - public override bool CanWrite => true; + public override bool CanWrite => !_isDisposed && !_finished; public override long Length => InputLength; @@ -65,6 +82,21 @@ internal sealed class AesEncoderStream : Stream public override void SetLength(long value) => throw new NotSupportedException(); + public override void WriteByte(byte value) + { + ThrowIfFinished(); + InputLength++; + // The LZMA range encoder emits bytes individually. Stream's default implementation + // allocates a new array for each byte instead of using our pending CBC block. + _pending[_pendingCount++] = value; + if (_pendingCount == BlockSize) + { + _pendingCount = 0; + var encrypted = _encryptor.TransformBlock(_pending, 0, BlockSize, _buffer, 0); + _output.Write(_buffer, 0, encrypted); + } + } + public override void Write(byte[] buffer, int offset, int count) { ThrowIfFinished(); @@ -105,6 +137,7 @@ internal sealed class AesEncoderStream : Stream /// public void Finish() { + ThrowIfDisposed(); var encrypted = FinishBlock(); if (encrypted > 0) { @@ -117,6 +150,7 @@ internal sealed class AesEncoderStream : Stream /// public async ValueTask FinishAsync(CancellationToken cancellationToken) { + ThrowIfDisposed(); var encrypted = FinishBlock(); if (encrypted > 0) { @@ -128,13 +162,19 @@ internal sealed class AesEncoderStream : Stream protected override void Dispose(bool disposing) { - if (!_isDisposed) + if (disposing && !_isDisposed) { _isDisposed = true; - if (disposing) + try { _encryptor.Dispose(); } + finally + { + // The pooled scratch contains ciphertext only; pending plaintext stays private. + Array.Clear(_pending, 0, _pending.Length); + _pool.Return(_buffer); + } } base.Dispose(disposing); } @@ -163,7 +203,7 @@ internal sealed class AesEncoderStream : Stream return _encryptor.TransformBlock(_pending, 0, BlockSize, _buffer, 0); } - var whole = Math.Min(count & ~(BlockSize - 1), _buffer.Length); + var whole = Math.Min(count & ~(BlockSize - 1), BufferSize); var written = _encryptor.TransformBlock(buffer, offset, whole, _buffer, 0); offset += whole; count -= whole; @@ -190,6 +230,7 @@ internal sealed class AesEncoderStream : Stream private void ThrowIfFinished() { + ThrowIfDisposed(); if (_finished) { throw new ObjectDisposedException( @@ -198,4 +239,12 @@ internal sealed class AesEncoderStream : Stream ); } } + + private void ThrowIfDisposed() + { + if (_isDisposed) + { + throw new ObjectDisposedException(nameof(AesEncoderStream)); + } + } } diff --git a/tests/SharpCompress.Performance/Benchmarks/SevenZipEncryptionBenchmarks.cs b/tests/SharpCompress.Performance/Benchmarks/SevenZipEncryptionBenchmarks.cs new file mode 100644 index 00000000..18d7609e --- /dev/null +++ b/tests/SharpCompress.Performance/Benchmarks/SevenZipEncryptionBenchmarks.cs @@ -0,0 +1,79 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using BenchmarkDotNet.Attributes; +using SharpCompress.Common; +using SharpCompress.Compressors.LZMA; +using SharpCompress.Writers.SevenZip; + +namespace SharpCompress.Performance.Benchmarks; + +[MemoryDiagnoser] +public class SevenZipEncryptionBenchmarks +{ + private byte[] inputBytes = null!; + private string[] names = null!; + private SevenZipWriterOptions options = null!; + + [Params(CompressionType.LZMA, CompressionType.LZMA2)] + public CompressionType Codec { get; set; } + + [Params("None", "Data", "DataAndHeader")] + public string Encryption { get; set; } = null!; + + [Params(false, true)] + public bool ManySmallFiles { get; set; } + + [GlobalSetup] + public void Setup() + { + inputBytes = new byte[ManySmallFiles ? 128 : 262144]; + new Random(1476).NextBytes(inputBytes); + names = new string[ManySmallFiles ? 100 : 1]; + for (var i = 0; i < names.Length; i++) + { + names[i] = $"file{i}"; + } + options = new SevenZipWriterOptions(Codec) + { + Password = Encryption == "None" ? null : "profile", + EncryptHeader = Encryption == "DataAndHeader", + LeaveStreamOpen = true, + LzmaProperties = new LzmaEncoderProperties(true, 4096), + }; + } + + [Benchmark] + public long Write() + { + // Include archive construction/finalization, but avoid output-growth noise. + using var output = new MemoryStream(1048576); + using (var writer = new SevenZipWriter(output, options)) + { + foreach (var name in names) + { + using var input = new MemoryStream(inputBytes, writable: false); + writer.Write(name, input, null); + } + } + return output.Length; + } + + [Benchmark] + public async Task WriteAsync() + { + using var output = new MemoryStream(1048576); + await using (var writer = new SevenZipWriter(output, options)) + { + foreach (var name in names) + { + using var input = new MemoryStream(inputBytes, writable: false); + await writer + .WriteAsync(name, input, null, CancellationToken.None) + .ConfigureAwait(false); + } + } + return output.Length; + } +} diff --git a/tests/SharpCompress.Performance/README.md b/tests/SharpCompress.Performance/README.md index 0be79444..aa5a8d50 100644 --- a/tests/SharpCompress.Performance/README.md +++ b/tests/SharpCompress.Performance/README.md @@ -8,7 +8,7 @@ The benchmarks test all major archive formats supported by SharpCompress: - **Zip**: Read (Archive & Reader API) and Write operations, each with sync and async variants - **Tar**: Read (Archive & Reader API) and Write operations, including Tar.GZip, each with sync and async variants - **Rar**: Read operations (Archive & Reader API), each with sync and async variants -- **7Zip**: Read operations for LZMA and LZMA2 compression, each with sync and async variants +- **7Zip**: Read operations and encrypted/unencrypted writing for LZMA and LZMA2, each with sync and async variants - **GZip**: Compression and decompression, each with sync and async variants - **XZ / BCJ**: XZ decompression and isolated x86, ARM, ARM-Thumb, PowerPC, SPARC, and IA-64 filters, using sync and both async read overloads @@ -17,6 +17,11 @@ see [XZ profiling](../../docs/XZ_PROFILING.md). `XzBcjBenchmarks` uses built-in inputs by default; set `SHARPCOMPRESS_XZ_PROFILE_FILE` to profile a local `.xz` file for its XZ cases. Filter cases remain independent of that file. +`SevenZipEncryptionBenchmarks` covers a 256 KiB file and 100 small files, with +unencrypted data, encrypted data, and encrypted data plus headers. See +[7z allocation profiling](../../docs/SEVENZIP_ALLOCATION_PROFILING.md) for the +controlled before/after results and measurement boundaries. + ## Running Benchmarks ### Run all benchmarks diff --git a/tests/SharpCompress.Test/SevenZip/SevenZipCompressionLookupTests.cs b/tests/SharpCompress.Test/SevenZip/SevenZipCompressionLookupTests.cs new file mode 100644 index 00000000..aebf0fac --- /dev/null +++ b/tests/SharpCompress.Test/SevenZip/SevenZipCompressionLookupTests.cs @@ -0,0 +1,82 @@ +using System; +using System.IO; +using System.Reflection; +using SharpCompress.Common; +using SharpCompress.Common.SevenZip; +using SharpCompress.Compressors.LZMA.Utilities; +using Xunit; + +namespace SharpCompress.Test.SevenZip; + +public class SevenZipCompressionLookupTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ReportsCodecWithOrWithoutAes(bool encrypted) + { + var part = CreatePart(encrypted, true); + Assert.Equal(CompressionType.LZMA, part.CompressionType); + Assert.Equal(CompressionType.LZMA, part.CompressionType); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void MissingCodecRetainsInvalidFormatError(bool encrypted) => + Assert.Throws(() => CreatePart(encrypted, false).CompressionType); + +#if !LEGACY_DOTNET && !DEBUG + [Fact] + public void UncachedLookupDoesNotAllocate() + { + var part = CreatePart(true, true); + // Bind once to bypass the public property's cache without measuring reflection/boxing. + var lookup = + (Func) + Delegate.CreateDelegate( + typeof(Func), + part, + typeof(SevenZipFilePart).GetMethod( + "GetCompression", + BindingFlags.Instance | BindingFlags.NonPublic + )! + ); + _ = lookup(); + var start = GC.GetAllocatedBytesForCurrentThread(); + for (var i = 0; i < 10000; i++) + { + _ = lookup(); + } + Assert.InRange(GC.GetAllocatedBytesForCurrentThread() - start, 0, 1024); + } +#endif + + private static SevenZipFilePart CreatePart(bool encrypted, bool codec) + { + var folder = new CFolder(); + if (encrypted) + { + folder._coders.Add(new CCoderInfo { _methodId = CMethodId.K_AES }); + } + if (codec) + { + folder._coders.Add(new CCoderInfo { _methodId = CMethodId.K_LZMA2 }); + } + var database = new ArchiveDatabase(new PasswordProvider()); + database._folders.Add(folder); + database._fileIndexToFolderIndexMap.Add(0); + return new SevenZipFilePart( + Stream.Null, + database, + 0, + new CFileItem(), + new ArchiveEncoding() + ); + } + + private sealed class PasswordProvider : IPasswordProvider + { + public string? CryptoGetTextPassword() => null; + } +} diff --git a/tests/SharpCompress.Test/SevenZip/SevenZipWriterEncryptionTests.cs b/tests/SharpCompress.Test/SevenZip/SevenZipWriterEncryptionTests.cs index e6688ae6..a2076f04 100644 --- a/tests/SharpCompress.Test/SevenZip/SevenZipWriterEncryptionTests.cs +++ b/tests/SharpCompress.Test/SevenZip/SevenZipWriterEncryptionTests.cs @@ -31,6 +31,8 @@ public class SevenZipWriterEncryptionTests : TestBase [InlineData(CompressionType.LZMA, true, false)] [InlineData(CompressionType.LZMA2, false, true)] [InlineData(CompressionType.LZMA2, true, true)] + [InlineData(CompressionType.LZMA, false, true)] + [InlineData(CompressionType.LZMA, true, true)] public async ValueTask SevenZipWriter_Encrypted_RoundTrip( CompressionType compressionType, bool encryptHeader, diff --git a/tests/SharpCompress.Test/Streams/AesEncoderStreamTests.cs b/tests/SharpCompress.Test/Streams/AesEncoderStreamTests.cs new file mode 100644 index 00000000..7b984bce --- /dev/null +++ b/tests/SharpCompress.Test/Streams/AesEncoderStreamTests.cs @@ -0,0 +1,235 @@ +using System; +using System.Buffers; +using System.IO; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using SharpCompress.Compressors.LZMA; +using Xunit; + +namespace SharpCompress.Test.Streams; + +public class AesEncoderStreamTests +{ + private static readonly byte[] Key = new byte[32]; + private static readonly byte[] Iv = new byte[16]; + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(15)] + [InlineData(16)] + [InlineData(17)] + [InlineData(4095)] + [InlineData(4096)] + [InlineData(10003)] + public async Task MixedWritesMatchCbcAndReturnOversizedLease(int length) + { + var input = new byte[length]; + new Random(1476).NextBytes(input); + var padded = new byte[(length + 15) & ~15]; + input.CopyTo(padded, 0); + using var aes = Aes.Create(); + aes.Padding = PaddingMode.None; + using var transform = aes.CreateEncryptor(Key, Iv); + var expected = transform.TransformFinalBlock(padded, 0, padded.Length); + var pool = new CountingPool(); + using var output = new RecordingOutput(); + var encoder = new AesEncoderStream(output, Key, Iv, pool); + var offset = 0; + if (length > 0) + { + encoder.WriteByte(input[offset++]); + } + var count = Math.Min(13, length - offset); + encoder.Write(input, offset, count); + offset += count; + count = Math.Max(0, length - offset - 1); + await encoder.WriteAsync(input, offset, count, CancellationToken.None); + offset += count; + if (offset < length) + { + encoder.WriteByte(input[offset]); + } + Assert.Equal(length, encoder.InputLength); + await encoder.FinishAsync(CancellationToken.None); + encoder.Finish(); + Assert.Equal(expected, output.ToArray()); + Assert.InRange(output.MaxWrite, 0, 4096); + Assert.Equal(0, pool.Returns); +#if !LEGACY_DOTNET + await encoder.DisposeAsync(); +#else + encoder.Dispose(); +#endif + encoder.Dispose(); + Assert.Equal(1, pool.Returns); + Assert.True(output.CanWrite); + Assert.False(encoder.CanWrite); + Assert.Throws(() => encoder.WriteByte(0)); + Assert.Throws(() => encoder.Write(input, 0, 0)); + Assert.Throws(() => encoder.Finish()); + await Assert.ThrowsAsync(() => + encoder.WriteAsync(input, 0, 0, CancellationToken.None) + ); + await Assert.ThrowsAsync(async () => + await encoder.FinishAsync(CancellationToken.None) + ); + } + + [Fact] + public void ByteWritesMatchBulkWrites() + { + var input = new byte[10003]; + new Random(42).NextBytes(input); + using var bulk = new MemoryStream(); + using var bytes = new MemoryStream(); + using var first = new AesEncoderStream(bulk, Key, Iv); + using var second = new AesEncoderStream(bytes, Key, Iv); + first.Write(input, 0, input.Length); + foreach (var value in input) + { + second.WriteByte(value); + } + first.Finish(); + second.Finish(); + Assert.Equal(bulk.ToArray(), bytes.ToArray()); + Assert.Throws(() => second.WriteByte(0)); + } + + [Fact] + public void FailedCryptoSetupDoesNotRent() + { + var pool = new CountingPool(); + var exception = Record.Exception(() => + new AesEncoderStream(Stream.Null, new byte[1], Iv, pool) + ); + Assert.True( + exception is ArgumentException or System.Security.Cryptography.CryptographicException + ); + Assert.Equal(0, pool.Rents); + } + + [Fact] + public void EarlyDisposalReturnsLeaseWithoutWritingPendingData() + { + var pool = new CountingPool(); + using var output = new MemoryStream(); + using (var encoder = new AesEncoderStream(output, Key, Iv, pool)) + { + encoder.WriteByte(42); + } + Assert.Equal(0, output.Length); + Assert.Equal(1, pool.Returns); + Assert.True(output.CanWrite); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FailedOrCanceledOutputReturnsLeaseOnDisposal(bool canceled) + { + var pool = new CountingPool(); + using var output = new FailingOutput(); + var encoder = new AesEncoderStream(output, Key, Iv, pool); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + if (canceled) + { + await Assert.ThrowsAnyAsync(() => + encoder.WriteAsync(new byte[16], 0, 16, cancellation.Token) + ); + } + else + { + Assert.Throws(() => encoder.Write(new byte[16], 0, 16)); + } + encoder.Dispose(); + encoder.Dispose(); + Assert.Equal(1, pool.Returns); + Assert.True(output.CanWrite); + } + +#if !LEGACY_DOTNET && !DEBUG + [Fact] + public void ByteWritesDoNotAllocatePerByte() + { + using var encoder = new AesEncoderStream(Stream.Null, Key, Iv); + for (var i = 0; i < 1024; i++) + { + encoder.WriteByte((byte)i); + } + var start = GC.GetAllocatedBytesForCurrentThread(); + for (var i = 0; i < 1024 * 1024; i++) + { + encoder.WriteByte((byte)i); + } + encoder.Finish(); + Assert.InRange(GC.GetAllocatedBytesForCurrentThread() - start, 0, 65536); + } + + [Fact] + public void RepeatedConstructionReusesScratchBuffer() + { + using (var warmup = new AesEncoderStream(Stream.Null, Key, Iv)) { } + var start = GC.GetAllocatedBytesForCurrentThread(); + for (var i = 0; i < 100; i++) + { + using var encoder = new AesEncoderStream(Stream.Null, Key, Iv); + } + Assert.InRange(GC.GetAllocatedBytesForCurrentThread() - start, 0, 200000); + } +#endif + + private sealed class CountingPool : ArrayPool + { + private byte[]? lease; + public int Rents { get; private set; } + public int Returns { get; private set; } + + public override byte[] Rent(int minimumLength) + { + Rents++; + // Deliberately not block-aligned: capacity must not control encryption batches. + lease = new byte[minimumLength + 37]; + for (var i = 0; i < lease.Length; i++) + { + lease[i] = 0xCC; + } + return lease; + } + + public override void Return(byte[] array, bool clearArray = false) + { + Assert.Same(lease, array); + Returns++; + } + } + + private sealed class RecordingOutput : MemoryStream + { + public int MaxWrite { get; private set; } + + public override void Write(byte[] buffer, int offset, int count) + { + MaxWrite = Math.Max(MaxWrite, count); + base.Write(buffer, offset, count); + } + } + + private sealed class FailingOutput : MemoryStream + { + public override void Write(byte[] buffer, int offset, int count) => + throw new IOException("Injected output failure"); + + public override Task WriteAsync( + byte[] buffer, + int offset, + int count, + CancellationToken token + ) => + token.IsCancellationRequested + ? Task.FromCanceled(token) + : Task.FromException(new IOException("Injected output failure")); + } +}