diff --git a/src/ImageSharp/Formats/Png/PngDecoderCore.cs b/src/ImageSharp/Formats/Png/PngDecoderCore.cs index 5e5225cdd0..c8870bcbf6 100644 --- a/src/ImageSharp/Formats/Png/PngDecoderCore.cs +++ b/src/ImageSharp/Formats/Png/PngDecoderCore.cs @@ -2485,7 +2485,17 @@ private IMemoryOwner ReadChunkData(int length) length = (int)Math.Min(length, this.currentStream.Length - this.currentStream.Position); IMemoryOwner buffer = this.configuration.MemoryAllocator.Allocate(length, AllocationOptions.Clean); - this.currentStream.Read(buffer.GetSpan(), 0, length); + try + { + this.currentStream.Read(buffer.GetSpan(), 0, length); + } + catch + { + // The read can throw before the caller takes ownership of the buffer (e.g. the stream + // observes a cancellation request), so dispose it here to avoid leaking the rented memory. + buffer.Dispose(); + throw; + } return buffer; } diff --git a/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.Cancellation.cs b/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.Cancellation.cs new file mode 100644 index 0000000000..75e5868661 --- /dev/null +++ b/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.Cancellation.cs @@ -0,0 +1,70 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using SixLabors.ImageSharp.Formats; +using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Tests.TestUtilities; + +namespace SixLabors.ImageSharp.Tests.Formats.Png; + +public partial class PngDecoderTests +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Decode_DisposesChunkDataBuffer_WhenChunkReadIsCancelled(bool identifyOnly) + { + byte[] pngBytes; + using (Image source = new(4, 4)) + using (MemoryStream encoded = new()) + { + source.SaveAsPng(encoded); + pngBytes = encoded.ToArray(); + } + + using CancellationTokenSource cts = new(); + using PausedMemoryStream stream = new(pngBytes); + + stream.OnWaiting(s => + { + // The signature occupies bytes 0-7 and the chunk length occupies bytes 8-11. + // Cancel during the type read, after BufferedReadStream has checked its token. + // That read completes; the next read checks cancellation after allocating chunk data. + if (s.Position == 12) + { + cts.Cancel(); + stream.Release(); + } + else + { + stream.Next(); + } + }); + + Configuration configuration = Configuration.CreateDefaultInstance(); + + // Read the chunk length and type separately so cancellation occurs during the type read. + configuration.StreamProcessingBufferSize = 4; + DecoderOptions options = new() { Configuration = configuration }; + + // Calling the decoder directly avoids cancellation during format detection. + if (identifyOnly) + { + await Assert.ThrowsAnyAsync(async () => + { + await PngDecoder.Instance.IdentifyAsync(options, stream, cts.Token); + }); + } + else + { + await Assert.ThrowsAnyAsync(async () => + { + using Image image = + await PngDecoder.Instance.DecodeAsync(options, stream, cts.Token); + }); + } + + Assert.True(cts.IsCancellationRequested); + } +}