diff --git a/docs/API.md b/docs/API.md index 7cc13b7d..e272efc4 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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()) +{ + Console.WriteLine(volume.Comment); +} +``` + ### Creating Archives ```csharp diff --git a/src/SharpCompress/Common/Rar/Headers/RarHeaderFactory.Async.cs b/src/SharpCompress/Common/Rar/Headers/RarHeaderFactory.Async.cs index d020add9..92f60ecf 100644 --- a/src/SharpCompress/Common/Rar/Headers/RarHeaderFactory.Async.cs +++ b/src/SharpCompress/Common/Rar/Headers/RarHeaderFactory.Async.cs @@ -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 { diff --git a/src/SharpCompress/Common/Rar/Headers/RarHeaderFactory.cs b/src/SharpCompress/Common/Rar/Headers/RarHeaderFactory.cs index e9694c2a..cbc68b69 100644 --- a/src/SharpCompress/Common/Rar/Headers/RarHeaderFactory.cs +++ b/src/SharpCompress/Common/Rar/Headers/RarHeaderFactory.cs @@ -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; + } } diff --git a/src/SharpCompress/Common/Rar/RarVolume.cs b/src/SharpCompress/Common/Rar/RarVolume.cs index 1b7de043..e3a15e90 100644 --- a/src/SharpCompress/Common/Rar/RarVolume.cs +++ b/src/SharpCompress/Common/Rar/RarVolume.cs @@ -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) diff --git a/tests/SharpCompress.Test/Rar/RarCommentTests.cs b/tests/SharpCompress.Test/Rar/RarCommentTests.cs new file mode 100644 index 00000000..5d86e8c7 --- /dev/null +++ b/tests/SharpCompress.Test/Rar/RarCommentTests.cs @@ -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 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().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(async () => + { + await using var archive = await RarArchive.OpenAsyncArchive(stream, options); + await foreach (var entry in archive.EntriesAsync) + { + _ = entry.Key; + } + }); + } + else + { + Assert.Throws(() => + { + 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(async () => + { + await foreach (var part in volume.GetVolumeFilePartsAsync(cancellation.Token)) + { + _ = part.FileHeader; + } + }); + } +} diff --git a/tests/TestArchives/Archives/Rar5.comment.encrypted15.rar b/tests/TestArchives/Archives/Rar5.comment.encrypted15.rar new file mode 100644 index 00000000..89c618bb Binary files /dev/null and b/tests/TestArchives/Archives/Rar5.comment.encrypted15.rar differ diff --git a/tests/TestArchives/Archives/Rar5.comment.encrypted16.rar b/tests/TestArchives/Archives/Rar5.comment.encrypted16.rar new file mode 100644 index 00000000..421541d5 Binary files /dev/null and b/tests/TestArchives/Archives/Rar5.comment.encrypted16.rar differ diff --git a/tests/TestArchives/Archives/Rar5.comment.encrypted17.rar b/tests/TestArchives/Archives/Rar5.comment.encrypted17.rar new file mode 100644 index 00000000..e38ba392 Binary files /dev/null and b/tests/TestArchives/Archives/Rar5.comment.encrypted17.rar differ diff --git a/tests/TestArchives/Archives/Rar5.comment.filesOnly.rar b/tests/TestArchives/Archives/Rar5.comment.filesOnly.rar new file mode 100644 index 00000000..1c9e65b1 Binary files /dev/null and b/tests/TestArchives/Archives/Rar5.comment.filesOnly.rar differ diff --git a/tests/TestArchives/Archives/Rar5.comment.plain.rar b/tests/TestArchives/Archives/Rar5.comment.plain.rar new file mode 100644 index 00000000..5ab75b3d Binary files /dev/null and b/tests/TestArchives/Archives/Rar5.comment.plain.rar differ