Fix encrypted RAR5 archive comments

This commit is contained in:
Adam Hathcock
2026-10-02 13:33:50 +01:00
parent c083c6efd8
commit b67a8a4ead
10 changed files with 201 additions and 9 deletions

View File

@@ -75,6 +75,25 @@ using (var archive = ArchiveFactory.OpenArchive(parts))
`ArchiveInformation.SupportsRandomAccess` is `true` when the detected format supports `IArchive` random access. It is `false` for reader-only formats such as Ace, Arc, Arj, and standalone LZW, where `ReaderFactory.OpenReader` should be used instead. Compressed tar wrappers such as `.tar.gz` and `.tar.xz` are also reader-only; `ArchiveFactory.GetArchiveInformation` returns `null` for them and `ArchiveFactory.OpenArchive` does not open them as the outer compression wrapper. Use `ReaderFactory.OpenReader` or `TarReader.OpenReader` for those files.
### RAR5 Archive Comments
`SharpCompress.Common.Rar.RarVolume.Comment` exposes UTF-8 archive comments, including encrypted comments when `ReaderOptions.Password` is supplied. Comments are loaded while entries are enumerated; the property can be `null` before enumeration or when no comment is present.
```csharp
using var archive = RarArchive.OpenArchive(
"encrypted.rar",
ReaderOptions.ForFilePath.WithPassword("password")
);
foreach (var entry in archive.Entries)
{
Console.WriteLine(entry.Key);
}
foreach (var volume in archive.Volumes.OfType<SharpCompress.Common.Rar.RarVolume>())
{
Console.WriteLine(volume.Comment);
}
```
### Creating Archives
```csharp

View File

@@ -138,7 +138,7 @@ public partial class RarHeaderFactory
.ConfigureAwait(false);
if (fh.FileName == "CMT")
{
fh.PackedStream = new ReadOnlySubStream(reader.BaseStream, fh.CompressedSize);
fh.PackedStream = CreatePackedStream(fh, reader.BaseStream);
}
else
{

View File

@@ -122,7 +122,7 @@ public partial class RarHeaderFactory
var fh = FileHeader.Create(header, reader, HeaderType.Service);
if (fh.FileName == "CMT")
{
fh.PackedStream = new ReadOnlySubStream(reader.BaseStream, fh.CompressedSize);
fh.PackedStream = CreatePackedStream(fh, reader.BaseStream);
}
else
{
@@ -223,4 +223,20 @@ public partial class RarHeaderFactory
}
}
}
private Stream CreatePackedStream(FileHeader header, Stream stream)
{
var packedStream = new ReadOnlySubStream(stream, header.CompressedSize);
// Service data has its own encryption metadata, independent of header encryption.
if (header.Rar5CryptoInfo is not null)
{
return new RarCryptoWrapper(
packedStream,
header.Rar5CryptoInfo.Salt,
new CryptKey5(Options.Password, header.Rar5CryptoInfo)
);
}
return packedStream;
}
}

View File

@@ -66,9 +66,11 @@ public abstract class RarVolume : Volume
var fh = (FileHeader)header;
if (fh.FileName == "CMT")
{
var buffer = new byte[fh.CompressedSize];
fh.PackedStream.NotNull().ReadFully(buffer);
Comment = Encoding.UTF8.GetString(buffer, 0, buffer.Length - 1);
// Read the logical service size, not the potentially padded packed size.
var buffer = new byte[checked((int)fh.UncompressedSize)];
using var packedStream = fh.PackedStream.NotNull();
packedStream.ReadFully(buffer);
Comment = DecodeComment(buffer);
}
}
break;
@@ -115,12 +117,12 @@ public abstract class RarVolume : Volume
var fh = (FileHeader)header;
if (fh.FileName == "CMT")
{
var buffer = new byte[fh.CompressedSize];
await fh
.PackedStream.NotNull()
var buffer = new byte[checked((int)fh.UncompressedSize)];
using var packedStream = fh.PackedStream.NotNull();
await packedStream
.ReadFullyAsync(buffer, cancellationToken)
.ConfigureAwait(false);
Comment = Encoding.UTF8.GetString(buffer, 0, buffer.Length - 1);
Comment = DecodeComment(buffer);
}
}
break;
@@ -128,6 +130,15 @@ public abstract class RarVolume : Volume
}
}
private static string DecodeComment(byte[] buffer)
{
// Like UnRAR, stop at the first NUL. Encrypted comments can include
// padding in their unpacked size as well as their packed size.
var terminator = Array.IndexOf(buffer, (byte)0);
var length = terminator < 0 ? buffer.Length : terminator;
return Encoding.UTF8.GetString(buffer, 0, length);
}
private void EnsureArchiveHeaderLoaded()
{
if (ArchiveHeader is null)

View File

@@ -0,0 +1,146 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using SharpCompress.Archives;
using SharpCompress.Archives.Rar;
using SharpCompress.Common.Rar;
using SharpCompress.Readers;
using SharpCompress.Readers.Rar;
using SharpCompress.Test.Mocks;
using Xunit;
namespace SharpCompress.Test.Rar;
public class RarCommentTests : TestBase
{
public static IEnumerable<object[]> Comments()
{
// Generated with RAR 7.22: rar a -ma5 -m0 -qo- -zcomment.txt [-hptest|-ptest] archive.rar payload.txt.
foreach (var useAsync in new[] { false, true })
{
yield return ["Rar5.comment.encrypted15.rar", "123456789012345", useAsync];
yield return ["Rar5.comment.encrypted16.rar", "1234567890123456", useAsync];
yield return ["Rar5.comment.encrypted17.rar", "123456789012345é", useAsync];
yield return ["Rar5.comment.plain.rar", "123456789012345é", useAsync];
yield return ["Rar5.comment.filesOnly.rar", "123456789012345é", useAsync];
}
}
[Theory]
[MemberData(nameof(Comments))]
public async Task Rar5_Comment_Archive(string filename, string expected, bool useAsync)
{
using var stream = File.OpenRead(Path.Combine(TEST_ARCHIVES_PATH, filename));
var options = ReaderOptions.ForExternalStream.WithPassword("test");
using var output = new MemoryStream();
if (useAsync)
{
await using var archive = await RarArchive.OpenAsyncArchive(stream, options);
await foreach (var entry in archive.EntriesAsync)
{
Assert.Equal("payload.txt", entry.Key);
await entry.WriteToAsync(output, cancellationToken: CancellationToken.None);
}
await foreach (var volume in archive.VolumesAsync)
{
Assert.Equal(expected, ((RarVolume)volume).Comment);
}
}
else
{
using var archive = RarArchive.OpenArchive(stream, options);
var entry = Assert.Single(archive.Entries);
Assert.Equal("payload.txt", entry.Key);
entry.WriteTo(output);
Assert.Equal(expected, archive.Volumes.OfType<RarVolume>().First().Comment);
}
Assert.Equal("hello from RAR!\n", Encoding.UTF8.GetString(output.ToArray()));
Assert.True(stream.CanRead);
}
[Theory]
[InlineData(null, false)]
[InlineData(null, true)]
[InlineData("wrong", false)]
[InlineData("wrong", true)]
public async Task Rar5_Comment_Rejects_Invalid_Password(string? password, bool useAsync)
{
using var stream = File.OpenRead(
Path.Combine(TEST_ARCHIVES_PATH, "Rar5.comment.encrypted17.rar")
);
var options = ReaderOptions.ForExternalStream with { Password = password };
if (useAsync)
{
await Assert.ThrowsAsync<SharpCompress.Common.CryptographicException>(async () =>
{
await using var archive = await RarArchive.OpenAsyncArchive(stream, options);
await foreach (var entry in archive.EntriesAsync)
{
_ = entry.Key;
}
});
}
else
{
Assert.Throws<SharpCompress.Common.CryptographicException>(() =>
{
using var archive = RarArchive.OpenArchive(stream, options);
_ = archive.Entries.ToArray();
});
}
}
[Theory]
[MemberData(nameof(Comments))]
public async Task Rar5_Comment_Reader(string filename, string expected, bool useAsync)
{
using var stream = File.OpenRead(Path.Combine(TEST_ARCHIVES_PATH, filename));
using var forwardOnly = new ForwardOnlyStream(stream);
var options = ReaderOptions.ForExternalStream.WithPassword("test");
using var output = new MemoryStream();
if (useAsync)
{
await using var reader = await RarReader.OpenAsyncReader(forwardOnly, options);
Assert.True(await reader.MoveToNextEntryAsync(CancellationToken.None));
Assert.Equal(expected, ((RarReader)reader).Volume!.Comment);
await reader.WriteEntryToAsync(output, CancellationToken.None);
Assert.False(await reader.MoveToNextEntryAsync(CancellationToken.None));
}
else
{
using var reader = RarReader.OpenReader(forwardOnly, options);
Assert.True(reader.MoveToNextEntry());
Assert.Equal(expected, ((RarReader)reader).Volume!.Comment);
reader.WriteEntryTo(output);
Assert.False(reader.MoveToNextEntry());
}
Assert.Equal("hello from RAR!\n", Encoding.UTF8.GetString(output.ToArray()));
Assert.True(forwardOnly.CanRead);
}
[Fact]
public async Task Rar5_Comment_Volume_Async_Cancellation()
{
using var stream = File.OpenRead(
Path.Combine(TEST_ARCHIVES_PATH, "Rar5.comment.encrypted17.rar")
);
using var volume = new StreamRarArchiveVolume(
stream,
ReaderOptions.ForExternalStream.WithPassword("test"),
0
);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
{
await foreach (var part in volume.GetVolumeFilePartsAsync(cancellation.Token))
{
_ = part.FileHeader;
}
});
}
}

Binary file not shown.

Binary file not shown.