acdream/tests/AcDream.Launcher.Core.Tests/Updates/SafeZipExtractorTests.cs

201 lines
7.4 KiB
C#

using System.IO.Compression;
using System.Text;
using AcDream.Launcher.Core.Updates;
namespace AcDream.Launcher.Core.Tests.Updates;
public sealed class SafeZipExtractorTests : IDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(),
"acdream-safe-zip-tests",
Guid.NewGuid().ToString("N"));
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
[Fact]
public async Task ExtractsPortableTreeWithHashesAndExecutableMode()
{
byte[] archive = UpdateTestData.CreateZip(
[
("bin/", [], 0x41ED),
("bin/client", Encoding.UTF8.GetBytes("client"), 0x81ED),
("data/value.txt", Encoding.UTF8.GetBytes("value"), 0x81A4),
]);
string zip = WriteArchive("valid.zip", archive);
string destination = Path.Combine(_root, "valid");
IReadOnlyList<ExtractedFileRecord> files = await new SafeZipExtractor()
.ExtractAsync(zip, destination);
Assert.Equal(["bin/client", "data/value.txt"], files.Select(file => file.Path));
Assert.Equal(0x1ED, files[0].UnixMode);
Assert.Equal(UpdateTestData.Sha256(Encoding.UTF8.GetBytes("client")), files[0].Sha256);
Assert.Equal("value", await File.ReadAllTextAsync(Path.Combine(destination, "data", "value.txt")));
}
[Theory]
[InlineData("../escape")]
[InlineData("a/../../escape")]
[InlineData("/rooted")]
[InlineData("C:/drive")]
[InlineData("file:stream")]
[InlineData("a//b")]
[InlineData("a/./b")]
[InlineData("CON")]
[InlineData("aux.txt")]
[InlineData("CLOCK$/value")]
[InlineData("CONIN$.txt")]
[InlineData("CONOUT$/value")]
[InlineData("COM¹.dll")]
[InlineData("com²/value")]
[InlineData("LPT³.log")]
[InlineData("trailing.")]
[InlineData("trailing ")]
public async Task RejectsTraversalRootedAdsAndPortableUnsafeNames(string entry)
{
string zip = WriteArchive(
"unsafe-" + Guid.NewGuid().ToString("N") + ".zip",
UpdateTestData.CreateZip([(entry, Encoding.UTF8.GetBytes("bad"), 0x81A4)]));
string destination = Path.Combine(_root, "unsafe-" + Guid.NewGuid().ToString("N"));
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
new SafeZipExtractor().ExtractAsync(zip, destination));
Assert.False(Directory.Exists(destination));
Assert.False(File.Exists(Path.Combine(_root, "escape")));
}
[Theory]
[InlineData("CONIN$.txt")]
[InlineData("CONOUT$/child")]
[InlineData("COM¹.dll")]
[InlineData("LPT³/child")]
[InlineData("CLOCK$")]
public void VersionMetadataUsesTheSameCompletePortableDeviceRules(string path) =>
Assert.False(ClientVersionStore.IsNormalizedRelative(path));
[Fact]
public async Task RejectsDuplicateCaseAndFileDirectoryCollisionsBeforeExtraction()
{
byte[][] archives =
[
UpdateTestData.CreateZip(
[
("Readme.txt", Encoding.UTF8.GetBytes("a"), 0x81A4),
("README.TXT", Encoding.UTF8.GetBytes("b"), 0x81A4),
]),
UpdateTestData.CreateZip(
[
("node", Encoding.UTF8.GetBytes("file"), 0x81A4),
("node/child", Encoding.UTF8.GetBytes("child"), 0x81A4),
]),
UpdateTestData.CreateZip(
[
("Folder/one", Encoding.UTF8.GetBytes("one"), 0x81A4),
("folder/two", Encoding.UTF8.GetBytes("two"), 0x81A4),
]),
];
foreach (byte[] archive in archives)
{
string id = Guid.NewGuid().ToString("N");
string zip = WriteArchive(id + ".zip", archive);
string destination = Path.Combine(_root, id);
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
new SafeZipExtractor().ExtractAsync(zip, destination));
Assert.False(Directory.Exists(destination));
}
}
[Fact]
public async Task RejectsSymlinkAndReparseMetadata()
{
byte[] symlink = UpdateTestData.CreateZip(
[("link", Encoding.UTF8.GetBytes("../../outside"), 0xA1FF)]);
string zip = WriteArchive("symlink.zip", symlink);
string destination = Path.Combine(_root, "symlink");
LauncherUpdateException error = await Assert.ThrowsAsync<LauncherUpdateException>(
() => new SafeZipExtractor().ExtractAsync(zip, destination));
Assert.Contains("symlink", error.Message, StringComparison.OrdinalIgnoreCase);
Assert.False(Directory.Exists(destination));
}
[Fact]
public async Task RejectsEntryCountSizeTotalAndCompressionRatioBombs()
{
var cases = new (byte[] Archive, SafeZipExtractionLimits Limits)[]
{
(
UpdateTestData.CreateZip(
[
("one", [1], 0x81A4),
("two", [2], 0x81A4),
]),
new SafeZipExtractionLimits(MaximumEntries: 1)),
(
UpdateTestData.CreateZip([("large", new byte[8], 0x81A4)]),
new SafeZipExtractionLimits(MaximumEntryBytes: 7)),
(
UpdateTestData.CreateZip(
[
("one", new byte[6], 0x81A4),
("two", new byte[6], 0x81A4),
]),
new SafeZipExtractionLimits(MaximumTotalBytes: 10)),
(
UpdateTestData.CreateZip(
[("ratio", new byte[64 * 1024], 0x81A4)],
CompressionLevel.SmallestSize),
new SafeZipExtractionLimits(MaximumCompressionRatio: 2)),
};
foreach ((byte[] archive, SafeZipExtractionLimits limits) in cases)
{
string id = Guid.NewGuid().ToString("N");
string zip = WriteArchive(id + ".zip", archive);
string destination = Path.Combine(_root, id);
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
new SafeZipExtractor(limits).ExtractAsync(zip, destination));
Assert.False(Directory.Exists(destination));
}
}
[Fact]
public async Task ExistingNonEmptyDestinationAndCancellationNeverPublishPartialTree()
{
string zip = WriteArchive(
"cancel.zip",
UpdateTestData.CreateZip([("large", new byte[1024 * 1024], 0x81A4)]));
string nonEmpty = Path.Combine(_root, "nonempty");
Directory.CreateDirectory(nonEmpty);
await File.WriteAllTextAsync(Path.Combine(nonEmpty, "owner"), "preserve");
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
new SafeZipExtractor().ExtractAsync(zip, nonEmpty));
Assert.Equal("preserve", await File.ReadAllTextAsync(Path.Combine(nonEmpty, "owner")));
string cancelled = Path.Combine(_root, "cancelled");
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
new SafeZipExtractor().ExtractAsync(zip, cancelled, cancellation.Token));
Assert.False(Directory.Exists(cancelled));
}
private string WriteArchive(string name, byte[] content)
{
Directory.CreateDirectory(_root);
string path = Path.Combine(_root, name);
File.WriteAllBytes(path, content);
return path;
}
}