mirror of
https://github.com/adamhathcock/sharpcompress.git
synced 2026-11-15 17:02:03 +00:00
Merge pull request #1445 from adamhathcock/fix/1439-sevenzip-password-exceptions
Expose public exceptions for encrypted 7z decoding failures
This commit is contained in:
10
docs/API.md
10
docs/API.md
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
58
src/SharpCompress/Compressors/LZMA/EncryptedDecoderStream.cs
Normal file
58
src/SharpCompress/Compressors/LZMA/EncryptedDecoderStream.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user