diff --git a/docs/FORMATS.md b/docs/FORMATS.md index fe8b8fa8..3ca381a8 100644 --- a/docs/FORMATS.md +++ b/docs/FORMATS.md @@ -44,6 +44,7 @@ ### Rar Format Notes - RAR5 encrypted headers and data accept KDF counts from 0 through 24 (the binary logarithm of the PBKDF2 iteration count), matching UnRAR's acceptance limit. Larger counts are rejected before key derivation. +- RAR companion filenames use the same exact-match-first, case-insensitive fallback as ZIP, with ambiguous fallback matches rejected. - Encrypted ciphertext must contain complete AES blocks. Truncation throws `InvalidFormatException` on synchronous reads or `IncompleteArchiveException` on asynchronous reads. ### 7Zip Format Notes diff --git a/src/SharpCompress/Archives/ArchiveVolumeFactory.cs b/src/SharpCompress/Archives/ArchiveVolumeFactory.cs index 92ed6818..c0da8ff3 100644 --- a/src/SharpCompress/Archives/ArchiveVolumeFactory.cs +++ b/src/SharpCompress/Archives/ArchiveVolumeFactory.cs @@ -28,11 +28,32 @@ internal abstract class ArchiveVolumeFactory ); } - if (item != null && item.Exists) + return item is null ? null : ResolveFilePart(item); + } + + internal static FileInfo? ResolveFilePart(FileInfo item) + { + if (item.Exists) { return item; } - - return null; + if (!item.Directory!.Exists) + { + return null; + } + FileInfo? match = null; + foreach (var file in item.Directory.EnumerateFiles()) + { + if (!string.Equals(file.Name, item.Name, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + if (match is not null) + { + throw new InvalidFormatException($"Ambiguous archive volume name: {item.Name}"); + } + match = file; + } + return match; } } diff --git a/src/SharpCompress/Archives/Rar/RarArchiveVolumeFactory.cs b/src/SharpCompress/Archives/Rar/RarArchiveVolumeFactory.cs index 1e464876..8a1f03b6 100644 --- a/src/SharpCompress/Archives/Rar/RarArchiveVolumeFactory.cs +++ b/src/SharpCompress/Archives/Rar/RarArchiveVolumeFactory.cs @@ -61,11 +61,6 @@ internal static class RarArchiveVolumeFactory } } - if (item != null && item.Exists) - { - return item; - } - - return null; //no more items + return item is null ? null : ArchiveVolumeFactory.ResolveFilePart(item); } } diff --git a/src/SharpCompress/Archives/Zip/ZipArchiveVolumeFactory.cs b/src/SharpCompress/Archives/Zip/ZipArchiveVolumeFactory.cs index fd034da3..0ab64be0 100644 --- a/src/SharpCompress/Archives/Zip/ZipArchiveVolumeFactory.cs +++ b/src/SharpCompress/Archives/Zip/ZipArchiveVolumeFactory.cs @@ -32,31 +32,6 @@ internal static class ZipArchiveVolumeFactory return ArchiveVolumeFactory.GetFilePart(index, part1); } - if (item != null && item.Exists) - { - return item; - } - - // Generated suffixes are lowercase, but split archives can use any on-disk casing. - // Prefer the exact path above and never choose arbitrarily between fallback matches. - if (item is not null && item.Directory!.Exists) - { - FileInfo? match = null; - foreach (var file in item.Directory!.EnumerateFiles()) - { - if (!string.Equals(file.Name, item.Name, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - if (match is not null) - { - throw new InvalidFormatException($"Ambiguous ZIP volume name: {item.Name}"); - } - match = file; - } - return match; - } - - return null; //no more items + return item is null ? null : ArchiveVolumeFactory.ResolveFilePart(item); } } diff --git a/tests/SharpCompress.Test/Rar/RarVolumeCasingTests.cs b/tests/SharpCompress.Test/Rar/RarVolumeCasingTests.cs new file mode 100644 index 00000000..17257c0f --- /dev/null +++ b/tests/SharpCompress.Test/Rar/RarVolumeCasingTests.cs @@ -0,0 +1,45 @@ +using System; +using System.IO; +using SharpCompress.Archives.Rar; +using SharpCompress.Common; +using Xunit; + +namespace SharpCompress.Test.Rar; + +public class RarVolumeCasingTests : TestBase +{ + [Theory] + [InlineData("sample.RAR", "SAMPLE.R00")] + [InlineData("sample.part01.RAR", "SAMPLE.PART02.rar")] + [InlineData("sample.rar.001", "SAMPLE.RAR.002")] + public void Companion_Lookup_Is_Case_Insensitive(string firstName, string nextName) + { + var first = new FileInfo(Path.Combine(SCRATCH_FILES_PATH, firstName)); + var next = Path.Combine(SCRATCH_FILES_PATH, nextName); + File.WriteAllBytes(next, []); + Assert.Equal( + next, + RarArchiveVolumeFactory.GetFilePart(1, first)!.FullName, + ignoreCase: true + ); + Assert.Null(RarArchiveVolumeFactory.GetFilePart(2, first)); + } + + [Fact] + public void Companion_Lookup_Prefers_Exact_Path_And_Rejects_Ambiguity() + { + var first = new FileInfo(Path.Combine(SCRATCH_FILES_PATH, "sample.rar")); + var upper = Path.Combine(SCRATCH_FILES_PATH, "SAMPLE.R00"); + var mixed = Path.Combine(SCRATCH_FILES_PATH, "Sample.r00"); + File.WriteAllBytes(upper, []); + if (File.Exists(mixed)) + { + return; // Case-insensitive filesystems cannot represent ambiguous companion names. + } + File.WriteAllBytes(mixed, []); + Assert.Throws(() => RarArchiveVolumeFactory.GetFilePart(1, first)); + var exact = Path.Combine(SCRATCH_FILES_PATH, "sample.r00"); + File.WriteAllBytes(exact, []); + Assert.Equal(exact, RarArchiveVolumeFactory.GetFilePart(1, first)!.FullName); + } +}