Merge pull request #1445 from adamhathcock/fix/1439-sevenzip-password-exceptions

Expose public exceptions for encrypted 7z decoding failures
This commit is contained in:
Adam Hathcock
2026-10-13 08:11:19 +01:00
committed by GitHub
8 changed files with 311 additions and 21 deletions

View File

@@ -837,6 +837,16 @@ using (var reader = ReaderFactory.OpenReader(stream))
- [USAGE.md](USAGE.md) - Complete code examples
- [FORMATS.md](FORMATS.md) - Supported formats
- [PERFORMANCE.md](PERFORMANCE.md) - API selection guide
## 7z Encrypted Decoding Errors
AES-encrypted 7z codec data errors and encrypted-header CRC failures throw `SharpCompress.Common.CryptographicException`,
with the original decoder error in `InnerException`. The exception can indicate either an
incorrect password or corrupt encrypted data; 7z AES has no independent password verifier.
`CryptographicException(string message, Exception inner)` preserves an underlying failure.
Unencrypted codec errors retain their existing behavior. This does not guarantee detection
of every incorrect password, particularly for data without compression or integrity checking.
## 7z Encryption Detection
For 7z archives, `IsEncrypted` is true when any entry has an AES-encrypted data stream.

View File

@@ -231,9 +231,12 @@ internal sealed partial class ArchiveReader
!= folder._unpackCrc
)
{
throw new InvalidFormatException(
var exception = new InvalidFormatException(
"Decoded stream does not match expected CRC."
);
throw folder.IsEncrypted()
? EncryptedDecoderStream.DecodingException(exception)
: exception;
}
}
}

View File

@@ -674,9 +674,12 @@ internal partial class ArchiveReader
!= folder._unpackCrc
)
{
throw new InvalidFormatException(
var exception = new InvalidFormatException(
"Decoded stream does not match expected CRC."
);
throw folder.IsEncrypted()
? EncryptedDecoderStream.DecodingException(exception)
: exception;
}
}
}

View File

@@ -28,7 +28,14 @@ public class ArchiveOperationException : SharpCompressException
public class IncompleteArchiveException(string message) : ArchiveException(message);
public class CryptographicException(string message) : SharpCompressException(message);
public class CryptographicException : SharpCompressException
{
public CryptographicException(string message)
: base(message) { }
public CryptographicException(string message, Exception inner)
: base(message, inner) { }
}
public class ReaderCancelledException(string message) : SharpCompressException(message);

View File

@@ -303,14 +303,23 @@ internal static class DecoderStreamHelper
out var primaryCoderIndex,
out var primaryOutStreamIndex
);
return CreateDecoderStream(
inStreams,
packSizes,
outStreams,
folderInfo,
primaryCoderIndex,
pass
);
var encrypted = folderInfo.IsEncrypted();
try
{
var decoder = CreateDecoderStream(
inStreams,
packSizes,
outStreams,
folderInfo,
primaryCoderIndex,
pass
);
return encrypted ? new EncryptedDecoderStream(decoder) : decoder;
}
catch (DataErrorException ex) when (encrypted)
{
throw EncryptedDecoderStream.DecodingException(ex);
}
}
internal static async ValueTask<Stream> CreateDecoderStreamAsync(
@@ -341,15 +350,24 @@ internal static class DecoderStreamHelper
out var primaryCoderIndex,
out var primaryOutStreamIndex
);
return await CreateDecoderStreamAsync(
inStreams,
packSizes,
outStreams,
folderInfo,
primaryCoderIndex,
pass,
cancellationToken
)
.ConfigureAwait(false);
var encrypted = folderInfo.IsEncrypted();
try
{
var decoder = await CreateDecoderStreamAsync(
inStreams,
packSizes,
outStreams,
folderInfo,
primaryCoderIndex,
pass,
cancellationToken
)
.ConfigureAwait(false);
return encrypted ? new EncryptedDecoderStream(decoder) : decoder;
}
catch (DataErrorException ex) when (encrypted)
{
throw EncryptedDecoderStream.DecodingException(ex);
}
}
}

View File

@@ -0,0 +1,58 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using SharpCompress.Common;
namespace SharpCompress.Compressors.LZMA;
// AES has no password verifier. Decoder errors can mean either a wrong password
// or damaged ciphertext; retain the codec error without claiming certainty.
internal sealed class EncryptedDecoderStream(Stream decoder) : DecoderStream2
{
internal static CryptographicException DecodingException(Exception exception) =>
new("Incorrect password or corrupt encrypted 7z data.", exception);
public override long Length => decoder.Length;
public override long Position => decoder.Position;
public override int Read(byte[] buffer, int offset, int count)
{
try
{
return decoder.Read(buffer, offset, count);
}
catch (DataErrorException ex)
{
throw DecodingException(ex);
}
}
public override async Task<int> ReadAsync(
byte[] buffer,
int offset,
int count,
CancellationToken cancellationToken
)
{
try
{
return await decoder
.ReadAsync(buffer, offset, count, cancellationToken)
.ConfigureAwait(false);
}
catch (DataErrorException ex)
{
throw DecodingException(ex);
}
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
decoder.Dispose();
}
base.Dispose(disposing);
}
}

View File

@@ -0,0 +1,78 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using SharpCompress.Common;
using SharpCompress.Compressors.LZMA;
using Xunit;
namespace SharpCompress.Test.SevenZip;
public class EncryptedDecoderStreamTests
{
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task EncryptedDecoderStream_DataError_PreservesInnerException(bool async)
{
var error = new DataErrorException();
using var decoder = new FailingStream(error);
using var stream = new EncryptedDecoderStream(decoder);
var exception = await Assert.ThrowsAsync<CryptographicException>(async () =>
{
if (async)
{
await stream.ReadAsync(new byte[1], 0, 1, CancellationToken.None);
}
else
{
stream.Read(new byte[1], 0, 1);
}
});
Assert.Same(error, exception.InnerException);
}
[Fact]
public async Task EncryptedDecoderStream_OtherErrors_AreNotTranslated()
{
var error = new IOException("read failed");
using var decoder = new FailingStream(error);
using var stream = new EncryptedDecoderStream(decoder);
Assert.Same(error, Assert.Throws<IOException>(() => stream.ReadByte()));
Assert.Same(
error,
await Assert.ThrowsAsync<IOException>(() => stream.ReadAsync(new byte[1], 0, 1))
);
using var canceled = new CancellationTokenSource();
canceled.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
stream.ReadAsync(new byte[1], 0, 1, canceled.Token)
);
stream.Dispose();
Assert.True(decoder.Disposed);
}
private sealed class FailingStream(Exception exception) : MemoryStream
{
public bool Disposed { get; private set; }
public override int Read(byte[] buffer, int offset, int count) => throw exception;
public override Task<int> ReadAsync(
byte[] buffer,
int offset,
int count,
CancellationToken cancellationToken
)
{
cancellationToken.ThrowIfCancellationRequested();
throw exception;
}
protected override void Dispose(bool disposing)
{
Disposed = true;
base.Dispose(disposing);
}
}
}

View File

@@ -0,0 +1,113 @@
using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using SharpCompress.Archives.SevenZip;
using SharpCompress.Common;
using SharpCompress.Compressors.LZMA;
using SharpCompress.Readers;
using Xunit;
namespace SharpCompress.Test.SevenZip;
public class SevenZipPasswordExceptionTests : ArchiveTests
{
// 206-byte fixture generated with 7zz a -psecret -mhe=on -m0=LZMA2
// encrypted-header.7z data.txt (data.txt contains "hello"). No external tool needed in CI.
private const string EncryptedHeader =
"N3q8ryccAAQx1ES9gAAAAAAAAAAuAAAAAAAAADjCO/gWFzzAowVAn1/zBdIMi+5r2AyhxUL1SKmLFzNxH6VJqIWJTTzQDTAhcddAjSIgK/e92hRdPfRIZYJaEPZcue3MFygwZTMOKvgnqcH5B+k/o2345MpVIuA4n2yg4ag99CRyj4LF52P188ujA4nEMXCEsl5lKfQmln5QFOXywSf2ShcGEAEJcAAHCwEAASQG8QcBElMPq2bT4KLY1zYjazQMOzZJ1AxqCgGTarjeAAA=";
[Theory]
[InlineData(false, "secret")]
[InlineData(true, "secret")]
[InlineData(false, "wrong")]
[InlineData(true, "wrong")]
public async Task SevenZipArchive_EncryptedHeader_PasswordErrors(bool async, string password)
{
using var input = new MemoryStream(Convert.FromBase64String(EncryptedHeader));
async Task ReadArchive()
{
if (async)
{
await using var archive = await SevenZipArchive.OpenAsyncArchive(
input,
new ReaderOptions { Password = password, LeaveStreamOpen = true },
CancellationToken.None
);
var count = 0;
await foreach (var entry in archive.EntriesAsync)
{
Assert.Equal("data.txt", entry.Key);
count++;
}
Assert.Equal(1, count);
}
else
{
using var archive = SevenZipArchive.OpenArchive(
input,
new ReaderOptions { Password = password, LeaveStreamOpen = true }
);
Assert.Equal("data.txt", archive.Entries.Single().Key);
}
}
if (password == "secret")
{
await ReadArchive();
}
else
{
var exception = await Assert.ThrowsAsync<CryptographicException>(ReadArchive);
Assert.IsType<InvalidFormatException>(exception.InnerException);
}
}
[Fact]
public async Task SevenZipArchive_WrongPassword_AsyncOpenPublicException()
{
await using var archive = await SevenZipArchive.OpenAsyncArchive(
Path.Combine(TEST_ARCHIVES_PATH, "7Zip.LZMA.Aes.7z"),
new ReaderOptions { Password = "wrong" },
CancellationToken.None
);
var exception = await Assert.ThrowsAsync<CryptographicException>(async () =>
{
await foreach (var entry in archive.EntriesAsync)
{
if (!entry.IsDirectory)
{
using var stream = await entry.OpenEntryStreamAsync(CancellationToken.None);
await stream.CopyToAsync(Stream.Null, 81920, CancellationToken.None);
}
}
});
Assert.IsType<DataErrorException>(exception.InnerException);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task SevenZipArchive_WrongPassword_PublicException(bool async)
{
using var archive = SevenZipArchive.OpenArchive(
Path.Combine(TEST_ARCHIVES_PATH, "7Zip.LZMA.Aes.7z"),
new ReaderOptions { Password = "wrong" }
);
var entry = archive.Entries.First(e => !e.IsDirectory);
var exception = await Assert.ThrowsAsync<CryptographicException>(async () =>
{
using var stream = entry.OpenEntryStream();
if (async)
{
await stream.CopyToAsync(Stream.Null, 81920, CancellationToken.None);
}
else
{
stream.CopyTo(Stream.Null);
}
});
Assert.IsType<DataErrorException>(exception.InnerException);
Assert.Contains("corrupt encrypted", exception.Message);
}
}