Resolve RAR companion volumes with unambiguous case-insensitive fallback

This commit is contained in:
Adam Hathcock
2026-10-10 15:40:36 +01:00
parent 13ed208a2b
commit 06fea02e64
5 changed files with 72 additions and 35 deletions

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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<InvalidFormatException>(() => RarArchiveVolumeFactory.GetFilePart(1, first));
var exact = Path.Combine(SCRATCH_FILES_PATH, "sample.r00");
File.WriteAllBytes(exact, []);
Assert.Equal(exact, RarArchiveVolumeFactory.GetFilePart(1, first)!.FullName);
}
}