diff --git a/Pipelines/recursive-extractor-pr.yml b/Pipelines/recursive-extractor-pr.yml index 3476419e..be9ac8a4 100644 --- a/Pipelines/recursive-extractor-pr.yml +++ b/Pipelines/recursive-extractor-pr.yml @@ -42,7 +42,7 @@ extends: poolName: MSSecurity-1ES-Build-Agents-Pool poolImage: MSSecurity-1ES-Windows-2022 poolOs: windows - dotnetTestArgs: '-- --coverage --report-trx' + dotnetTestArgs: '--logger trx --results-directory TestResults -- --coverage' includeNuGetOrg: false nugetFeedsToUse: 'config' nugetConfigPath: 'nuget.config' @@ -57,7 +57,7 @@ extends: poolName: MSSecurity-1ES-Build-Agents-Pool poolImage: MSSecurity-1ES-Windows-2022 poolOs: windows - dotnetTestArgs: '-- --coverage --report-trx' + dotnetTestArgs: '--logger trx --results-directory TestResults -- --coverage' includeNuGetOrg: false nugetFeedsToUse: 'config' nugetConfigPath: 'nuget.config' diff --git a/Pipelines/recursive-extractor-release.yml b/Pipelines/recursive-extractor-release.yml index b2a85fc8..ab1dd80d 100644 --- a/Pipelines/recursive-extractor-release.yml +++ b/Pipelines/recursive-extractor-release.yml @@ -43,7 +43,7 @@ extends: poolName: MSSecurity-1ES-Build-Agents-Pool poolImage: MSSecurity-1ES-Windows-2022 poolOs: windows - dotnetTestArgs: '-- --coverage --report-trx' + dotnetTestArgs: '--logger trx --results-directory TestResults -- --coverage' includeNuGetOrg: false nugetFeedsToUse: 'config' nugetConfigPath: 'nuget.config' @@ -57,7 +57,7 @@ extends: poolName: MSSecurity-1ES-Build-Agents-Pool poolImage: MSSecurity-1ES-Windows-2022 poolOs: windows - dotnetTestArgs: '-- --coverage --report-trx' + dotnetTestArgs: '--logger trx --results-directory TestResults -- --coverage' includeNuGetOrg: false nugetFeedsToUse: 'config' nugetConfigPath: 'nuget.config' diff --git a/RecursiveExtractor.Tests/AssemblyInfo.cs b/RecursiveExtractor.Tests/AssemblyInfo.cs new file mode 100644 index 00000000..38707aef --- /dev/null +++ b/RecursiveExtractor.Tests/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +[assembly: Parallelize(Workers = 0, Scope = ExecutionScope.MethodLevel)] diff --git a/RecursiveExtractor.Tests/ExtractorTests/BaseExtractorTestClass.cs b/RecursiveExtractor.Tests/ExtractorTests/BaseExtractorTestClass.cs index d223a1a1..7c301b58 100644 --- a/RecursiveExtractor.Tests/ExtractorTests/BaseExtractorTestClass.cs +++ b/RecursiveExtractor.Tests/ExtractorTests/BaseExtractorTestClass.cs @@ -8,6 +8,8 @@ namespace RecursiveExtractor.Tests.ExtractorTests; public class BaseExtractorTestClass { + public TestContext TestContext { get; set; } = null!; + [ClassCleanup] public static void ClassCleanup() { diff --git a/RecursiveExtractor.Tests/ExtractorTests/CustomExtractorTests.cs b/RecursiveExtractor.Tests/ExtractorTests/CustomExtractorTests.cs index 547ba87d..281e1c95 100644 --- a/RecursiveExtractor.Tests/ExtractorTests/CustomExtractorTests.cs +++ b/RecursiveExtractor.Tests/ExtractorTests/CustomExtractorTests.cs @@ -12,6 +12,8 @@ namespace RecursiveExtractor.Tests.ExtractorTests; [TestClass] public class CustomExtractorTests { + public TestContext TestContext { get; set; } = null!; + /// /// A simple test custom extractor that extracts files with a specific magic number /// For testing purposes, it recognizes files starting with "CUSTOM1" @@ -127,7 +129,7 @@ public void Constructor_WithCustomExtractors_RegistersExtractors() var customExtractor = new TestCustomExtractor(null!); var extractor = new Extractor(new[] { customExtractor }); - Assert.AreEqual(1, extractor.CustomExtractors.Count); + Assert.HasCount(1, extractor.CustomExtractors); } [TestMethod] @@ -137,7 +139,7 @@ public void Constructor_WithMultipleCustomExtractors_RegistersAll() var customExtractor2 = new SecondTestCustomExtractor(null!); var extractor = new Extractor(new ICustomAsyncExtractor[] { customExtractor1, customExtractor2 }); - Assert.AreEqual(2, extractor.CustomExtractors.Count); + Assert.HasCount(2, extractor.CustomExtractors); } [TestMethod] @@ -146,7 +148,7 @@ public void Constructor_WithNullInCollection_IgnoresNull() var customExtractor = new TestCustomExtractor(null!); var extractor = new Extractor(new ICustomAsyncExtractor[] { customExtractor, null! }); - Assert.AreEqual(1, extractor.CustomExtractors.Count); + Assert.HasCount(1, extractor.CustomExtractors); } [TestMethod] @@ -154,7 +156,7 @@ public void Constructor_WithNullCollection_CreatesEmptyExtractor() { var extractor = new Extractor((IEnumerable)null!); - Assert.AreEqual(0, extractor.CustomExtractors.Count); + Assert.IsEmpty(extractor.CustomExtractors); } [TestMethod] @@ -167,7 +169,7 @@ public void Extract_WithMatchingCustomExtractor_UsesCustomExtractor() var testData = System.Text.Encoding.ASCII.GetBytes("CUSTOM1 This is test data"); var results = extractor.Extract("test.custom", testData).ToList(); - Assert.AreEqual(1, results.Count); + Assert.HasCount(1, results); Assert.AreEqual("extracted_from_custom.txt", results[0].Name); // Read the content to verify it was processed by our custom extractor @@ -185,9 +187,9 @@ public async Task ExtractAsync_WithMatchingCustomExtractor_UsesCustomExtractor() // Create a test file with the custom magic bytes var testData = System.Text.Encoding.ASCII.GetBytes("CUSTOM1 This is test data"); - var results = await extractor.ExtractAsync("test.custom", testData).ToListAsync(); + var results = await extractor.ExtractAsync("test.custom", testData).ToListAsync(TestContext.CancellationTokenSource.Token); - Assert.AreEqual(1, results.Count); + Assert.HasCount(1, results); Assert.AreEqual("extracted_from_custom.txt", results[0].Name); // Read the content to verify it was processed by our custom extractor @@ -208,7 +210,7 @@ public void Extract_WithoutMatchingCustomExtractor_ReturnsOriginalFile() var results = extractor.Extract("test.txt", testData).ToList(); // Should return the original file since no custom extractor matched - Assert.AreEqual(1, results.Count); + Assert.HasCount(1, results); Assert.AreEqual("test.txt", results[0].Name); // Verify it's the original content @@ -230,13 +232,13 @@ public void Extract_MultipleCustomExtractors_UsesCorrectOne() // Test with first custom format var testData1 = System.Text.Encoding.ASCII.GetBytes("CUSTOM1 data"); var results1 = extractor.Extract("test1.custom", testData1).ToList(); - Assert.AreEqual(1, results1.Count); + Assert.HasCount(1, results1); Assert.AreEqual("extracted_from_custom.txt", results1[0].Name); // Test with second custom format var testData2 = System.Text.Encoding.ASCII.GetBytes("CUSTOM2 data"); var results2 = extractor.Extract("test2.custom", testData2).ToList(); - Assert.AreEqual(1, results2.Count); + Assert.HasCount(1, results2); Assert.AreEqual("extracted_from_second_custom.txt", results2[0].Name); } @@ -250,7 +252,7 @@ public void Extract_NoCustomExtractors_ReturnsOriginalFile() var results = extractor.Extract("test.custom", testData).ToList(); // Should return the original file since no custom extractor is registered - Assert.AreEqual(1, results.Count); + Assert.HasCount(1, results); Assert.AreEqual("test.custom", results[0].Name); } @@ -267,7 +269,7 @@ public void Extract_CustomExtractorForKnownFormat_UsesBuiltInExtractor() var results = extractor.Extract(path).ToList(); // Should extract the ZIP normally, not use the custom extractor - Assert.IsTrue(results.Count > 0); + Assert.IsGreaterThan(results.Count, 0); Assert.IsTrue(results.Any(r => r.Name.Contains("EmptyFile"))); } } diff --git a/RecursiveExtractor.Tests/ExtractorTests/DisposeBehaviorTests.cs b/RecursiveExtractor.Tests/ExtractorTests/DisposeBehaviorTests.cs index bab30610..08b9b91c 100644 --- a/RecursiveExtractor.Tests/ExtractorTests/DisposeBehaviorTests.cs +++ b/RecursiveExtractor.Tests/ExtractorTests/DisposeBehaviorTests.cs @@ -11,7 +11,7 @@ namespace RecursiveExtractor.Tests.ExtractorTests; [TestClass] public class DisposeBehaviorTests : BaseExtractorTestClass { - [DataTestMethod] + [TestMethod] [DataRow("TestData.7z", 3, false)] [DataRow("TestData.tar", 6, false)] [DataRow("TestData.rar", 3, false)] @@ -43,8 +43,6 @@ public class DisposeBehaviorTests : BaseExtractorTestClass [DataRow("EmptyFile.txt", 1, true)] [DataRow("TestDataArchivesNested.Zip", 54, true)] [DataRow("TestDataArchivesNested.Zip", 54, false)] - [DataRow("TestDataArchivesNested.Zip", 54, true)] - [DataRow("TestDataArchivesNested.Zip", 54, false)] public void ExtractArchiveAndDisposeWhileEnumerating(string fileName, int expectedNumFiles = 3, bool parallel = false) { @@ -63,11 +61,11 @@ public void ExtractArchiveAndDisposeWhileEnumerating(string fileName, int expect Assert.AreEqual(expectedNumFiles, disposedResults.Count); foreach (var disposedResult in disposedResults) { - Assert.ThrowsException(() => disposedResult.Content.Position); + Assert.ThrowsExactly(() => disposedResult.Content.Position); } } - [DataTestMethod] + [TestMethod] [DataRow("TestData.7z", 3, false)] [DataRow("TestData.tar", 6, false)] [DataRow("TestData.rar", 3, false)] @@ -99,8 +97,6 @@ public void ExtractArchiveAndDisposeWhileEnumerating(string fileName, int expect [DataRow("EmptyFile.txt", 1, true)] [DataRow("TestDataArchivesNested.Zip", 54, true)] [DataRow("TestDataArchivesNested.Zip", 54, false)] - [DataRow("TestDataArchivesNested.Zip", 54, true)] - [DataRow("TestDataArchivesNested.Zip", 54, false)] public async Task ExtractArchiveAndDisposeWhileEnumeratingAsync(string fileName, int expectedNumFiles = 3, bool parallel = false) { @@ -119,11 +115,11 @@ public async Task ExtractArchiveAndDisposeWhileEnumeratingAsync(string fileName, Assert.AreEqual(expectedNumFiles, disposedResults.Count); foreach (var disposedResult in disposedResults) { - Assert.ThrowsException(() => disposedResult.Content.Position); + Assert.ThrowsExactly(() => disposedResult.Content.Position); } } - [DataTestMethod] + [TestMethod] [DataRow("TestData.zip")] public void EnsureDisposedWithExtractToDirectory(string fileName) { @@ -146,7 +142,7 @@ public void EnsureDisposedWithExtractToDirectory(string fileName) } } - [DataTestMethod] + [TestMethod] [DataRow("TestData.zip")] public async Task EnsureDisposedWithExtractToDirectoryAsync(string fileName) { diff --git a/RecursiveExtractor.Tests/ExtractorTests/EncryptedArchiveTests.cs b/RecursiveExtractor.Tests/ExtractorTests/EncryptedArchiveTests.cs index 9d034e73..05b7d9e2 100644 --- a/RecursiveExtractor.Tests/ExtractorTests/EncryptedArchiveTests.cs +++ b/RecursiveExtractor.Tests/ExtractorTests/EncryptedArchiveTests.cs @@ -11,7 +11,7 @@ namespace RecursiveExtractor.Tests.ExtractorTests; [TestClass] public class EncryptedArchiveTests : BaseExtractorTestClass { - [DataTestMethod] + [TestMethod] [DataRow("TestDataEncryptedZipCrypto.zip")] [DataRow("TestDataEncryptedAes.zip")] [DataRow("TestDataEncrypted.7z")] @@ -26,7 +26,7 @@ public void FileTypeSetCorrectlyForEncryptedArchives(string fileName, int expect Assert.AreEqual(FileEntryStatus.EncryptedArchive, results.First().EntryStatus); } - [DataTestMethod] + [TestMethod] [DataRow("TestDataEncryptedZipCrypto.zip")] [DataRow("TestDataEncryptedAes.zip")] [DataRow("TestDataEncrypted.7z")] @@ -46,7 +46,7 @@ public async Task FileTypeSetCorrectlyForEncryptedArchivesAsync(string fileName, Assert.AreEqual(FileEntryStatus.EncryptedArchive, results.First().EntryStatus); } - [DataTestMethod] + [TestMethod] [DataRow("TestDataEncryptedZipCrypto.zip")] [DataRow("TestDataEncryptedAes.zip")] [DataRow("TestDataEncrypted.7z")] @@ -64,7 +64,7 @@ public void ExtractEncryptedArchive(string fileName, int expectedNumFiles = 3) Assert.AreEqual(0, results.Count(x => x.EntryStatus == FileEntryStatus.EncryptedArchive || x.EntryStatus == FileEntryStatus.FailedArchive)); } - [DataTestMethod] + [TestMethod] [DataRow("TestDataEncryptedZipCrypto.zip")] [DataRow("TestDataEncryptedAes.zip")] [DataRow("TestDataEncrypted.7z")] diff --git a/RecursiveExtractor.Tests/ExtractorTests/ExpectedNumFilesTests.cs b/RecursiveExtractor.Tests/ExtractorTests/ExpectedNumFilesTests.cs index ab9544cf..c8542d7f 100644 --- a/RecursiveExtractor.Tests/ExtractorTests/ExpectedNumFilesTests.cs +++ b/RecursiveExtractor.Tests/ExtractorTests/ExpectedNumFilesTests.cs @@ -43,6 +43,8 @@ public static IEnumerable ArchiveData new object[] { "TestDataArchivesNested.Zip", 54 }, new object[] { "UdfTest.iso", 3 }, new object[] { "UdfTestWithMultiSystem.iso", 3 }, + new object[] { "TestData.arj", 1 }, + new object[] { "TestData.arc", 1 }, // new object[] { "HfsSampleUDCO.dmg", 2 } }; } @@ -75,6 +77,8 @@ public static IEnumerable NoRecursionData new object[] { "EmptyFile.txt", 1 }, new object[] { "TestDataArchivesNested.Zip", 14 }, new object[] { "UdfTestWithMultiSystem.iso", 3 }, + new object[] { "TestData.arj", 1 }, + new object[] { "TestData.arc", 1 }, // new object[] { "HfsSampleUDCO.dmg", 2 } }; } @@ -223,9 +227,9 @@ public async Task ExtractArchiveAsync(string fileName, int expectedNumFiles) [DynamicData(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); + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); var results = extractor.ExtractAsync(path, stream, new ExtractorOptions()); var numFiles = 0; await foreach (var result in results) @@ -240,9 +244,9 @@ public async Task ExtractArchiveFromStreamAsync(string fileName, int expectedNum [DynamicData(nameof(ArchiveData))] public void ExtractArchiveFromStream(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); + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); var results = extractor.Extract(path, stream, GetExtractorOptions()); Assert.AreEqual(expectedNumFiles, results.Count()); stream.Close(); diff --git a/RecursiveExtractor.Tests/ExtractorTests/FilterTests.cs b/RecursiveExtractor.Tests/ExtractorTests/FilterTests.cs index 4fcc28aa..c5828a0f 100644 --- a/RecursiveExtractor.Tests/ExtractorTests/FilterTests.cs +++ b/RecursiveExtractor.Tests/ExtractorTests/FilterTests.cs @@ -10,7 +10,7 @@ namespace RecursiveExtractor.Tests.ExtractorTests; [TestClass] public class FilterTests : BaseExtractorTestClass { - [DataTestMethod] + [TestMethod] [DataRow("TestData.zip")] [DataRow("TestData.7z")] [DataRow("TestData.tar")] @@ -42,7 +42,7 @@ public async Task ExtractArchiveAsyncAllowFiltered(string fileName, int expected Assert.AreEqual(expectedNumFiles, numResults); } - [DataTestMethod] + [TestMethod] [DataRow("TestData.zip")] [DataRow("TestData.7z")] [DataRow("TestData.tar")] @@ -68,7 +68,7 @@ public void ExtractArchiveAllowFiltered(string fileName, int expectedNumFiles = Assert.AreEqual(expectedNumFiles, results.Count()); } - [DataTestMethod] + [TestMethod] [DataRow("TestData.zip")] [DataRow("TestData.7z")] [DataRow("TestData.tar")] @@ -94,7 +94,7 @@ public void ExtractArchiveParallelAllowFiltered(string fileName, int expectedNum Assert.AreEqual(expectedNumFiles, results.Count()); } - [DataTestMethod] + [TestMethod] [DataRow("TestData.zip", 4)] [DataRow("TestData.7z")] [DataRow("TestData.tar", 5)] @@ -119,7 +119,7 @@ public void ExtractArchiveDenyFiltered(string fileName, int expectedNumFiles = 2 Assert.AreEqual(expectedNumFiles, results.Count()); } - [DataTestMethod] + [TestMethod] [DataRow("TestData.zip", 4)] [DataRow("TestData.7z")] [DataRow("TestData.tar", 5)] @@ -145,7 +145,7 @@ public void ExtractArchiveParallelDenyFiltered(string fileName, int expectedNumF Assert.AreEqual(expectedNumFiles, results.Count()); } - [DataTestMethod] + [TestMethod] [DataRow("TestData.zip", 4)] [DataRow("TestData.7z")] [DataRow("TestData.tar", 5)] @@ -177,7 +177,7 @@ public async Task ExtractArchiveAsyncDenyFiltered(string fileName, int expectedN Assert.AreEqual(expectedNumFiles, numResults); } - [DataTestMethod] + [TestMethod] [DataRow(ArchiveFileType.ZIP, new[] { ArchiveFileType.ZIP }, new ArchiveFileType[] { }, false)] [DataRow(ArchiveFileType.ZIP, new[] { ArchiveFileType.TAR }, new ArchiveFileType[] { }, true)] [DataRow(ArchiveFileType.ZIP, new ArchiveFileType[] { }, new[] { ArchiveFileType.ZIP }, true)] diff --git a/RecursiveExtractor.Tests/ExtractorTests/MiniMagicTests.cs b/RecursiveExtractor.Tests/ExtractorTests/MiniMagicTests.cs index 016ef133..ee4de521 100644 --- a/RecursiveExtractor.Tests/ExtractorTests/MiniMagicTests.cs +++ b/RecursiveExtractor.Tests/ExtractorTests/MiniMagicTests.cs @@ -7,7 +7,7 @@ namespace RecursiveExtractor.Tests.ExtractorTests; [TestClass] public class MiniMagicTests : BaseExtractorTestClass { - [DataTestMethod] + [TestMethod] [DataRow("TestData.zip", ArchiveFileType.ZIP)] [DataRow("TestData.7z", ArchiveFileType.P7ZIP)] [DataRow("TestData.Tar", ArchiveFileType.TAR)] @@ -24,11 +24,13 @@ public class MiniMagicTests : BaseExtractorTestClass [DataRow("TestData.wim", ArchiveFileType.WIM)] [DataRow("Empty.vmdk", ArchiveFileType.VMDK)] [DataRow("HfsSampleUDCO.dmg", ArchiveFileType.DMG)] + [DataRow("TestData.arj", ArchiveFileType.ARJ)] + [DataRow("TestData.arc", ArchiveFileType.ARC)] [DataRow("EmptyFile.txt", ArchiveFileType.UNKNOWN)] public void TestMiniMagic(string fileName, ArchiveFileType expectedArchiveFileType) { var path = Path.Combine(Directory.GetCurrentDirectory(), "TestData", "TestDataArchives", fileName); - using var fs = new FileStream(path, FileMode.Open); + using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); // Test just based on the content var fileEntry = new FileEntry("NoName", fs); diff --git a/RecursiveExtractor.Tests/ExtractorTests/MiscTests.cs b/RecursiveExtractor.Tests/ExtractorTests/MiscTests.cs index e375904a..5b9aa104 100644 --- a/RecursiveExtractor.Tests/ExtractorTests/MiscTests.cs +++ b/RecursiveExtractor.Tests/ExtractorTests/MiscTests.cs @@ -10,7 +10,9 @@ namespace RecursiveExtractor.Tests.ExtractorTests; [TestClass] public class MiscTests { - [DataTestMethod] + public TestContext TestContext { get; set; } = null!; + + [TestMethod] [DataRow("TestDataCorrupt.tar", false, 0, 1)] [DataRow("TestDataCorrupt.tar", true, 1, 1)] [DataRow("TestDataCorrupt.tar.zip", false, 0, 2)] @@ -20,7 +22,7 @@ public async Task ExtractCorruptArchiveAsync(string fileName, bool requireTopLev var extractor = new Extractor(); var path = Path.Combine(Directory.GetCurrentDirectory(), "TestData", "TestDataArchives", fileName); var results = await extractor.ExtractAsync(path, - new ExtractorOptions() { RequireTopLevelToBeArchive = requireTopLevelToBeArchive }).ToListAsync(); + new ExtractorOptions() { RequireTopLevelToBeArchive = requireTopLevelToBeArchive }).ToListAsync(TestContext.CancellationTokenSource.Token); Assert.AreEqual(expectedNumFiles, results.Count); @@ -28,23 +30,23 @@ public async Task ExtractCorruptArchiveAsync(string fileName, bool requireTopLev Assert.AreEqual(expectedNumFailures, actualNumberOfFailedArchives); } - [DataTestMethod] + [TestMethod] [DataRow("Lorem.txt", true, 1)] [DataRow("Lorem.txt", false, 0)] public async Task ExtractFlatFileAsync(string fileName, bool requireTopLevelToBeArchive, int expectedNumFailures) { var extractor = new Extractor(); var path = Path.Combine(Directory.GetCurrentDirectory(), "TestData", "TestData", fileName); - var results = await extractor.ExtractAsync(path, new ExtractorOptions(){ RequireTopLevelToBeArchive = requireTopLevelToBeArchive }).ToListAsync(); + var results = await extractor.ExtractAsync(path, new ExtractorOptions(){ RequireTopLevelToBeArchive = requireTopLevelToBeArchive }).ToListAsync(TestContext.CancellationTokenSource.Token); - Assert.AreEqual(1, results.Count); + Assert.HasCount(1, results); var actualNumberOfFailedArchives = results.Count(x => x.EntryStatus == FileEntryStatus.FailedArchive); Assert.AreEqual(expectedNumFailures, actualNumberOfFailedArchives); } - [DataTestMethod] + [TestMethod] [DataRow("TestDataCorrupt.tar", false, 0, 1)] [DataRow("TestDataCorrupt.tar", true, 1, 1)] [DataRow("TestDataCorrupt.tar.zip", false, 0, 2)] @@ -61,7 +63,7 @@ public void ExtractCorruptArchive(string fileName, bool requireTopLevelToBeArchi Assert.AreEqual(expectedNumFailures, actualNumberOfFailedArchives); } - [DataTestMethod] + [TestMethod] [DataRow("Lorem.txt", true, 1)] [DataRow("Lorem.txt", false, 0)] public void ExtractFlatFile(string fileName, bool requireTopLevelToBeArchive, int expectedNumFailures) @@ -70,12 +72,12 @@ public void ExtractFlatFile(string fileName, bool requireTopLevelToBeArchive, in var path = Path.Combine(Directory.GetCurrentDirectory(), "TestData", "TestData", fileName); var results = extractor.Extract(path, new ExtractorOptions(){ RequireTopLevelToBeArchive = requireTopLevelToBeArchive }).ToList(); - Assert.AreEqual(1, results.Count); + Assert.HasCount(1, results); var actualNumberOfFailedArchives = results.Count(x => x.EntryStatus == FileEntryStatus.FailedArchive); Assert.AreEqual(expectedNumFailures, actualNumberOfFailedArchives); } - [DataTestMethod] + [TestMethod] [DataRow("EmptyFile.txt")] [DataRow("TestData.zip", ".zip")] public void ExtractAsRaw(string fileName, string? RawExtension = null) @@ -88,6 +90,6 @@ public void ExtractAsRaw(string fileName, string? RawExtension = null) var path = Path.Combine(Directory.GetCurrentDirectory(), "TestData", "TestDataArchives", fileName); var results = extractor.Extract(path, options); - Assert.AreEqual(1, results.Count()); + Assert.HasCount(1, results); } } \ No newline at end of file diff --git a/RecursiveExtractor.Tests/ExtractorTests/TestQuinesAndSlip.cs b/RecursiveExtractor.Tests/ExtractorTests/TestQuinesAndSlip.cs index e2da32d4..252820b4 100644 --- a/RecursiveExtractor.Tests/ExtractorTests/TestQuinesAndSlip.cs +++ b/RecursiveExtractor.Tests/ExtractorTests/TestQuinesAndSlip.cs @@ -42,7 +42,7 @@ public async Task TestZipSlipAsync(string fileName) { var extractor = new Extractor(); var path = Path.Combine(Directory.GetCurrentDirectory(), "TestData", "Bombs", fileName); - var results = await extractor.ExtractAsync(path, new ExtractorOptions()).ToListAsync(); + var results = await extractor.ExtractAsync(path, new ExtractorOptions()).ToListAsync(TestContext.CancellationTokenSource.Token); Assert.IsTrue(results.All(x => !x.FullPath.Contains(".."))); } @@ -65,21 +65,25 @@ public static IEnumerable QuineBombNames [TestMethod] [DynamicData(nameof(QuineBombNames))] - [ExpectedException(typeof(OverflowException))] public void TestQuineBombs(string fileName) { var extractor = new Extractor(); var path = Path.Combine(Directory.GetCurrentDirectory(), "TestData", "Bombs", fileName); - _ = extractor.Extract(path, new ExtractorOptions() { MemoryStreamCutoff = 1024 * 1024 * 1024 }).ToList(); + Assert.ThrowsExactly(() => + { + _ = extractor.Extract(path, new ExtractorOptions() { MemoryStreamCutoff = 1024 * 1024 * 1024 }).ToList(); + }); } [TestMethod] [DynamicData(nameof(QuineBombNames))] - [ExpectedException(typeof(OverflowException))] public async Task TestQuineBombsAsync(string fileName) { var extractor = new Extractor(); var path = Path.Combine(Directory.GetCurrentDirectory(), "TestData", "Bombs", fileName); - _ = await extractor.ExtractAsync(path, new ExtractorOptions() { MemoryStreamCutoff = 1024 * 1024 * 1024 }).ToListAsync(); + await Assert.ThrowsExactlyAsync(async () => + { + _ = await extractor.ExtractAsync(path, new ExtractorOptions() { MemoryStreamCutoff = 1024 * 1024 * 1024 }).ToListAsync(TestContext.CancellationTokenSource.Token); + }); } } \ No newline at end of file diff --git a/RecursiveExtractor.Tests/ExtractorTests/TimeOutTests.cs b/RecursiveExtractor.Tests/ExtractorTests/TimeOutTests.cs index 2f2c8455..16ec9392 100644 --- a/RecursiveExtractor.Tests/ExtractorTests/TimeOutTests.cs +++ b/RecursiveExtractor.Tests/ExtractorTests/TimeOutTests.cs @@ -9,7 +9,7 @@ namespace RecursiveExtractor.Tests.ExtractorTests; [TestClass] public class TimeOutTests : BaseExtractorTestClass { - [DataTestMethod] + [TestMethod] [DataRow("TestData.7z", 3, false)] [DataRow("TestData.tar", 6, false)] [DataRow("TestData.rar", 3, false)] @@ -45,7 +45,7 @@ public void TimeoutTest(string fileName, int expectedNumFiles = 3, bool parallel { var extractor = new Extractor(); var path = Path.Combine(Directory.GetCurrentDirectory(), "TestData", "TestDataArchives", fileName); - Assert.ThrowsException(() => + Assert.ThrowsExactly(() => { var results = extractor.Extract(path, new ExtractorOptions() @@ -63,7 +63,7 @@ public void TimeoutTest(string fileName, int expectedNumFiles = 3, bool parallel }); } - [DataTestMethod] + [TestMethod] [DataRow("TestData.7z", 3, false)] [DataRow("TestData.tar", 6, false)] [DataRow("TestData.rar", 3, false)] @@ -95,13 +95,11 @@ public void TimeoutTest(string fileName, int expectedNumFiles = 3, bool parallel [DataRow("EmptyFile.txt", 1, true)] [DataRow("TestDataArchivesNested.Zip", 54, true)] [DataRow("TestDataArchivesNested.Zip", 54, false)] - [DataRow("TestDataArchivesNested.Zip", 54, true)] - [DataRow("TestDataArchivesNested.Zip", 54, false)] public async Task TimeoutTestAsync(string fileName, int expectedNumFiles = 3, bool parallel = false) { var extractor = new Extractor(); var path = Path.Combine(Directory.GetCurrentDirectory(), "TestData", "TestDataArchives", fileName); - await Assert.ThrowsExceptionAsync(async () => + await Assert.ThrowsExactlyAsync(async () => { var results = extractor.ExtractAsync(path, new ExtractorOptions() diff --git a/RecursiveExtractor.Tests/RecursiveExtractor.Tests.csproj b/RecursiveExtractor.Tests/RecursiveExtractor.Tests.csproj index fe84518b..ab4cbd61 100644 --- a/RecursiveExtractor.Tests/RecursiveExtractor.Tests.csproj +++ b/RecursiveExtractor.Tests/RecursiveExtractor.Tests.csproj @@ -153,6 +153,12 @@ PreserveNewest + + PreserveNewest + + + PreserveNewest + PreserveNewest diff --git a/RecursiveExtractor.Tests/SanitizePathTests.cs b/RecursiveExtractor.Tests/SanitizePathTests.cs index f8d56a29..774cf115 100644 --- a/RecursiveExtractor.Tests/SanitizePathTests.cs +++ b/RecursiveExtractor.Tests/SanitizePathTests.cs @@ -10,7 +10,7 @@ namespace RecursiveExtractor.Tests [TestClass] public class SanitizePathTests { - [DataTestMethod] + [TestMethod] [DataRow("a\\file\\with:colon.name", "a\\file\\with_colon.name")] [DataRow("a\\folder:with\\colon.name", "a\\folder_with\\colon.name")] @@ -23,7 +23,7 @@ public void TestSanitizePathWindows(string windowsInputPath, string expectedWind } } - [DataTestMethod] + [TestMethod] [DataRow("a/file/with:colon.name", "a/file/with_colon.name")] [DataRow("a/folder:with/colon.name", "a/folder_with/colon.name")] diff --git a/RecursiveExtractor.Tests/TestData/TestDataArchives/TestData.arc b/RecursiveExtractor.Tests/TestData/TestDataArchives/TestData.arc new file mode 100644 index 00000000..0c99f274 Binary files /dev/null and b/RecursiveExtractor.Tests/TestData/TestDataArchives/TestData.arc differ diff --git a/RecursiveExtractor.Tests/TestData/TestDataArchives/TestData.arj b/RecursiveExtractor.Tests/TestData/TestDataArchives/TestData.arj new file mode 100644 index 00000000..5882d12e Binary files /dev/null and b/RecursiveExtractor.Tests/TestData/TestDataArchives/TestData.arj differ diff --git a/RecursiveExtractor/Extractor.cs b/RecursiveExtractor/Extractor.cs index 15dafc57..144cc425 100644 --- a/RecursiveExtractor/Extractor.cs +++ b/RecursiveExtractor/Extractor.cs @@ -70,6 +70,8 @@ public Extractor(IEnumerable customExtractors) : this() /// public void SetDefaultExtractors() { + SetExtractor(ArchiveFileType.ARC, new ArcExtractor(this)); + SetExtractor(ArchiveFileType.ARJ, new ArjExtractor(this)); SetExtractor(ArchiveFileType.BZIP2, new BZip2Extractor(this)); SetExtractor(ArchiveFileType.DEB, new DebExtractor(this)); SetExtractor(ArchiveFileType.AR, new GnuArExtractor(this)); diff --git a/RecursiveExtractor/Extractors/ArcExtractor.cs b/RecursiveExtractor/Extractors/ArcExtractor.cs new file mode 100644 index 00000000..a3d2e44c --- /dev/null +++ b/RecursiveExtractor/Extractors/ArcExtractor.cs @@ -0,0 +1,173 @@ +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 SharpCompress.Readers.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 SharpCompress.Readers.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..88c38cf2 --- /dev/null +++ b/RecursiveExtractor/Extractors/ArjExtractor.cs @@ -0,0 +1,167 @@ +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 SharpCompress.Readers.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 SharpCompress.Readers.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 932ac5db..76c4b294 100644 --- a/RecursiveExtractor/MiniMagic.cs +++ b/RecursiveExtractor/MiniMagic.cs @@ -85,6 +85,14 @@ public enum ArchiveFileType /// DMG, /// + /// An ARJ formatted file. + /// + ARJ, + /// + /// An ARC formatted file. + /// + ARC, + /// /// Unused. /// INVALID @@ -173,6 +181,17 @@ public static ArchiveFileType DetectFileType(Stream fileStream) { return ArchiveFileType.P7ZIP; } + // ARJ archive format https://en.wikipedia.org/wiki/ARJ + if (buffer[0] == 0x60 && buffer[1] == 0xEA) + { + return ArchiveFileType.ARJ; + } + // ARC archive format https://en.wikipedia.org/wiki/ARC_(file_format) + // First byte is marker 0x1A, second byte is compression method (0x01-0x09 or 0x7F valid for standard ARC) + if (buffer[0] == 0x1A && ((buffer[1] >= 0x01 && buffer[1] <= 0x09) || buffer[1] == 0x7F)) + { + return ArchiveFileType.ARC; + } 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..9185ad66 100644 --- a/RecursiveExtractor/RecursiveExtractor.csproj +++ b/RecursiveExtractor/RecursiveExtractor.csproj @@ -13,7 +13,7 @@ Enable false true - RecursiveExtractor is able to process the following formats: ar, bzip2, deb, gzip, iso, tar, vhd, vhdx, vmdk, wim, xzip, and zip. RecursiveExtractor automatically detects the archive type and fails gracefully when attempting to process malformed content. + RecursiveExtractor is able to process the following formats: ar, arc, arj, bzip2, deb, gzip, iso, tar, vhd, vhdx, vmdk, wim, xzip, and zip. RecursiveExtractor automatically detects the archive type and fails gracefully when attempting to process malformed content. Microsoft.CST.RecursiveExtractor unzip extract extractor 0.0.0-placeholder @@ -48,7 +48,7 @@ - +