diff --git a/src/SharpCompress/Compressors/BZip2/BZip2Stream.Async.cs b/src/SharpCompress/Compressors/BZip2/BZip2Stream.Async.cs
index 28b7b926..35cf9f1d 100644
--- a/src/SharpCompress/Compressors/BZip2/BZip2Stream.Async.cs
+++ b/src/SharpCompress/Compressors/BZip2/BZip2Stream.Async.cs
@@ -25,12 +25,22 @@ public sealed partial class BZip2Stream : IAsyncDisposable
/// The stream to read from
/// Compression Mode
/// Decompress Concatenated
+ /// Leave the underlying stream open when this stream is disposed
+ ///
+ /// Decompression only. When true, an end-of-stream reached at a bzip2 block boundary is treated as a
+ /// normal end of stream rather than throwing. This allows decoding a truncated or partial stream - for
+ /// example a sub-range of blocks extracted for random access - that has no trailing stream footer. EOF
+ /// in the middle of a block is still reported as an error. Because a partial decode's running combined
+ /// CRC won't match the whole-stream value stored in the footer, that whole-stream CRC is not verified
+ /// in this mode (per-block CRCs are still checked).
+ ///
/// Cancellation Token
public static async ValueTask CreateAsync(
Stream stream,
CompressionMode compressionMode,
bool decompressConcatenated,
bool leaveOpen = false,
+ bool tolerateTruncatedStream = false,
CancellationToken cancellationToken = default
)
{
@@ -43,7 +53,13 @@ public sealed partial class BZip2Stream : IAsyncDisposable
else
{
bZip2Stream.stream = await CBZip2InputStream
- .CreateAsync(stream, decompressConcatenated, leaveOpen, cancellationToken)
+ .CreateAsync(
+ stream,
+ decompressConcatenated,
+ leaveOpen,
+ tolerateTruncatedStream,
+ cancellationToken
+ )
.ConfigureAwait(false);
}
diff --git a/src/SharpCompress/Compressors/BZip2/BZip2Stream.cs b/src/SharpCompress/Compressors/BZip2/BZip2Stream.cs
index 5b9eeb43..dc0c175b 100644
--- a/src/SharpCompress/Compressors/BZip2/BZip2Stream.cs
+++ b/src/SharpCompress/Compressors/BZip2/BZip2Stream.cs
@@ -20,11 +20,21 @@ public sealed partial class BZip2Stream : Stream, IFinishable
/// The stream to read from
/// Compression Mode
/// Decompress Concatenated
+ /// Leave the underlying stream open when this stream is disposed
+ ///
+ /// Decompression only. When true, an end-of-stream reached at a bzip2 block boundary is treated as a
+ /// normal end of stream rather than throwing. This allows decoding a truncated or partial stream - for
+ /// example a sub-range of blocks extracted for random access - that has no trailing stream footer. EOF
+ /// in the middle of a block is still reported as an error. Because a partial decode's running combined
+ /// CRC won't match the whole-stream value stored in the footer, that whole-stream CRC is not verified
+ /// in this mode (per-block CRCs are still checked).
+ ///
public static BZip2Stream Create(
Stream stream,
CompressionMode compressionMode,
bool decompressConcatenated,
- bool leaveOpen = false
+ bool leaveOpen = false,
+ bool tolerateTruncatedStream = false
)
{
var bZip2Stream = new BZip2Stream();
@@ -38,7 +48,8 @@ public sealed partial class BZip2Stream : Stream, IFinishable
bZip2Stream.stream = CBZip2InputStream.Create(
stream,
decompressConcatenated,
- leaveOpen
+ leaveOpen,
+ tolerateTruncatedStream
);
}
diff --git a/src/SharpCompress/Compressors/BZip2/CBZip2InputStream.Async.cs b/src/SharpCompress/Compressors/BZip2/CBZip2InputStream.Async.cs
index 1780bfcd..c1053216 100644
--- a/src/SharpCompress/Compressors/BZip2/CBZip2InputStream.Async.cs
+++ b/src/SharpCompress/Compressors/BZip2/CBZip2InputStream.Async.cs
@@ -99,12 +99,25 @@ internal partial class CBZip2InputStream
while (true)
{
+ // A clean EOF is only acceptable here, at the start of a block/footer header.
+ expectingBlockStart = tolerateTruncatedStream;
magic1 = await BsGetUCharAsync(cancellationToken).ConfigureAwait(false);
magic2 = await BsGetUCharAsync(cancellationToken).ConfigureAwait(false);
magic3 = await BsGetUCharAsync(cancellationToken).ConfigureAwait(false);
magic4 = await BsGetUCharAsync(cancellationToken).ConfigureAwait(false);
magic5 = await BsGetUCharAsync(cancellationToken).ConfigureAwait(false);
magic6 = await BsGetUCharAsync(cancellationToken).ConfigureAwait(false);
+ expectingBlockStart = false;
+
+ if (hitEof)
+ {
+ // tolerateTruncatedStream: the input ended at a block boundary (no stream footer). Treat
+ // it as the end of the stream rather than throwing.
+ BsFinishedWithStream();
+ streamEnd = true;
+ return;
+ }
+
if (
magic1 != 0x17
|| magic2 != 0x72
@@ -158,7 +171,9 @@ internal partial class CBZip2InputStream
private async ValueTask CompleteAsync(CancellationToken cancellationToken)
{
storedCombinedCRC = await BsGetInt32Async(cancellationToken).ConfigureAwait(false);
- if (storedCombinedCRC != computedCombinedCRC)
+ // See Complete() in CBZip2InputStream.cs: the whole-stream combined CRC is not verified in
+ // tolerateTruncatedStream mode (a partial decode won't match); per-block CRCs still are.
+ if (!tolerateTruncatedStream && storedCombinedCRC != computedCombinedCRC)
{
CrcError();
}
@@ -877,7 +892,11 @@ internal partial class CBZip2InputStream
{
if (bsStream is null)
{
- CompressedStreamEOF();
+ HandleCompressedStreamEof();
+ if (hitEof)
+ {
+ return 0;
+ }
}
int zzi;
int thech = '\0';
@@ -889,7 +908,11 @@ internal partial class CBZip2InputStream
}
catch (IOException)
{
- CompressedStreamEOF();
+ HandleCompressedStreamEof();
+ if (hitEof)
+ {
+ return 0;
+ }
}
finally
{
@@ -897,7 +920,11 @@ internal partial class CBZip2InputStream
}
if (thech == '\uffff')
{
- CompressedStreamEOF();
+ HandleCompressedStreamEof();
+ if (hitEof)
+ {
+ return 0;
+ }
}
zzi = thech;
bsBuff = (bsBuff << 8) | (zzi & 0xff);
@@ -924,10 +951,15 @@ internal partial class CBZip2InputStream
Stream zStream,
bool decompressConcatenated,
bool leaveOpen = false,
+ bool tolerateTruncatedStream = false,
CancellationToken cancellationToken = default
)
{
- var cbZip2InputStream = new CBZip2InputStream(decompressConcatenated, leaveOpen);
+ var cbZip2InputStream = new CBZip2InputStream(
+ decompressConcatenated,
+ leaveOpen,
+ tolerateTruncatedStream
+ );
cbZip2InputStream.ll8 = null;
cbZip2InputStream.tt = null;
cbZip2InputStream.BsSetStream(zStream);
diff --git a/src/SharpCompress/Compressors/BZip2/CBZip2InputStream.cs b/src/SharpCompress/Compressors/BZip2/CBZip2InputStream.cs
index b15bb7f5..bcc0c73a 100644
--- a/src/SharpCompress/Compressors/BZip2/CBZip2InputStream.cs
+++ b/src/SharpCompress/Compressors/BZip2/CBZip2InputStream.cs
@@ -58,6 +58,19 @@ internal partial class CBZip2InputStream : Stream
throw new ArchiveOperationException("BZip2 compressed file ends unexpectedly");
}
+ // Handles the underlying stream running out while BsR needs more bits. In tolerateTruncatedStream
+ // mode an EOF that lands on a block boundary (expectingBlockStart) ends the stream cleanly; anywhere
+ // else - or when not tolerating truncation - it is an unexpected truncation and throws.
+ private void HandleCompressedStreamEof()
+ {
+ if (tolerateTruncatedStream && expectingBlockStart)
+ {
+ hitEof = true;
+ return;
+ }
+ CompressedStreamEOF();
+ }
+
private void MakeMaps()
{
int i;
@@ -151,6 +164,18 @@ internal partial class CBZip2InputStream : Stream
private readonly bool decompressConcatenated;
private readonly bool leaveOpen;
+ // When true, an end-of-stream reached at a bzip2 block boundary (i.e. while reading a block header)
+ // is treated as a normal end of stream rather than throwing. Lets a caller decode a truncated or
+ // partial stream - e.g. a sub-range of blocks extracted for random access - that has no stream footer.
+ private readonly bool tolerateTruncatedStream;
+
+ // true only while reading the 6 bytes of a block/stream-footer header, where a clean EOF is allowed
+ // (in tolerateTruncatedStream mode). EOF anywhere else is still an unexpected truncation.
+ private bool expectingBlockStart;
+
+ // set by BsR when a tolerated end-of-stream is hit; checked by InitBlock to end the stream.
+ private bool hitEof;
+
private int i2,
count,
chPrev,
@@ -163,19 +188,29 @@ internal partial class CBZip2InputStream : Stream
private char z;
private bool isDisposed;
- private CBZip2InputStream(bool decompressConcatenated, bool leaveOpen)
+ private CBZip2InputStream(
+ bool decompressConcatenated,
+ bool leaveOpen,
+ bool tolerateTruncatedStream
+ )
{
this.decompressConcatenated = decompressConcatenated;
this.leaveOpen = leaveOpen;
+ this.tolerateTruncatedStream = tolerateTruncatedStream;
}
public static CBZip2InputStream Create(
Stream zStream,
bool decompressConcatenated,
- bool leaveOpen
+ bool leaveOpen,
+ bool tolerateTruncatedStream = false
)
{
- var cbZip2InputStream = new CBZip2InputStream(decompressConcatenated, leaveOpen);
+ var cbZip2InputStream = new CBZip2InputStream(
+ decompressConcatenated,
+ leaveOpen,
+ tolerateTruncatedStream
+ );
cbZip2InputStream.ll8 = null;
cbZip2InputStream.tt = null;
cbZip2InputStream.BsSetStream(zStream);
@@ -290,12 +325,25 @@ internal partial class CBZip2InputStream : Stream
while (true)
{
+ // A clean EOF is only acceptable here, at the start of a block/footer header.
+ expectingBlockStart = tolerateTruncatedStream;
magic1 = BsGetUChar();
magic2 = BsGetUChar();
magic3 = BsGetUChar();
magic4 = BsGetUChar();
magic5 = BsGetUChar();
magic6 = BsGetUChar();
+ expectingBlockStart = false;
+
+ if (hitEof)
+ {
+ // tolerateTruncatedStream: the input ended at a block boundary (no stream footer). Treat
+ // it as the end of the stream rather than throwing.
+ BsFinishedWithStream();
+ streamEnd = true;
+ return;
+ }
+
if (
magic1 != 0x17
|| magic2 != 0x72
@@ -362,7 +410,11 @@ internal partial class CBZip2InputStream : Stream
private bool Complete()
{
storedCombinedCRC = BsGetInt32();
- if (storedCombinedCRC != computedCombinedCRC)
+ // In tolerateTruncatedStream mode the input may be only part of a stream (e.g. a sub-range of
+ // blocks decoded for random access), so the running combined CRC won't match the stored
+ // whole-stream value in the footer. Per-block CRCs are still validated; only this whole-stream
+ // check is skipped.
+ if (!tolerateTruncatedStream && storedCombinedCRC != computedCombinedCRC)
{
CrcError();
}
@@ -408,7 +460,11 @@ internal partial class CBZip2InputStream : Stream
{
if (bsStream is null)
{
- CompressedStreamEOF();
+ HandleCompressedStreamEof();
+ if (hitEof)
+ {
+ return 0;
+ }
}
int zzi;
int thech = '\0';
@@ -418,11 +474,19 @@ internal partial class CBZip2InputStream : Stream
}
catch (IOException)
{
- CompressedStreamEOF();
+ HandleCompressedStreamEof();
+ if (hitEof)
+ {
+ return 0;
+ }
}
if (thech == '\uffff')
{
- CompressedStreamEOF();
+ HandleCompressedStreamEof();
+ if (hitEof)
+ {
+ return 0;
+ }
}
zzi = thech;
bsBuff = (bsBuff << 8) | (zzi & 0xff);
diff --git a/src/SharpCompress/Providers/Default/BZip2CompressionProvider.cs b/src/SharpCompress/Providers/Default/BZip2CompressionProvider.cs
index 8ff07fb7..29062c03 100644
--- a/src/SharpCompress/Providers/Default/BZip2CompressionProvider.cs
+++ b/src/SharpCompress/Providers/Default/BZip2CompressionProvider.cs
@@ -30,7 +30,13 @@ public sealed class BZip2CompressionProvider : CompressionProviderBase
{
// BZip2 doesn't use compressionLevel parameter in this implementation
return await BZip2Stream
- .CreateAsync(destination, CompressionMode.Compress, false, false, cancellationToken)
+ .CreateAsync(
+ destination,
+ CompressionMode.Compress,
+ false,
+ false,
+ cancellationToken: cancellationToken
+ )
.ConfigureAwait(false);
}
@@ -45,7 +51,13 @@ public sealed class BZip2CompressionProvider : CompressionProviderBase
)
{
return await BZip2Stream
- .CreateAsync(source, CompressionMode.Decompress, false, false, cancellationToken)
+ .CreateAsync(
+ source,
+ CompressionMode.Decompress,
+ false,
+ false,
+ cancellationToken: cancellationToken
+ )
.ConfigureAwait(false);
}
}
diff --git a/tests/SharpCompress.Test/BZip2/BZip2StreamTests.cs b/tests/SharpCompress.Test/BZip2/BZip2StreamTests.cs
index 9bd7f44a..dc603878 100644
--- a/tests/SharpCompress.Test/BZip2/BZip2StreamTests.cs
+++ b/tests/SharpCompress.Test/BZip2/BZip2StreamTests.cs
@@ -1,6 +1,8 @@
+using System;
using System.IO;
using System.Text;
using SharpCompress.Common;
+using SharpCompress.Compressors;
using SharpCompress.Compressors.BZip2;
using Xunit;
@@ -24,6 +26,98 @@ public class BZip2StreamTests
Assert.Throws(() => stream.CopyTo(output));
}
+ // A stream that ends exactly where a block header is expected (no stream footer) is the shape a caller
+ // sees when decoding a truncated stream or a sub-range of blocks extracted for random access. "BZh9" is
+ // a valid header with no blocks and no footer, so the very first block-header read hits end-of-input.
+ [Fact]
+ public void BZip2Stream_TolerateTruncatedStream_DecodesFooterlessStreamAsEmpty()
+ {
+ var headerOnly = Encoding.ASCII.GetBytes("BZh9");
+
+ Assert.Throws(() =>
+ Decompress(headerOnly, tolerateTruncatedStream: false)
+ );
+
+ Assert.Empty(Decompress(headerOnly, tolerateTruncatedStream: true));
+ }
+
+ // A real block followed by end-of-input at the next block boundary: decode a complete stream, then
+ // append another header that stops before its first block. With tolerance the first stream's data comes
+ // back and the truncated continuation ends cleanly; without it, the end-of-input throws.
+ [Fact]
+ public void BZip2Stream_TolerateTruncatedStream_DecodesStreamTruncatedAtBlockBoundary()
+ {
+ const string text = "Some data that bzip2 will put into a single block.";
+ var truncated = Concat(Compress(text), Encoding.ASCII.GetBytes("BZh9"));
+
+ Assert.Throws(() =>
+ Decompress(truncated, tolerateTruncatedStream: false, decompressConcatenated: true)
+ );
+
+ var result = Decompress(
+ truncated,
+ tolerateTruncatedStream: true,
+ decompressConcatenated: true
+ );
+ Assert.Equal(text, Encoding.ASCII.GetString(result));
+ }
+
+ // A partial decode's running combined CRC won't match the whole-stream value in the footer, so the
+ // flag skips that whole-stream check (per-block CRCs are still enforced). Corrupting the stored
+ // combined CRC is fatal by default but tolerated with the flag.
+ [Fact]
+ public void BZip2Stream_TolerateTruncatedStream_SkipsWholeStreamCrc()
+ {
+ const string text = "BZip2 combined-CRC validation test data.";
+ var compressed = Compress(text);
+ compressed[^5] ^= 1;
+
+ Assert.Throws(() =>
+ Decompress(compressed, tolerateTruncatedStream: false)
+ );
+
+ var result = Decompress(compressed, tolerateTruncatedStream: true);
+ Assert.Equal(text, Encoding.ASCII.GetString(result));
+ }
+
+ // The flag must not change decoding of a normal, well-formed stream.
+ [Fact]
+ public void BZip2Stream_TolerateTruncatedStream_StillDecodesCompleteStream()
+ {
+ const string text =
+ "Round trip with tolerateTruncatedStream set on a complete, valid stream.";
+
+ var result = Decompress(Compress(text), tolerateTruncatedStream: true);
+
+ Assert.Equal(text, Encoding.ASCII.GetString(result));
+ }
+
+ private static byte[] Decompress(
+ byte[] compressed,
+ bool tolerateTruncatedStream,
+ bool decompressConcatenated = false
+ )
+ {
+ using var stream = BZip2Stream.Create(
+ new MemoryStream(compressed),
+ CompressionMode.Decompress,
+ decompressConcatenated,
+ leaveOpen: false,
+ tolerateTruncatedStream: tolerateTruncatedStream
+ );
+ using var output = new MemoryStream();
+ stream.CopyTo(output);
+ return output.ToArray();
+ }
+
+ private static byte[] Concat(byte[] a, byte[] b)
+ {
+ var result = new byte[a.Length + b.Length];
+ Buffer.BlockCopy(a, 0, result, 0, a.Length);
+ Buffer.BlockCopy(b, 0, result, a.Length, b.Length);
+ return result;
+ }
+
private static byte[] Compress(string value)
{
using var memoryStream = new MemoryStream();