From b3482f95c6998c265aa8dd98abe2186a4c7e60bd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 18:11:42 +0000 Subject: [PATCH 1/8] Initial plan From 5d909442955ce0e4d87c70b47b38c5eb227979f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 18:23:33 +0000 Subject: [PATCH 2/8] Add ARJ, ARC, ACE support and update agent instructions for NBGV deep clone requirement Co-authored-by: gfs <98900+gfs@users.noreply.github.com> --- .github/copilot-instructions.md | 9 + .../ExtractorTests/ExpectedNumFilesTests.cs | 6 + .../ExtractorTests/MiniMagicTests.cs | 3 + .../RecursiveExtractor.Tests.csproj | 9 + .../TestData/TestDataArchives/TestData.ace | Bin 0 -> 117 bytes .../TestData/TestDataArchives/TestData.arc | Bin 0 -> 75 bytes .../TestData/TestDataArchives/TestData.arj | Bin 0 -> 176 bytes RecursiveExtractor/Extractor.cs | 3 + RecursiveExtractor/Extractors/AceExtractor.cs | 170 +++++++++++++++++ RecursiveExtractor/Extractors/ArcExtractor.cs | 174 ++++++++++++++++++ RecursiveExtractor/Extractors/ArjExtractor.cs | 168 +++++++++++++++++ RecursiveExtractor/MiniMagic.cs | 33 +++- RecursiveExtractor/RecursiveExtractor.csproj | 2 +- nuget.config | 2 +- 14 files changed, 574 insertions(+), 5 deletions(-) create mode 100644 RecursiveExtractor.Tests/TestData/TestDataArchives/TestData.ace create mode 100644 RecursiveExtractor.Tests/TestData/TestDataArchives/TestData.arc create mode 100644 RecursiveExtractor.Tests/TestData/TestDataArchives/TestData.arj create mode 100644 RecursiveExtractor/Extractors/AceExtractor.cs create mode 100644 RecursiveExtractor/Extractors/ArcExtractor.cs create mode 100644 RecursiveExtractor/Extractors/ArjExtractor.cs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 90fc64cd..0ed3ebc7 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -13,6 +13,15 @@ RecursiveExtractor is a cross-platform .NET library and CLI tool for parsing arc ## Building and Testing +### Git Clone Depth + +⚠️ **Important**: This repository uses [Nerdbank.GitVersioning](https://github.com/dotnet/Nerdbank.GitVersioning) (NBGV) to calculate version numbers from git history. Shallow clones will cause the build to fail with a `GitException: Shallow clone lacks the objects required to calculate version height` error. If you encounter this, deepen the clone: +```bash +git fetch --unshallow +# or if that fails: +git fetch --depth=100 +``` + ### Build Commands ```bash # Build the entire solution diff --git a/RecursiveExtractor.Tests/ExtractorTests/ExpectedNumFilesTests.cs b/RecursiveExtractor.Tests/ExtractorTests/ExpectedNumFilesTests.cs index 7535716a..627dcb93 100644 --- a/RecursiveExtractor.Tests/ExtractorTests/ExpectedNumFilesTests.cs +++ b/RecursiveExtractor.Tests/ExtractorTests/ExpectedNumFilesTests.cs @@ -43,6 +43,9 @@ public static TheoryData ArchiveData { "TestDataArchivesNested.Zip", 54 }, { "UdfTest.iso", 3 }, { "UdfTestWithMultiSystem.iso", 3 }, + { "TestData.arj", 1 }, + { "TestData.arc", 1 }, + { "TestData.ace", 1 }, // { "HfsSampleUDCO.dmg", 2 } }; } @@ -75,6 +78,9 @@ public static TheoryData NoRecursionData { "EmptyFile.txt", 1 }, { "TestDataArchivesNested.Zip", 14 }, { "UdfTestWithMultiSystem.iso", 3 }, + { "TestData.arj", 1 }, + { "TestData.arc", 1 }, + { "TestData.ace", 1 }, // { "HfsSampleUDCO.dmg", 2 } }; } diff --git a/RecursiveExtractor.Tests/ExtractorTests/MiniMagicTests.cs b/RecursiveExtractor.Tests/ExtractorTests/MiniMagicTests.cs index 20e2d959..555975ab 100644 --- a/RecursiveExtractor.Tests/ExtractorTests/MiniMagicTests.cs +++ b/RecursiveExtractor.Tests/ExtractorTests/MiniMagicTests.cs @@ -24,6 +24,9 @@ public class MiniMagicTests [InlineData("Empty.vmdk", ArchiveFileType.VMDK)] [InlineData("HfsSampleUDCO.dmg", ArchiveFileType.DMG)] [InlineData("EmptyFile.txt", ArchiveFileType.UNKNOWN)] + [InlineData("TestData.arj", ArchiveFileType.ARJ)] + [InlineData("TestData.arc", ArchiveFileType.ARC)] + [InlineData("TestData.ace", ArchiveFileType.ACE)] public void TestMiniMagic(string fileName, ArchiveFileType expectedArchiveFileType) { var path = Path.Combine(Directory.GetCurrentDirectory(), "TestData", "TestDataArchives", fileName); diff --git a/RecursiveExtractor.Tests/RecursiveExtractor.Tests.csproj b/RecursiveExtractor.Tests/RecursiveExtractor.Tests.csproj index 5b57be59..30848fc1 100644 --- a/RecursiveExtractor.Tests/RecursiveExtractor.Tests.csproj +++ b/RecursiveExtractor.Tests/RecursiveExtractor.Tests.csproj @@ -301,6 +301,15 @@ PreserveNewest + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + PreserveNewest diff --git a/RecursiveExtractor.Tests/TestData/TestDataArchives/TestData.ace b/RecursiveExtractor.Tests/TestData/TestDataArchives/TestData.ace new file mode 100644 index 0000000000000000000000000000000000000000..bfc21130a8882577e1ba9b6a4c7b156dbeed98d3 GIT binary patch literal 117 zcmX>dCdL2+T3U|Iu3B1LTudMy7{ngaW?*Ds&;fEl7z7l+B#hu;2uUq2am&m})hnqe t3CYMTRsfU Y$r+htsS2qTB}IwJC7JnodR$Tr0Ath_qW}N^ literal 0 HcmV?d00001 diff --git a/RecursiveExtractor.Tests/TestData/TestDataArchives/TestData.arj b/RecursiveExtractor.Tests/TestData/TestDataArchives/TestData.arj new file mode 100644 index 0000000000000000000000000000000000000000..a0b168629bbb101ae6e32a8892290cd4bf8f01f5 GIT binary patch literal 176 zcmYdzWx$}s&B!Fcz$7JN(3T0p3>Y9JwYbD3u_RG1u_%jyf!)y~nSmkUl?{U)SSN$X zKmE2$9iT1{R?`vq%)qck5Ml^O282O8kST7NIjMRj6(tM|{_366fMOY$#R@=_s89lw mRY(I$E2QNYDI^vpXJnS8Dx_AF6eT8?Waj7TaV5NBU;qGtj4P!8 literal 0 HcmV?d00001 diff --git a/RecursiveExtractor/Extractor.cs b/RecursiveExtractor/Extractor.cs index 8b14fe2c..85d53e8a 100644 --- a/RecursiveExtractor/Extractor.cs +++ b/RecursiveExtractor/Extractor.cs @@ -85,6 +85,9 @@ public void SetDefaultExtractors() SetExtractor(ArchiveFileType.VMDK, new VmdkExtractor(this)); SetExtractor(ArchiveFileType.XZ, new XzExtractor(this)); SetExtractor(ArchiveFileType.ZIP, new ZipExtractor(this)); + SetExtractor(ArchiveFileType.ARJ, new ArjExtractor(this)); + SetExtractor(ArchiveFileType.ARC, new ArcExtractor(this)); + SetExtractor(ArchiveFileType.ACE, new AceExtractor(this)); if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { SetExtractor(ArchiveFileType.WIM, new WimExtractor(this)); diff --git a/RecursiveExtractor/Extractors/AceExtractor.cs b/RecursiveExtractor/Extractors/AceExtractor.cs new file mode 100644 index 00000000..35a1a768 --- /dev/null +++ b/RecursiveExtractor/Extractors/AceExtractor.cs @@ -0,0 +1,170 @@ +using SharpCompress.Readers; +using SharpCompress.Readers.Ace; +using System; +using System.Collections.Generic; +using System.IO; + +namespace Microsoft.CST.RecursiveExtractor.Extractors +{ + /// + /// The ACE Archive extractor implementation + /// + public class AceExtractor : AsyncExtractorInterface + { + /// + /// The constructor takes the Extractor context for recursion. + /// + /// The Extractor context. + public AceExtractor(Extractor context) + { + Context = context; + } + private readonly NLog.Logger Logger = NLog.LogManager.GetCurrentClassLogger(); + + internal Extractor Context { get; } + + /// + /// Extracts an ACE archive + /// + /// + public async IAsyncEnumerable ExtractAsync(FileEntry fileEntry, ExtractorOptions options, ResourceGovernor governor, bool topLevel = true) + { + AceReader? aceReader = null; + try + { + aceReader = AceReader.Open(fileEntry.Content, new ReaderOptions() + { + LeaveStreamOpen = true + }); + } + catch (Exception e) + { + Logger.Debug(Extractor.FAILED_PARSING_ERROR_MESSAGE_STRING, ArchiveFileType.ACE, fileEntry.FullPath, string.Empty, e.GetType()); + } + + if (aceReader != null) + { + using (aceReader) + { + while (aceReader.MoveToNextEntry()) + { + var entry = aceReader.Entry; + if (entry.IsDirectory) + { + continue; + } + + var name = entry.Key?.Replace('/', Path.DirectorySeparatorChar); + if (string.IsNullOrEmpty(name)) + { + Logger.Debug(Extractor.ENTRY_MISSING_NAME_ERROR_MESSAGE_STRING, ArchiveFileType.ACE, fileEntry.FullPath); + continue; + } + + var newFileEntry = await FileEntry.FromStreamAsync(name, aceReader.OpenEntryStream(), fileEntry, entry.CreatedTime, entry.LastModifiedTime, entry.LastAccessedTime, memoryStreamCutoff: options.MemoryStreamCutoff).ConfigureAwait(false); + if (newFileEntry != null) + { + governor.CheckResourceGovernor(newFileEntry.Content.Length); + + if (options.Recurse || topLevel) + { + await foreach (var innerEntry in Context.ExtractAsync(newFileEntry, options, governor, false)) + { + yield return innerEntry; + } + } + else + { + yield return newFileEntry; + } + } + } + } + } + else + { + if (options.ExtractSelfOnFail) + { + fileEntry.EntryStatus = FileEntryStatus.FailedArchive; + yield return fileEntry; + } + } + } + + /// + /// Extracts an ACE archive + /// + /// + public IEnumerable Extract(FileEntry fileEntry, ExtractorOptions options, ResourceGovernor governor, bool topLevel = true) + { + AceReader? aceReader = null; + try + { + aceReader = AceReader.Open(fileEntry.Content, new ReaderOptions() + { + LeaveStreamOpen = true + }); + } + catch (Exception e) + { + Logger.Debug(Extractor.FAILED_PARSING_ERROR_MESSAGE_STRING, ArchiveFileType.ACE, fileEntry.FullPath, string.Empty, e.GetType()); + } + + if (aceReader != null) + { + using (aceReader) + { + while (aceReader.MoveToNextEntry()) + { + var entry = aceReader.Entry; + if (entry.IsDirectory) + { + continue; + } + + FileEntry? newFileEntry = null; + try + { + var stream = aceReader.OpenEntryStream(); + var name = entry.Key?.Replace('/', Path.DirectorySeparatorChar); + if (string.IsNullOrEmpty(name)) + { + Logger.Debug(Extractor.ENTRY_MISSING_NAME_ERROR_MESSAGE_STRING, ArchiveFileType.ACE, fileEntry.FullPath); + continue; + } + newFileEntry = new FileEntry(name, stream, fileEntry, false, entry.CreatedTime, entry.LastModifiedTime, entry.LastAccessedTime, memoryStreamCutoff: options.MemoryStreamCutoff); + } + catch (Exception e) + { + Logger.Debug(Extractor.FAILED_PARSING_ERROR_MESSAGE_STRING, ArchiveFileType.ACE, fileEntry.FullPath, entry.Key, e.GetType()); + } + if (newFileEntry != null) + { + governor.CheckResourceGovernor(newFileEntry.Content.Length); + + if (options.Recurse || topLevel) + { + foreach (var innerEntry in Context.Extract(newFileEntry, options, governor, false)) + { + yield return innerEntry; + } + } + else + { + yield return newFileEntry; + } + } + } + } + } + else + { + if (options.ExtractSelfOnFail) + { + fileEntry.EntryStatus = FileEntryStatus.FailedArchive; + yield return fileEntry; + } + } + } + } +} diff --git a/RecursiveExtractor/Extractors/ArcExtractor.cs b/RecursiveExtractor/Extractors/ArcExtractor.cs new file mode 100644 index 00000000..47257aa0 --- /dev/null +++ b/RecursiveExtractor/Extractors/ArcExtractor.cs @@ -0,0 +1,174 @@ +using SharpCompress.Readers; +using SharpCompress.Readers.Arc; +using System; +using System.Collections.Generic; +using System.IO; + +namespace Microsoft.CST.RecursiveExtractor.Extractors +{ + /// + /// The ARC Archive extractor implementation + /// + public class ArcExtractor : AsyncExtractorInterface + { + /// + /// The constructor takes the Extractor context for recursion. + /// + /// The Extractor context. + public ArcExtractor(Extractor context) + { + Context = context; + } + private readonly NLog.Logger Logger = NLog.LogManager.GetCurrentClassLogger(); + + internal Extractor Context { get; } + + /// + /// Extracts an ARC archive + /// + /// + public async IAsyncEnumerable ExtractAsync(FileEntry fileEntry, ExtractorOptions options, ResourceGovernor governor, bool topLevel = true) + { + ArcReader? arcReader = null; + try + { + arcReader = ArcReader.Open(fileEntry.Content, new ReaderOptions() + { + LeaveStreamOpen = true + }); + } + catch (Exception e) + { + Logger.Debug(Extractor.FAILED_PARSING_ERROR_MESSAGE_STRING, ArchiveFileType.ARC, fileEntry.FullPath, string.Empty, e.GetType()); + } + + if (arcReader != null) + { + using (arcReader) + { + while (arcReader.MoveToNextEntry()) + { + var entry = arcReader.Entry; + if (entry.IsDirectory) + { + continue; + } + + var name = entry.Key?.Replace('/', Path.DirectorySeparatorChar); + if (string.IsNullOrEmpty(name)) + { + Logger.Debug(Extractor.ENTRY_MISSING_NAME_ERROR_MESSAGE_STRING, ArchiveFileType.ARC, fileEntry.FullPath); + continue; + } + + var newFileEntry = await FileEntry.FromStreamAsync(name, arcReader.OpenEntryStream(), fileEntry, entry.CreatedTime, entry.LastModifiedTime, entry.LastAccessedTime, memoryStreamCutoff: options.MemoryStreamCutoff).ConfigureAwait(false); + if (newFileEntry != null) + { + // SharpCompress ARC does not expose entry sizes, so we check the resource governor + // after extraction using the actual decompressed content length. + governor.CheckResourceGovernor(newFileEntry.Content.Length); + + if (options.Recurse || topLevel) + { + await foreach (var innerEntry in Context.ExtractAsync(newFileEntry, options, governor, false)) + { + yield return innerEntry; + } + } + else + { + yield return newFileEntry; + } + } + } + } + } + else + { + if (options.ExtractSelfOnFail) + { + fileEntry.EntryStatus = FileEntryStatus.FailedArchive; + yield return fileEntry; + } + } + } + + /// + /// Extracts an ARC archive + /// + /// + public IEnumerable Extract(FileEntry fileEntry, ExtractorOptions options, ResourceGovernor governor, bool topLevel = true) + { + ArcReader? arcReader = null; + try + { + arcReader = ArcReader.Open(fileEntry.Content, new ReaderOptions() + { + LeaveStreamOpen = true + }); + } + catch (Exception e) + { + Logger.Debug(Extractor.FAILED_PARSING_ERROR_MESSAGE_STRING, ArchiveFileType.ARC, fileEntry.FullPath, string.Empty, e.GetType()); + } + + if (arcReader != null) + { + using (arcReader) + { + while (arcReader.MoveToNextEntry()) + { + var entry = arcReader.Entry; + if (entry.IsDirectory) + { + continue; + } + + FileEntry? newFileEntry = null; + try + { + var stream = arcReader.OpenEntryStream(); + var name = entry.Key?.Replace('/', Path.DirectorySeparatorChar); + if (string.IsNullOrEmpty(name)) + { + Logger.Debug(Extractor.ENTRY_MISSING_NAME_ERROR_MESSAGE_STRING, ArchiveFileType.ARC, fileEntry.FullPath); + continue; + } + newFileEntry = new FileEntry(name, stream, fileEntry, false, entry.CreatedTime, entry.LastModifiedTime, entry.LastAccessedTime, memoryStreamCutoff: options.MemoryStreamCutoff); + } + catch (Exception e) + { + Logger.Debug(Extractor.FAILED_PARSING_ERROR_MESSAGE_STRING, ArchiveFileType.ARC, fileEntry.FullPath, entry.Key, e.GetType()); + } + if (newFileEntry != null) + { + // SharpCompress ARC does not expose entry sizes, so we check the resource governor + // after extraction using the actual decompressed content length. + governor.CheckResourceGovernor(newFileEntry.Content.Length); + + if (options.Recurse || topLevel) + { + foreach (var innerEntry in Context.Extract(newFileEntry, options, governor, false)) + { + yield return innerEntry; + } + } + else + { + yield return newFileEntry; + } + } + } + } + } + else + { + if (options.ExtractSelfOnFail) + { + fileEntry.EntryStatus = FileEntryStatus.FailedArchive; + yield return fileEntry; + } + } + } + } +} diff --git a/RecursiveExtractor/Extractors/ArjExtractor.cs b/RecursiveExtractor/Extractors/ArjExtractor.cs new file mode 100644 index 00000000..43e15082 --- /dev/null +++ b/RecursiveExtractor/Extractors/ArjExtractor.cs @@ -0,0 +1,168 @@ +using SharpCompress.Readers; +using SharpCompress.Readers.Arj; +using System; +using System.Collections.Generic; +using System.IO; + +namespace Microsoft.CST.RecursiveExtractor.Extractors +{ + /// + /// The ARJ Archive extractor implementation + /// + public class ArjExtractor : AsyncExtractorInterface + { + /// + /// The constructor takes the Extractor context for recursion. + /// + /// The Extractor context. + public ArjExtractor(Extractor context) + { + Context = context; + } + private readonly NLog.Logger Logger = NLog.LogManager.GetCurrentClassLogger(); + + internal Extractor Context { get; } + + /// + /// Extracts an ARJ archive + /// + /// + public async IAsyncEnumerable ExtractAsync(FileEntry fileEntry, ExtractorOptions options, ResourceGovernor governor, bool topLevel = true) + { + ArjReader? arjReader = null; + try + { + arjReader = ArjReader.Open(fileEntry.Content, new ReaderOptions() + { + LeaveStreamOpen = true + }); + } + catch (Exception e) + { + Logger.Debug(Extractor.FAILED_PARSING_ERROR_MESSAGE_STRING, ArchiveFileType.ARJ, fileEntry.FullPath, string.Empty, e.GetType()); + } + + if (arjReader != null) + { + using (arjReader) + { + while (arjReader.MoveToNextEntry()) + { + var entry = arjReader.Entry; + if (entry.IsDirectory) + { + continue; + } + + governor.CheckResourceGovernor(entry.Size); + var name = entry.Key?.Replace('/', Path.DirectorySeparatorChar); + if (string.IsNullOrEmpty(name)) + { + Logger.Debug(Extractor.ENTRY_MISSING_NAME_ERROR_MESSAGE_STRING, ArchiveFileType.ARJ, fileEntry.FullPath); + continue; + } + + var newFileEntry = await FileEntry.FromStreamAsync(name, arjReader.OpenEntryStream(), fileEntry, entry.CreatedTime, entry.LastModifiedTime, entry.LastAccessedTime, memoryStreamCutoff: options.MemoryStreamCutoff).ConfigureAwait(false); + if (newFileEntry != null) + { + if (options.Recurse || topLevel) + { + await foreach (var innerEntry in Context.ExtractAsync(newFileEntry, options, governor, false)) + { + yield return innerEntry; + } + } + else + { + yield return newFileEntry; + } + } + } + } + } + else + { + if (options.ExtractSelfOnFail) + { + fileEntry.EntryStatus = FileEntryStatus.FailedArchive; + yield return fileEntry; + } + } + } + + /// + /// Extracts an ARJ archive + /// + /// + public IEnumerable Extract(FileEntry fileEntry, ExtractorOptions options, ResourceGovernor governor, bool topLevel = true) + { + ArjReader? arjReader = null; + try + { + arjReader = ArjReader.Open(fileEntry.Content, new ReaderOptions() + { + LeaveStreamOpen = true + }); + } + catch (Exception e) + { + Logger.Debug(Extractor.FAILED_PARSING_ERROR_MESSAGE_STRING, ArchiveFileType.ARJ, fileEntry.FullPath, string.Empty, e.GetType()); + } + + if (arjReader != null) + { + using (arjReader) + { + while (arjReader.MoveToNextEntry()) + { + var entry = arjReader.Entry; + if (entry.IsDirectory) + { + continue; + } + + governor.CheckResourceGovernor(entry.Size); + FileEntry? newFileEntry = null; + try + { + var stream = arjReader.OpenEntryStream(); + var name = entry.Key?.Replace('/', Path.DirectorySeparatorChar); + if (string.IsNullOrEmpty(name)) + { + Logger.Debug(Extractor.ENTRY_MISSING_NAME_ERROR_MESSAGE_STRING, ArchiveFileType.ARJ, fileEntry.FullPath); + continue; + } + newFileEntry = new FileEntry(name, stream, fileEntry, false, entry.CreatedTime, entry.LastModifiedTime, entry.LastAccessedTime, memoryStreamCutoff: options.MemoryStreamCutoff); + } + catch (Exception e) + { + Logger.Debug(Extractor.FAILED_PARSING_ERROR_MESSAGE_STRING, ArchiveFileType.ARJ, fileEntry.FullPath, entry.Key, e.GetType()); + } + if (newFileEntry != null) + { + if (options.Recurse || topLevel) + { + foreach (var innerEntry in Context.Extract(newFileEntry, options, governor, false)) + { + yield return innerEntry; + } + } + else + { + yield return newFileEntry; + } + } + } + } + } + else + { + if (options.ExtractSelfOnFail) + { + fileEntry.EntryStatus = FileEntryStatus.FailedArchive; + yield return fileEntry; + } + } + } + } +} diff --git a/RecursiveExtractor/MiniMagic.cs b/RecursiveExtractor/MiniMagic.cs index 64c914d7..b6a8622c 100644 --- a/RecursiveExtractor/MiniMagic.cs +++ b/RecursiveExtractor/MiniMagic.cs @@ -85,6 +85,18 @@ public enum ArchiveFileType /// DMG, /// + /// An ARJ compressed archive. + /// + ARJ, + /// + /// An ARC compressed archive. + /// + ARC, + /// + /// An ACE compressed archive. + /// + ACE, + /// /// Unused. /// INVALID @@ -120,7 +132,7 @@ public static ArchiveFileType DetectFileType(Stream fileStream) return ArchiveFileType.UNKNOWN; } var initialPosition = fileStream.Position; - var buffer = new byte[9]; + var buffer = new byte[14]; // DMG format uses the magic value 'koly' at the start of the 512 byte footer at the end of the file // Due to compression used, needs to be first or can be misidentified as other formats // https://newosxbook.com/DMG.html @@ -137,10 +149,10 @@ public static ArchiveFileType DetectFileType(Stream fileStream) } } - if (fileStream.Length >= 9) + if (fileStream.Length >= 14) { fileStream.Position = 0; - fileStream.ReadExactly(buffer, 0, 9); + fileStream.ReadExactly(buffer, 0, 14); fileStream.Position = initialPosition; if (buffer[0] == 0x50 && buffer[1] == 0x4B && buffer[2] == 0x03 && buffer[3] == 0x04) @@ -173,6 +185,21 @@ public static ArchiveFileType DetectFileType(Stream fileStream) { return ArchiveFileType.P7ZIP; } + // ARJ archive header starts with 0x60, 0xEA + if (buffer[0] == 0x60 && buffer[1] == 0xEA) + { + return ArchiveFileType.ARJ; + } + // ARC archive: marker byte 0x1A, then compression method (valid: 0x01-0x09 or 0x7F) + if (buffer[0] == 0x1A && ((buffer[1] >= 0x01 && buffer[1] <= 0x09) || buffer[1] == 0x7F)) + { + return ArchiveFileType.ARC; + } + // ACE archive: signature "**ACE**" at offset 7 + if (buffer[7] == 0x2A && buffer[8] == 0x2A && buffer[9] == 0x41 && buffer[10] == 0x43 && buffer[11] == 0x45 && buffer[12] == 0x2A && buffer[13] == 0x2A) + { + return ArchiveFileType.ACE; + } if (Encoding.ASCII.GetString(buffer[0..8]) == "MSWIM\0\0\0" || Encoding.ASCII.GetString(buffer[0..8]) == "WLPWM\0\0\0") { return ArchiveFileType.WIM; diff --git a/RecursiveExtractor/RecursiveExtractor.csproj b/RecursiveExtractor/RecursiveExtractor.csproj index a45aeb8c..1b6bd271 100644 --- a/RecursiveExtractor/RecursiveExtractor.csproj +++ b/RecursiveExtractor/RecursiveExtractor.csproj @@ -48,7 +48,7 @@ - + diff --git a/nuget.config b/nuget.config index 227ad0ce..248a5bb5 100644 --- a/nuget.config +++ b/nuget.config @@ -2,6 +2,6 @@ - + \ No newline at end of file From e03db48ec36ef1117a194e8d0c742c1be81d3009 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 18:27:36 +0000 Subject: [PATCH 3/8] Fix ACE test data file CRC and restore nuget.config Co-authored-by: gfs <98900+gfs@users.noreply.github.com> --- .../TestData/TestDataArchives/TestData.ace | Bin 117 -> 121 bytes nuget.config | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/RecursiveExtractor.Tests/TestData/TestDataArchives/TestData.ace b/RecursiveExtractor.Tests/TestData/TestDataArchives/TestData.ace index bfc21130a8882577e1ba9b6a4c7b156dbeed98d3..36861e7eb1834808013c330a6a982542e363079d 100644 GIT binary patch delta 64 zcmXS|G(9*&i~$I=v>csXwY0dnm_R%*(5TU7U}Rv>0dhbX1QdYy=H>T16HOghKzsl< CZV3|r delta 59 xcmb;{H9Z|B#sCCbT8_@HT3TFOOduW@#2(XTU}Rv>0dhbX1Qfs|1H(j92LSxy2Co1B diff --git a/nuget.config b/nuget.config index 248a5bb5..227ad0ce 100644 --- a/nuget.config +++ b/nuget.config @@ -2,6 +2,6 @@ - + \ No newline at end of file From 4f4764353201d8e08ef8e3819aec583a4908c6a8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 18:30:12 +0000 Subject: [PATCH 4/8] Improve ACE resource governor check to validate before decompression Co-authored-by: gfs <98900+gfs@users.noreply.github.com> --- RecursiveExtractor/Extractors/AceExtractor.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/RecursiveExtractor/Extractors/AceExtractor.cs b/RecursiveExtractor/Extractors/AceExtractor.cs index 35a1a768..ddf6d658 100644 --- a/RecursiveExtractor/Extractors/AceExtractor.cs +++ b/RecursiveExtractor/Extractors/AceExtractor.cs @@ -61,11 +61,10 @@ public async IAsyncEnumerable ExtractAsync(FileEntry fileEntry, Extra continue; } + governor.CheckResourceGovernor(entry.Size); var newFileEntry = await FileEntry.FromStreamAsync(name, aceReader.OpenEntryStream(), fileEntry, entry.CreatedTime, entry.LastModifiedTime, entry.LastAccessedTime, memoryStreamCutoff: options.MemoryStreamCutoff).ConfigureAwait(false); if (newFileEntry != null) { - governor.CheckResourceGovernor(newFileEntry.Content.Length); - if (options.Recurse || topLevel) { await foreach (var innerEntry in Context.ExtractAsync(newFileEntry, options, governor, false)) @@ -125,6 +124,7 @@ public IEnumerable Extract(FileEntry fileEntry, ExtractorOptions opti FileEntry? newFileEntry = null; try { + governor.CheckResourceGovernor(entry.Size); var stream = aceReader.OpenEntryStream(); var name = entry.Key?.Replace('/', Path.DirectorySeparatorChar); if (string.IsNullOrEmpty(name)) @@ -140,8 +140,6 @@ public IEnumerable Extract(FileEntry fileEntry, ExtractorOptions opti } if (newFileEntry != null) { - governor.CheckResourceGovernor(newFileEntry.Content.Length); - if (options.Recurse || topLevel) { foreach (var innerEntry in Context.Extract(newFileEntry, options, governor, false)) From fba41e21b5be2c7b22c20313a8765b9fcaf3bc81 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 18:57:14 +0000 Subject: [PATCH 5/8] Fix DetectFileType regression: read min(length, buffer) bytes and gate each signature check on bytes actually read Co-authored-by: gfs <98900+gfs@users.noreply.github.com> --- RecursiveExtractor/MiniMagic.cs | 72 +++++++++++++++++++++------------ 1 file changed, 47 insertions(+), 25 deletions(-) diff --git a/RecursiveExtractor/MiniMagic.cs b/RecursiveExtractor/MiniMagic.cs index b6a8622c..f5ea2362 100644 --- a/RecursiveExtractor/MiniMagic.cs +++ b/RecursiveExtractor/MiniMagic.cs @@ -149,56 +149,72 @@ public static ArchiveFileType DetectFileType(Stream fileStream) } } - if (fileStream.Length >= 14) + var bytesRead = 0; + if (fileStream.Length >= 2) { + var toRead = (int)Math.Min(fileStream.Length, buffer.Length); fileStream.Position = 0; - fileStream.ReadExactly(buffer, 0, 14); + fileStream.ReadExactly(buffer, 0, toRead); fileStream.Position = initialPosition; - - if (buffer[0] == 0x50 && buffer[1] == 0x4B && buffer[2] == 0x03 && buffer[3] == 0x04) - { - return ArchiveFileType.ZIP; - } + bytesRead = toRead; if (buffer[0] == 0x1F && buffer[1] == 0x8B) { return ArchiveFileType.GZIP; } - - if (buffer[0] == 0xFD && buffer[1] == 0x37 && buffer[2] == 0x7A && buffer[3] == 0x58 && buffer[4] == 0x5A && buffer[5] == 0x00) + // ARJ archive header starts with 0x60, 0xEA + if (buffer[0] == 0x60 && buffer[1] == 0xEA) { - return ArchiveFileType.XZ; + return ArchiveFileType.ARJ; } + // ARC archive: marker byte 0x1A, then compression method (valid: 0x01-0x09 or 0x7F) + if (buffer[0] == 0x1A && ((buffer[1] >= 0x01 && buffer[1] <= 0x09) || buffer[1] == 0x7F)) + { + return ArchiveFileType.ARC; + } + } + + if (bytesRead >= 3) + { if (buffer[0] == 0x42 && buffer[1] == 0x5A && buffer[2] == 0x68) { return ArchiveFileType.BZIP2; } - if (buffer[0] == 0x52 && buffer[1] == 0x61 && buffer[2] == 0x72 && buffer[3] == 0x21 && buffer[4] == 0x1A && buffer[5] == 0x07 && buffer[6] == 0x00) + } + + if (bytesRead >= 4) + { + if (buffer[0] == 0x50 && buffer[1] == 0x4B && buffer[2] == 0x03 && buffer[3] == 0x04) { - return ArchiveFileType.RAR; + return ArchiveFileType.ZIP; } - if (buffer[0] == 0x52 && buffer[1] == 0x61 && buffer[2] == 0x72 && buffer[3] == 0x21 && buffer[4] == 0x1A && buffer[5] == 0x07 && buffer[6] == 0x01 && buffer[7] == 0x00) + } + + if (bytesRead >= 6) + { + if (buffer[0] == 0xFD && buffer[1] == 0x37 && buffer[2] == 0x7A && buffer[3] == 0x58 && buffer[4] == 0x5A && buffer[5] == 0x00) { - return ArchiveFileType.RAR5; + return ArchiveFileType.XZ; } if (buffer[0] == 0x37 && buffer[1] == 0x7A && buffer[2] == 0xBC && buffer[3] == 0xAF && buffer[4] == 0x27 && buffer[5] == 0x1C) { return ArchiveFileType.P7ZIP; } - // ARJ archive header starts with 0x60, 0xEA - if (buffer[0] == 0x60 && buffer[1] == 0xEA) - { - return ArchiveFileType.ARJ; - } - // ARC archive: marker byte 0x1A, then compression method (valid: 0x01-0x09 or 0x7F) - if (buffer[0] == 0x1A && ((buffer[1] >= 0x01 && buffer[1] <= 0x09) || buffer[1] == 0x7F)) + } + + if (bytesRead >= 7) + { + if (buffer[0] == 0x52 && buffer[1] == 0x61 && buffer[2] == 0x72 && buffer[3] == 0x21 && buffer[4] == 0x1A && buffer[5] == 0x07 && buffer[6] == 0x00) { - return ArchiveFileType.ARC; + return ArchiveFileType.RAR; } - // ACE archive: signature "**ACE**" at offset 7 - if (buffer[7] == 0x2A && buffer[8] == 0x2A && buffer[9] == 0x41 && buffer[10] == 0x43 && buffer[11] == 0x45 && buffer[12] == 0x2A && buffer[13] == 0x2A) + } + + if (bytesRead >= 8) + { + if (buffer[0] == 0x52 && buffer[1] == 0x61 && buffer[2] == 0x72 && buffer[3] == 0x21 && buffer[4] == 0x1A && buffer[5] == 0x07 && buffer[6] == 0x01 && buffer[7] == 0x00) { - return ArchiveFileType.ACE; + return ArchiveFileType.RAR5; } if (Encoding.ASCII.GetString(buffer[0..8]) == "MSWIM\0\0\0" || Encoding.ASCII.GetString(buffer[0..8]) == "WLPWM\0\0\0") { @@ -256,6 +272,12 @@ public static ArchiveFileType DetectFileType(Stream fileStream) } } + // ACE archive: signature "**ACE**" at offset 7 + if (bytesRead >= 14 && buffer[7] == 0x2A && buffer[8] == 0x2A && buffer[9] == 0x41 && buffer[10] == 0x43 && buffer[11] == 0x45 && buffer[12] == 0x2A && buffer[13] == 0x2A) + { + return ArchiveFileType.ACE; + } + if (fileStream.Length >= 262) { fileStream.Position = 257; From 9444f820b68266daea7fa702e946e4227034003b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 19:03:49 +0000 Subject: [PATCH 6/8] Fix encrypted 7z stream position after SharpCompress 0.44.5 upgrade SharpCompress 0.44.5 now throws CryptographicException at Open() time for encrypted 7z archives, leaving the stream at a non-zero position. Subsequent password retry calls to Open() then fail with EndOfStreamException. Reset fileEntry.Content.Position = 0 before each retry, matching the pattern already used by RarExtractor and ZipExtractor. Co-authored-by: gfs <98900+gfs@users.noreply.github.com> --- RecursiveExtractor/Extractors/SevenZipExtractor.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/RecursiveExtractor/Extractors/SevenZipExtractor.cs b/RecursiveExtractor/Extractors/SevenZipExtractor.cs index 2f1e3a53..887f55dd 100644 --- a/RecursiveExtractor/Extractors/SevenZipExtractor.cs +++ b/RecursiveExtractor/Extractors/SevenZipExtractor.cs @@ -105,6 +105,7 @@ public async IAsyncEnumerable ExtractAsync(FileEntry fileEntry, Extra { try { + fileEntry.Content.Position = 0; sevenZipArchive = SevenZipArchive.Open(fileEntry.Content, new SharpCompress.Readers.ReaderOptions() { Password = password }); // When filenames are encrypted we can't access the size of individual files // But if we can accesss the total uncompressed size we have the right password From ac4537f392f3e5ce53350cbc29d41ccc7ae85081 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 21:28:26 +0000 Subject: [PATCH 7/8] Add FailedArchive status assertions to count-only test methods Three test methods (ExtractArchiveParallel, ExtractArchiveFromStreamAsync, ExtractArchiveFromStream) only verified result count, which could mask extraction failures for single-file archives (ARJ, ARC, ACE) since ExtractSelfOnFail returns 1 result with FailedArchive status. Now all three also assert that no result has FailedArchive status. Co-authored-by: gfs <98900+gfs@users.noreply.github.com> --- .../ExtractorTests/ExpectedNumFilesTests.cs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/RecursiveExtractor.Tests/ExtractorTests/ExpectedNumFilesTests.cs b/RecursiveExtractor.Tests/ExtractorTests/ExpectedNumFilesTests.cs index 627dcb93..ed5e6ec1 100644 --- a/RecursiveExtractor.Tests/ExtractorTests/ExpectedNumFilesTests.cs +++ b/RecursiveExtractor.Tests/ExtractorTests/ExpectedNumFilesTests.cs @@ -199,8 +199,7 @@ public void ExtractArchiveParallel(string fileName, int expectedNumFiles) var extractor = new Extractor(); var path = Path.Combine(Directory.GetCurrentDirectory(), "TestData", "TestDataArchives", fileName); var results = extractor.Extract(path, GetExtractorOptions(true)).ToList(); - var names = results.Select(x => x.FullPath); - var stringOfNames = string.Join("\n", names); + Assert.DoesNotContain(results, r => r.EntryStatus == FileEntryStatus.FailedArchive); Assert.Equal(expectedNumFiles, results.Count); } @@ -234,10 +233,16 @@ public async Task ExtractArchiveFromStreamAsync(string fileName, int expectedNum using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); var results = extractor.ExtractAsync(path, stream, new ExtractorOptions()); var numFiles = 0; + var numFailed = 0; await foreach (var result in results) { numFiles++; + if (result.EntryStatus == FileEntryStatus.FailedArchive) + { + numFailed++; + } } + Assert.Equal(0, numFailed); Assert.Equal(expectedNumFiles, numFiles); stream.Close(); } @@ -249,8 +254,9 @@ public void ExtractArchiveFromStream(string fileName, int expectedNumFiles) var extractor = new Extractor(); var path = Path.Combine(Directory.GetCurrentDirectory(), "TestData", "TestDataArchives", fileName); using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); - var results = extractor.Extract(path, stream, GetExtractorOptions()); - Assert.Equal(expectedNumFiles, results.Count()); + var resultsList = extractor.Extract(path, stream, GetExtractorOptions()).ToList(); + Assert.DoesNotContain(resultsList, r => r.EntryStatus == FileEntryStatus.FailedArchive); + Assert.Equal(expectedNumFiles, resultsList.Count); stream.Close(); } From 755b17e736e3c9d155a19e97ea31bf6cf1b3c9bf Mon Sep 17 00:00:00 2001 From: Giulia Stocco <98900+gfs@users.noreply.github.com> Date: Fri, 6 Feb 2026 23:34:58 +0000 Subject: [PATCH 8/8] Refactor entry stream handling in ACE, ARC, and ARJ extractors to use 'using' statements for better resource management --- .../ExtractorTests/ExpectedNumFilesTests.cs | 3 +- RecursiveExtractor/Extractors/AceExtractor.cs | 35 ++++++++------- RecursiveExtractor/Extractors/ArcExtractor.cs | 43 +++++++++++-------- RecursiveExtractor/Extractors/ArjExtractor.cs | 37 +++++++++------- 4 files changed, 66 insertions(+), 52 deletions(-) diff --git a/RecursiveExtractor.Tests/ExtractorTests/ExpectedNumFilesTests.cs b/RecursiveExtractor.Tests/ExtractorTests/ExpectedNumFilesTests.cs index ed5e6ec1..e36aead8 100644 --- a/RecursiveExtractor.Tests/ExtractorTests/ExpectedNumFilesTests.cs +++ b/RecursiveExtractor.Tests/ExtractorTests/ExpectedNumFilesTests.cs @@ -228,7 +228,7 @@ public async Task ExtractArchiveAsync(string fileName, int expectedNumFiles) [MemberData(nameof(ArchiveData))] public async Task ExtractArchiveFromStreamAsync(string fileName, int expectedNumFiles) { - var extractor = new Extractor(); + var extractor = new Extractor(); var path = Path.Combine(Directory.GetCurrentDirectory(), "TestData", "TestDataArchives", fileName); using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); var results = extractor.ExtractAsync(path, stream, new ExtractorOptions()); @@ -244,7 +244,6 @@ public async Task ExtractArchiveFromStreamAsync(string fileName, int expectedNum } Assert.Equal(0, numFailed); Assert.Equal(expectedNumFiles, numFiles); - stream.Close(); } [Theory] diff --git a/RecursiveExtractor/Extractors/AceExtractor.cs b/RecursiveExtractor/Extractors/AceExtractor.cs index ddf6d658..edc7930a 100644 --- a/RecursiveExtractor/Extractors/AceExtractor.cs +++ b/RecursiveExtractor/Extractors/AceExtractor.cs @@ -62,19 +62,22 @@ public async IAsyncEnumerable ExtractAsync(FileEntry fileEntry, Extra } governor.CheckResourceGovernor(entry.Size); - var newFileEntry = await FileEntry.FromStreamAsync(name, aceReader.OpenEntryStream(), fileEntry, entry.CreatedTime, entry.LastModifiedTime, entry.LastAccessedTime, memoryStreamCutoff: options.MemoryStreamCutoff).ConfigureAwait(false); - if (newFileEntry != null) + using var entryStream = aceReader.OpenEntryStream() { - if (options.Recurse || topLevel) + var newFileEntry = await FileEntry.FromStreamAsync(name, entryStream, fileEntry, entry.CreatedTime, entry.LastModifiedTime, entry.LastAccessedTime, memoryStreamCutoff: options.MemoryStreamCutoff).ConfigureAwait(false); + if (newFileEntry != null) { - await foreach (var innerEntry in Context.ExtractAsync(newFileEntry, options, governor, false)) + if (options.Recurse || topLevel) { - yield return innerEntry; + await foreach (var innerEntry in Context.ExtractAsync(newFileEntry, options, governor, false)) + { + yield return innerEntry; + } + } + else + { + yield return newFileEntry; } - } - else - { - yield return newFileEntry; } } } @@ -125,14 +128,16 @@ public IEnumerable Extract(FileEntry fileEntry, ExtractorOptions opti try { governor.CheckResourceGovernor(entry.Size); - var stream = aceReader.OpenEntryStream(); - var name = entry.Key?.Replace('/', Path.DirectorySeparatorChar); - if (string.IsNullOrEmpty(name)) + using var stream = aceReader.OpenEntryStream() { - Logger.Debug(Extractor.ENTRY_MISSING_NAME_ERROR_MESSAGE_STRING, ArchiveFileType.ACE, fileEntry.FullPath); - continue; + var name = entry.Key?.Replace('/', Path.DirectorySeparatorChar); + if (string.IsNullOrEmpty(name)) + { + Logger.Debug(Extractor.ENTRY_MISSING_NAME_ERROR_MESSAGE_STRING, ArchiveFileType.ACE, fileEntry.FullPath); + continue; + } + newFileEntry = new FileEntry(name, stream, fileEntry, false, entry.CreatedTime, entry.LastModifiedTime, entry.LastAccessedTime, memoryStreamCutoff: options.MemoryStreamCutoff); } - newFileEntry = new FileEntry(name, stream, fileEntry, false, entry.CreatedTime, entry.LastModifiedTime, entry.LastAccessedTime, memoryStreamCutoff: options.MemoryStreamCutoff); } catch (Exception e) { diff --git a/RecursiveExtractor/Extractors/ArcExtractor.cs b/RecursiveExtractor/Extractors/ArcExtractor.cs index 47257aa0..00a5ee28 100644 --- a/RecursiveExtractor/Extractors/ArcExtractor.cs +++ b/RecursiveExtractor/Extractors/ArcExtractor.cs @@ -61,23 +61,26 @@ public async IAsyncEnumerable ExtractAsync(FileEntry fileEntry, Extra continue; } - var newFileEntry = await FileEntry.FromStreamAsync(name, arcReader.OpenEntryStream(), fileEntry, entry.CreatedTime, entry.LastModifiedTime, entry.LastAccessedTime, memoryStreamCutoff: options.MemoryStreamCutoff).ConfigureAwait(false); - if (newFileEntry != null) + using var entryStream = arcReader.OpenEntryStream() { - // SharpCompress ARC does not expose entry sizes, so we check the resource governor - // after extraction using the actual decompressed content length. - governor.CheckResourceGovernor(newFileEntry.Content.Length); - - if (options.Recurse || topLevel) + var newFileEntry = await FileEntry.FromStreamAsync(name, entryStream, fileEntry, entry.CreatedTime, entry.LastModifiedTime, entry.LastAccessedTime, memoryStreamCutoff: options.MemoryStreamCutoff).ConfigureAwait(false); + if (newFileEntry != null) { - await foreach (var innerEntry in Context.ExtractAsync(newFileEntry, options, governor, false)) + // SharpCompress ARC does not expose entry sizes, so we check the resource governor + // after extraction using the actual decompressed content length. + governor.CheckResourceGovernor(newFileEntry.Content.Length); + + if (options.Recurse || topLevel) { - yield return innerEntry; + await foreach (var innerEntry in Context.ExtractAsync(newFileEntry, options, governor, false)) + { + yield return innerEntry; + } + } + else + { + yield return newFileEntry; } - } - else - { - yield return newFileEntry; } } } @@ -127,14 +130,16 @@ public IEnumerable Extract(FileEntry fileEntry, ExtractorOptions opti FileEntry? newFileEntry = null; try { - var stream = arcReader.OpenEntryStream(); - var name = entry.Key?.Replace('/', Path.DirectorySeparatorChar); - if (string.IsNullOrEmpty(name)) + using var stream = arcReader.OpenEntryStream() { - Logger.Debug(Extractor.ENTRY_MISSING_NAME_ERROR_MESSAGE_STRING, ArchiveFileType.ARC, fileEntry.FullPath); - continue; + var name = entry.Key?.Replace('/', Path.DirectorySeparatorChar); + if (string.IsNullOrEmpty(name)) + { + Logger.Debug(Extractor.ENTRY_MISSING_NAME_ERROR_MESSAGE_STRING, ArchiveFileType.ARC, fileEntry.FullPath); + continue; + } + newFileEntry = new FileEntry(name, stream, fileEntry, false, entry.CreatedTime, entry.LastModifiedTime, entry.LastAccessedTime, memoryStreamCutoff: options.MemoryStreamCutoff); } - newFileEntry = new FileEntry(name, stream, fileEntry, false, entry.CreatedTime, entry.LastModifiedTime, entry.LastAccessedTime, memoryStreamCutoff: options.MemoryStreamCutoff); } catch (Exception e) { diff --git a/RecursiveExtractor/Extractors/ArjExtractor.cs b/RecursiveExtractor/Extractors/ArjExtractor.cs index 43e15082..6cf6881e 100644 --- a/RecursiveExtractor/Extractors/ArjExtractor.cs +++ b/RecursiveExtractor/Extractors/ArjExtractor.cs @@ -62,19 +62,22 @@ public async IAsyncEnumerable ExtractAsync(FileEntry fileEntry, Extra continue; } - var newFileEntry = await FileEntry.FromStreamAsync(name, arjReader.OpenEntryStream(), fileEntry, entry.CreatedTime, entry.LastModifiedTime, entry.LastAccessedTime, memoryStreamCutoff: options.MemoryStreamCutoff).ConfigureAwait(false); - if (newFileEntry != null) - { - if (options.Recurse || topLevel) + using (var entryStream = arjReader.OpenEntryStream()) + { + var newFileEntry = await FileEntry.FromStreamAsync(name, entryStream, fileEntry, entry.CreatedTime, entry.LastModifiedTime, entry.LastAccessedTime, memoryStreamCutoff: options.MemoryStreamCutoff).ConfigureAwait(false); + if (newFileEntry != null) { - await foreach (var innerEntry in Context.ExtractAsync(newFileEntry, options, governor, false)) + if (options.Recurse || topLevel) { - yield return innerEntry; + await foreach (var innerEntry in Context.ExtractAsync(newFileEntry, options, governor, false)) + { + yield return innerEntry; + } + } + else + { + yield return newFileEntry; } - } - else - { - yield return newFileEntry; } } } @@ -125,14 +128,16 @@ public IEnumerable Extract(FileEntry fileEntry, ExtractorOptions opti FileEntry? newFileEntry = null; try { - var stream = arjReader.OpenEntryStream(); - var name = entry.Key?.Replace('/', Path.DirectorySeparatorChar); - if (string.IsNullOrEmpty(name)) + using var stream = arjReader.OpenEntryStream() { - Logger.Debug(Extractor.ENTRY_MISSING_NAME_ERROR_MESSAGE_STRING, ArchiveFileType.ARJ, fileEntry.FullPath); - continue; + var name = entry.Key?.Replace('/', Path.DirectorySeparatorChar); + if (string.IsNullOrEmpty(name)) + { + Logger.Debug(Extractor.ENTRY_MISSING_NAME_ERROR_MESSAGE_STRING, ArchiveFileType.ARJ, fileEntry.FullPath); + continue; + } + newFileEntry = new FileEntry(name, stream, fileEntry, false, entry.CreatedTime, entry.LastModifiedTime, entry.LastAccessedTime, memoryStreamCutoff: options.MemoryStreamCutoff); } - newFileEntry = new FileEntry(name, stream, fileEntry, false, entry.CreatedTime, entry.LastModifiedTime, entry.LastAccessedTime, memoryStreamCutoff: options.MemoryStreamCutoff); } catch (Exception e) {