From b67a8a4eadf0dccf23146de4913c415aa2a9052d Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Fri, 2 Oct 2026 13:33:50 +0100 Subject: [PATCH 1/5] Fix encrypted RAR5 archive comments --- docs/API.md | 19 +++ .../Rar/Headers/RarHeaderFactory.Async.cs | 2 +- .../Common/Rar/Headers/RarHeaderFactory.cs | 18 ++- src/SharpCompress/Common/Rar/RarVolume.cs | 25 ++- .../SharpCompress.Test/Rar/RarCommentTests.cs | 146 ++++++++++++++++++ .../Archives/Rar5.comment.encrypted15.rar | Bin 0 -> 350 bytes .../Archives/Rar5.comment.encrypted16.rar | Bin 0 -> 350 bytes .../Archives/Rar5.comment.encrypted17.rar | Bin 0 -> 366 bytes .../Archives/Rar5.comment.filesOnly.rar | Bin 0 -> 176 bytes .../Archives/Rar5.comment.plain.rar | Bin 0 -> 127 bytes 10 files changed, 201 insertions(+), 9 deletions(-) create mode 100644 tests/SharpCompress.Test/Rar/RarCommentTests.cs create mode 100644 tests/TestArchives/Archives/Rar5.comment.encrypted15.rar create mode 100644 tests/TestArchives/Archives/Rar5.comment.encrypted16.rar create mode 100644 tests/TestArchives/Archives/Rar5.comment.encrypted17.rar create mode 100644 tests/TestArchives/Archives/Rar5.comment.filesOnly.rar create mode 100644 tests/TestArchives/Archives/Rar5.comment.plain.rar 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 0000000000000000000000000000000000000000..89c618bb5a8014e1c37112a799b0d191388f2a01 GIT binary patch literal 350 zcmWGaEK-zWXJkmc)NP{3!oa}DzeW6c)WoXBi<8{)4`rC0JEpwr=dAxZ^=zwc{xIknOBY_;|5e|d^%cw9?9v0WI}C0x z3kSO!2JtnNFBDq-U*L1rMf+pjnv3thJD~V6+UrQL{*7zZO}BfA$26JhLf$m2+TC^X@`c<-UzFItZ+`K{ zzww{nu)mUgq&{Zh2#EU1^}Uq1ceyar}P6G@fC L)_XCgPO{Sgla{Iu literal 0 HcmV?d00001 diff --git a/tests/TestArchives/Archives/Rar5.comment.encrypted16.rar b/tests/TestArchives/Archives/Rar5.comment.encrypted16.rar new file mode 100644 index 0000000000000000000000000000000000000000..421541d55247278c00f34c4d124a63af02b84623 GIT binary patch literal 350 zcmWGaEK-zWXJnW^G4`G!3j+fqzer_jnWTUBygQr;(>G{N+b+CbUU&1$>D$jsM!gM_ zJ7c05Ygl(MW3u*|b9Wu>%4JWU+#amlmCKN*t(uWm)-=Q7tA*zg>u3D(GP|1{)1sQ= zx2;|t>~9f!^@!iz(@Pe;Ex7Cdy8Oy>6JaHZNpdgVZd|}BUc$8C{Z7^_u{D2A<*07^ zD959@*sSk;$b*MDH>0#pznyhMY@*JBxf}Fat_HN_dALnG;H_qJHgSn<7VGJ={uK1DV0! zRc2*~n_Y17WmDWp0Q&K#ZvX%Q literal 0 HcmV?d00001 diff --git a/tests/TestArchives/Archives/Rar5.comment.encrypted17.rar b/tests/TestArchives/Archives/Rar5.comment.encrypted17.rar new file mode 100644 index 0000000000000000000000000000000000000000..e38ba392d4c58448d418eccbd4bc1fce1cd1eb61 GIT binary patch literal 366 zcmWGaEK-zWXJn|4^znFb2H;}r1_-gD=sW|HHGA32$X1czYMJrUXli?sPMl zV{z?mZheQ`L1C4R(NYY8D{P7vUNe;bv+cTE$7xBPZ)I~`YmW*yF5PVWAd=}vVp(JJ z)pcnW%R_6#KO|=#KfUpUlEwc`etVP?Z*L^hde(q+E9NBzNQF_{3RH68ptm4*YsGi-q^x bFaQ1%wTgx%TQ|38JFU0YUwBFK#`G8fIaRBG literal 0 HcmV?d00001 diff --git a/tests/TestArchives/Archives/Rar5.comment.filesOnly.rar b/tests/TestArchives/Archives/Rar5.comment.filesOnly.rar new file mode 100644 index 0000000000000000000000000000000000000000..1c9e65b1d01a67111783d0f068e9f4f75630bc91 GIT binary patch literal 176 zcmWGaEK-zWXJj~dKR%3^k%d83`D&RkGt)!{mWd1uw`bgNY+ztycJ>W1G%_|ZH8Z!c zGyt;>uT+ZXYl&iFwwb`dGJ#=9Gt;hIYqtglM(%>d%AEYf6upv)5(7pCX8zbaucuBf zU<)$)y(cI7{JMD-ahb2mR@rl&)_vELbb0A7Tit1^T@x$ns^=b>!o@7SuWoW1G%_|ZH8Z!c zGyt;>uPk$tII79S%sqjDWdg&JW+pG@6MGvN7`Y1)D|7M_Q}jwIO1PMX_tovsy0>+@ dd`4 Date: Fri, 2 Oct 2026 13:56:16 +0100 Subject: [PATCH 2/5] use arraypool --- src/SharpCompress/Common/Rar/RarVolume.cs | 54 +++++++++++++++++------ 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/src/SharpCompress/Common/Rar/RarVolume.cs b/src/SharpCompress/Common/Rar/RarVolume.cs index e3a15e90..96b6b335 100644 --- a/src/SharpCompress/Common/Rar/RarVolume.cs +++ b/src/SharpCompress/Common/Rar/RarVolume.cs @@ -1,4 +1,5 @@ using System; +using System.Buffers; using System.Collections.Generic; using System.IO; using System.Linq; @@ -67,10 +68,22 @@ public abstract class RarVolume : Volume if (fh.FileName == "CMT") { // 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); + var commentSize = checked((int)fh.UncompressedSize); + var buffer = ArrayPool.Shared.Rent(commentSize); + try + { + using var packedStream = fh.PackedStream.NotNull(); + if (!packedStream.ReadFully(buffer.AsSpan(0, commentSize))) + { + // Never decode unread bytes from a pooled buffer. + throw new EndOfStreamException(); + } + Comment = DecodeComment(buffer, commentSize); + } + finally + { + ArrayPool.Shared.Return(buffer); + } } } break; @@ -117,12 +130,26 @@ public abstract class RarVolume : Volume var fh = (FileHeader)header; if (fh.FileName == "CMT") { - var buffer = new byte[checked((int)fh.UncompressedSize)]; - using var packedStream = fh.PackedStream.NotNull(); - await packedStream - .ReadFullyAsync(buffer, cancellationToken) - .ConfigureAwait(false); - Comment = DecodeComment(buffer); + var commentSize = checked((int)fh.UncompressedSize); + var buffer = ArrayPool.Shared.Rent(commentSize); + try + { + using var packedStream = fh.PackedStream.NotNull(); + if ( + !await packedStream + .ReadFullyAsync(buffer, 0, commentSize, cancellationToken) + .ConfigureAwait(false) + ) + { + // Never decode unread bytes from a pooled buffer. + throw new EndOfStreamException(); + } + Comment = DecodeComment(buffer, commentSize); + } + finally + { + ArrayPool.Shared.Return(buffer); + } } } break; @@ -130,12 +157,13 @@ public abstract class RarVolume : Volume } } - private static string DecodeComment(byte[] buffer) + private static string DecodeComment(byte[] buffer, int commentSize) { // 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; + // Limit decoding to the payload, since rented buffers can be larger. + var terminator = Array.IndexOf(buffer, (byte)0, 0, commentSize); + var length = terminator < 0 ? commentSize : terminator; return Encoding.UTF8.GetString(buffer, 0, length); } From 4f8c69f2c3ed69070ed027634081ab6f24a5313d Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Fri, 2 Oct 2026 15:23:16 +0100 Subject: [PATCH 3/5] don't use RarReader directory --- tests/SharpCompress.Test/Rar/RarCommentTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/SharpCompress.Test/Rar/RarCommentTests.cs b/tests/SharpCompress.Test/Rar/RarCommentTests.cs index 5d86e8c7..36893b96 100644 --- a/tests/SharpCompress.Test/Rar/RarCommentTests.cs +++ b/tests/SharpCompress.Test/Rar/RarCommentTests.cs @@ -104,7 +104,7 @@ public class RarCommentTests : TestBase using var output = new MemoryStream(); if (useAsync) { - await using var reader = await RarReader.OpenAsyncReader(forwardOnly, options); + await using var reader = await ReaderFactory.OpenAsyncReader(forwardOnly, options); Assert.True(await reader.MoveToNextEntryAsync(CancellationToken.None)); Assert.Equal(expected, ((RarReader)reader).Volume!.Comment); await reader.WriteEntryToAsync(output, CancellationToken.None); From 915183320ca79d8ef320c3060e4a26696c4b94b6 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Mon, 5 Oct 2026 10:48:34 +0100 Subject: [PATCH 4/5] added some review fixes --- docs/API.md | 2 + .../Rar/Headers/RarHeaderFactory.Async.cs | 2 +- .../Common/Rar/Headers/RarHeaderFactory.cs | 3 +- src/SharpCompress/Common/Rar/RarVolume.cs | 22 +- .../SharpCompress.Test/Rar/RarCommentTests.cs | 219 ++++++++++++++++++ 5 files changed, 242 insertions(+), 6 deletions(-) diff --git a/docs/API.md b/docs/API.md index e272efc4..57c618dd 100644 --- a/docs/API.md +++ b/docs/API.md @@ -79,6 +79,8 @@ using (var archive = ArchiveFactory.OpenArchive(parts)) `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. +Stored (compression method 0) comments are supported up to 16 MiB of unpacked data; larger or unknown-size comments throw `InvalidFormatException`. Comments using other compression methods are skipped without affecting entry extraction. + ```csharp using var archive = RarArchive.OpenArchive( "encrypted.rar", diff --git a/src/SharpCompress/Common/Rar/Headers/RarHeaderFactory.Async.cs b/src/SharpCompress/Common/Rar/Headers/RarHeaderFactory.Async.cs index ac043dd3..7010ed96 100644 --- a/src/SharpCompress/Common/Rar/Headers/RarHeaderFactory.Async.cs +++ b/src/SharpCompress/Common/Rar/Headers/RarHeaderFactory.Async.cs @@ -134,7 +134,7 @@ public partial class RarHeaderFactory var fh = await FileHeader .CreateAsync(header, reader, HeaderType.Service, cancellationToken) .ConfigureAwait(false); - if (fh.FileName == "CMT") + if (fh.FileName == "CMT" && fh.IsStored) { fh.PackedStream = CreatePackedStream(fh, reader.BaseStream); } diff --git a/src/SharpCompress/Common/Rar/Headers/RarHeaderFactory.cs b/src/SharpCompress/Common/Rar/Headers/RarHeaderFactory.cs index fc8543f6..42615b58 100644 --- a/src/SharpCompress/Common/Rar/Headers/RarHeaderFactory.cs +++ b/src/SharpCompress/Common/Rar/Headers/RarHeaderFactory.cs @@ -132,7 +132,8 @@ public partial class RarHeaderFactory case HeaderCodeV.RAR5_SERVICE_HEADER: { var fh = FileHeader.Create(header, reader, HeaderType.Service); - if (fh.FileName == "CMT") + // Only stored comments are supported; skip other methods without decrypting. + if (fh.FileName == "CMT" && fh.IsStored) { fh.PackedStream = CreatePackedStream(fh, reader.BaseStream); } diff --git a/src/SharpCompress/Common/Rar/RarVolume.cs b/src/SharpCompress/Common/Rar/RarVolume.cs index 96b6b335..aaf7c0e8 100644 --- a/src/SharpCompress/Common/Rar/RarVolume.cs +++ b/src/SharpCompress/Common/Rar/RarVolume.cs @@ -18,6 +18,8 @@ namespace SharpCompress.Common.Rar; /// public abstract class RarVolume : Volume { + private const int MaxCommentSize = 16 * 1024 * 1024; + private readonly RarHeaderFactory _headerFactory; private int _maxCompressionAlgorithm; @@ -65,10 +67,10 @@ public abstract class RarVolume : Volume case HeaderType.Service: { var fh = (FileHeader)header; - if (fh.FileName == "CMT") + if (fh.FileName == "CMT" && fh.IsStored) { // Read the logical service size, not the potentially padded packed size. - var commentSize = checked((int)fh.UncompressedSize); + var commentSize = GetCommentSize(fh); var buffer = ArrayPool.Shared.Rent(commentSize); try { @@ -128,9 +130,9 @@ public abstract class RarVolume : Volume case HeaderType.Service: { var fh = (FileHeader)header; - if (fh.FileName == "CMT") + if (fh.FileName == "CMT" && fh.IsStored) { - var commentSize = checked((int)fh.UncompressedSize); + var commentSize = GetCommentSize(fh); var buffer = ArrayPool.Shared.Rent(commentSize); try { @@ -157,6 +159,18 @@ public abstract class RarVolume : Volume } } + private static int GetCommentSize(FileHeader header) + { + // Match UnRAR's limit for in-memory service data before renting a buffer. + // Unknown unpacked sizes are represented by long.MaxValue and rejected too. + if (header.UncompressedSize < 0 || header.UncompressedSize > MaxCommentSize) + { + throw new InvalidFormatException("RAR archive comment exceeds the 16 MiB size limit."); + } + + return (int)header.UncompressedSize; + } + private static string DecodeComment(byte[] buffer, int commentSize) { // Like UnRAR, stop at the first NUL. Encrypted comments can include diff --git a/tests/SharpCompress.Test/Rar/RarCommentTests.cs b/tests/SharpCompress.Test/Rar/RarCommentTests.cs index 36893b96..5c284b57 100644 --- a/tests/SharpCompress.Test/Rar/RarCommentTests.cs +++ b/tests/SharpCompress.Test/Rar/RarCommentTests.cs @@ -7,7 +7,10 @@ using System.Threading; using System.Threading.Tasks; using SharpCompress.Archives; using SharpCompress.Archives.Rar; +using SharpCompress.Common; using SharpCompress.Common.Rar; +using SharpCompress.Compressors.Rar; +using SharpCompress.IO; using SharpCompress.Readers; using SharpCompress.Readers.Rar; using SharpCompress.Test.Mocks; @@ -143,4 +146,220 @@ public class RarCommentTests : TestBase } }); } + + public static IEnumerable InvalidCommentSizes() + { + foreach (var useAsync in new[] { false, true }) + { + foreach (var useReader in new[] { false, true }) + { + foreach ( + var size in new long[] { 16 * 1024 * 1024 + 1, int.MaxValue, long.MaxValue } + ) + { + yield return [size, useAsync, useReader]; + } + } + } + } + + [Theory] + [MemberData(nameof(InvalidCommentSizes))] + public async Task Rar5_Comment_Rejects_Oversized_Data(long size, bool useAsync, bool useReader) + { + using var stream = CreateCommentArchive(size, 0, [1]); + var exception = await Assert.ThrowsAsync(() => + ReadCommentArchive(stream, useAsync, useReader) + ); + Assert.Contains("16 MiB", exception.Message); + Assert.True(stream.CanRead); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task Rar5_Comment_Allows_Size_Limit(bool useAsync, bool useReader) + { + var data = new byte[16 * 1024 * 1024]; + data[0] = (byte)'a'; + using var stream = CreateCommentArchive(data.Length, 0, data); + Assert.Equal("a", await ReadCommentArchive(stream, useAsync, useReader)); + Assert.True(stream.CanRead); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task Rar5_Comment_Rejects_Truncated_Data(bool useAsync, bool useReader) + { + using var stream = CreateCommentArchive(10, 0, [1, 2, 3]); + await Assert.ThrowsAsync(() => + ReadCommentArchive(stream, useAsync, useReader) + ); + Assert.True(stream.CanRead); + } + + public static IEnumerable UnsupportedCommentMethods() + { + foreach (var useAsync in new[] { false, true }) + { + foreach (var useReader in new[] { false, true }) + { + foreach (var method in Enumerable.Range(1, 7)) + { + yield return [method, useAsync, useReader]; + } + } + } + } + + [Theory] + [MemberData(nameof(UnsupportedCommentMethods))] + public async Task Rar5_Comment_Skips_Nonzero_Methods(int method, bool useAsync, bool useReader) + { + // Ignored comments must not allocate from their unpacked size or disturb the next entry. + using var stream = CreateCommentArchive(int.MaxValue, method, [1, 2, 3]); + Assert.Null(await ReadCommentArchive(stream, useAsync, useReader)); + Assert.True(stream.CanRead); + } + + private static async Task ReadCommentArchive( + Stream stream, + bool useAsync, + bool useReader + ) + { + var options = ReaderOptions.ForExternalStream; + if (useReader) + { + using var forwardOnly = new ForwardOnlyStream( + SharpCompressStream.CreateNonDisposing(stream) + ); + using var output = new MemoryStream(); + string? comment; + if (useAsync) + { + await using var reader = await RarReader.OpenAsyncReader( + forwardOnly, + options, + CancellationToken.None + ); + Assert.True(await reader.MoveToNextEntryAsync(CancellationToken.None)); + Assert.Equal("payload.txt", reader.Entry.Key); + comment = ((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("payload.txt", reader.Entry.Key); + comment = ((RarReader)reader).Volume!.Comment; + reader.WriteEntryTo(output); + Assert.False(reader.MoveToNextEntry()); + } + Assert.Equal("hello from RAR!\n", Encoding.UTF8.GetString(output.ToArray())); + return comment; + } + + using var extracted = new MemoryStream(); + if (useAsync) + { + await using var archive = await RarArchive.OpenAsyncArchive( + stream, + options, + CancellationToken.None + ); + var count = 0; + await foreach (var entry in archive.EntriesAsync) + { + Assert.Equal("payload.txt", entry.Key); + await entry.WriteToAsync(extracted, cancellationToken: CancellationToken.None); + count++; + } + Assert.Equal(1, count); + Assert.Equal("hello from RAR!\n", Encoding.UTF8.GetString(extracted.ToArray())); + await foreach (var volume in archive.VolumesAsync) + { + return ((RarVolume)volume).Comment; + } + throw new InvalidOperationException("Expected a RAR volume."); + } + + using var syncArchive = RarArchive.OpenArchive(stream, options); + var syncEntry = Assert.Single(syncArchive.Entries); + Assert.Equal("payload.txt", syncEntry.Key); + syncEntry.WriteTo(extracted); + Assert.Equal("hello from RAR!\n", Encoding.UTF8.GetString(extracted.ToArray())); + return syncArchive.Volumes.OfType().First().Comment; + } + + private static MemoryStream CreateCommentArchive(long size, int method, byte[] commentData) + { + // Build small headers with valid CRCs so malformed sizes reach comment validation. + var archive = new MemoryStream(); + using var writer = new BinaryWriter(archive, Encoding.UTF8, leaveOpen: true); + writer.Write(new byte[] { 0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x01, 0x00 }); + WriteHeader(writer, [1, 0, 0]); + + using var body = new MemoryStream(); + using var header = new BinaryWriter(body, Encoding.UTF8, leaveOpen: true); + WriteVInts( + header, + 3, + 2, + commentData.Length, + size == long.MaxValue ? 8 : 0, + size, + 0, + method << 7, + 1, + 3 + ); + header.Write(Encoding.UTF8.GetBytes("CMT")); + WriteHeader(writer, body.ToArray()); + writer.Write(commentData); + + body.SetLength(0); + var payload = Encoding.UTF8.GetBytes("hello from RAR!\n"); + WriteVInts(header, 2, 2, payload.Length, 4, payload.Length, 0); + header.Write(~RarCRC.CheckCrc(uint.MaxValue, payload, 0, payload.Length)); + WriteVInts(header, 0, 1, 11); + header.Write(Encoding.UTF8.GetBytes("payload.txt")); + WriteHeader(writer, body.ToArray()); + writer.Write(payload); + WriteHeader(writer, [5, 0, 0]); + archive.Position = 0; + return archive; + } + + private static void WriteHeader(BinaryWriter writer, byte[] body) + { + using var bytes = new MemoryStream(); + using var header = new BinaryWriter(bytes); + WriteVInts(header, body.Length); + header.Write(body); + var data = bytes.ToArray(); + writer.Write(~RarCRC.CheckCrc(uint.MaxValue, data, 0, data.Length)); + writer.Write(data); + } + + private static void WriteVInts(BinaryWriter writer, params long[] values) + { + foreach (var value in values) + { + var remaining = (ulong)value; + while (remaining >= 0x80) + { + writer.Write((byte)((remaining & 0x7f) | 0x80)); + remaining >>= 7; + } + writer.Write((byte)remaining); + } + } } From ce081cda1ca86ab31fc687b01890ec25eac868e4 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Mon, 5 Oct 2026 10:59:57 +0100 Subject: [PATCH 5/5] fix build --- tests/SharpCompress.Test/Rar/RarCommentTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/SharpCompress.Test/Rar/RarCommentTests.cs b/tests/SharpCompress.Test/Rar/RarCommentTests.cs index 5c284b57..a1c6d284 100644 --- a/tests/SharpCompress.Test/Rar/RarCommentTests.cs +++ b/tests/SharpCompress.Test/Rar/RarCommentTests.cs @@ -243,7 +243,7 @@ public class RarCommentTests : TestBase string? comment; if (useAsync) { - await using var reader = await RarReader.OpenAsyncReader( + await using var reader = await ReaderFactory.OpenAsyncReader( forwardOnly, options, CancellationToken.None