152 lines
5.6 KiB
C#
152 lines
5.6 KiB
C#
using System.Buffers.Binary;
|
|
using ErsatzTV.Core.Images;
|
|
using ErsatzTV.Core.Interfaces.Images;
|
|
using ErsatzTV.Infrastructure.Images;
|
|
using NUnit.Framework;
|
|
using Shouldly;
|
|
using SixLabors.ImageSharp;
|
|
using SixLabors.ImageSharp.Formats.Png;
|
|
using SixLabors.ImageSharp.PixelFormats;
|
|
using Image = SixLabors.ImageSharp.Image;
|
|
|
|
namespace ErsatzTV.Infrastructure.Tests.Images;
|
|
|
|
[TestFixture]
|
|
public class RemoteImageValidatorTests
|
|
{
|
|
private static readonly Uri Uri = new("https://example.com/logo.png");
|
|
|
|
// decode cases exercise the static method (used by the render path)
|
|
[Test]
|
|
public async Task Should_Decode_A_Normal_Image()
|
|
{
|
|
await using MemoryStream stream = await RealPng(64, 32);
|
|
using Image image = await RemoteImageValidator.DecodeAndValidate(stream, Uri, CancellationToken.None);
|
|
image.Width.ShouldBe(64);
|
|
image.Height.ShouldBe(32);
|
|
}
|
|
|
|
[Test]
|
|
public async Task Should_Reject_A_Decompression_Bomb_By_Declared_Dimensions()
|
|
{
|
|
await using MemoryStream stream = PngHeaderDeclaring(30000, 30000);
|
|
InvalidOperationException ex = await Should.ThrowAsync<InvalidOperationException>(
|
|
() => RemoteImageValidator.DecodeAndValidate(stream, Uri, CancellationToken.None));
|
|
ex.Message.ShouldContain("pixel limit");
|
|
}
|
|
|
|
[Test]
|
|
public async Task Should_Reject_An_Apng_Whose_Header_Under_Reports_Its_Frames()
|
|
{
|
|
await using MemoryStream stream = Apng(64, 64, RemoteImageDecodeBudget.MaxRemoteFrames + 100);
|
|
InvalidOperationException ex = await Should.ThrowAsync<InvalidOperationException>(
|
|
() => RemoteImageValidator.DecodeAndValidate(stream, Uri, CancellationToken.None));
|
|
ex.Message.ShouldContain("frame limit");
|
|
}
|
|
|
|
[Test]
|
|
public async Task Should_Decode_An_Apng_That_A_Default_Identify_Cannot_Read()
|
|
{
|
|
await using MemoryStream stream = Apng(288, 288, 60);
|
|
stream.Position = 0;
|
|
await Should.ThrowAsync<Exception>(() => Image.IdentifyAsync(stream));
|
|
stream.Position = 0;
|
|
using Image image = await RemoteImageValidator.DecodeAndValidate(stream, Uri, CancellationToken.None);
|
|
image.Frames.Count.ShouldBe(60);
|
|
}
|
|
|
|
// the Core interface Validate() is the save/upload contract: throws on invalid, returns on valid,
|
|
// never surfaces an ImageSharp type
|
|
[Test]
|
|
public async Task Validate_Returns_On_A_Good_Image()
|
|
{
|
|
IRemoteImageValidator validator = new RemoteImageValidator();
|
|
await using MemoryStream stream = await RealPng(64, 32);
|
|
await Should.NotThrowAsync(() => validator.Validate(stream, Uri, CancellationToken.None));
|
|
}
|
|
|
|
[Test]
|
|
public async Task Validate_Throws_On_A_Bomb()
|
|
{
|
|
IRemoteImageValidator validator = new RemoteImageValidator();
|
|
await using MemoryStream stream = PngHeaderDeclaring(30000, 30000);
|
|
await Should.ThrowAsync<InvalidOperationException>(
|
|
() => validator.Validate(stream, Uri, CancellationToken.None));
|
|
}
|
|
|
|
/// <summary>A real multi-frame APNG. Small on the wire, many frames — the shape that matters.</summary>
|
|
private static MemoryStream Apng(int width, int height, int frames)
|
|
{
|
|
using var image = new Image<Rgba32>(width, height);
|
|
for (var i = 1; i < frames; i++)
|
|
{
|
|
image.Frames.CreateFrame();
|
|
}
|
|
|
|
var stream = new MemoryStream();
|
|
image.Save(stream, new PngEncoder { ColorType = PngColorType.RgbWithAlpha });
|
|
stream.Position = 0;
|
|
return stream;
|
|
}
|
|
|
|
// PNG chunk CRC-32 (IEEE, reflected). hand-rolled because the repo does not reference
|
|
// System.IO.Hashing, and ImageSharp validates the CRC of critical chunks like IHDR.
|
|
private static uint Crc32(ReadOnlySpan<byte> data)
|
|
{
|
|
uint crc = 0xFFFFFFFF;
|
|
foreach (byte b in data)
|
|
{
|
|
crc ^= b;
|
|
for (var i = 0; i < 8; i++)
|
|
{
|
|
crc = (crc & 1) != 0 ? (crc >> 1) ^ 0xEDB88320 : crc >> 1;
|
|
}
|
|
}
|
|
|
|
return crc ^ 0xFFFFFFFF;
|
|
}
|
|
|
|
/// <summary>A real, decodable PNG.</summary>
|
|
private static async Task<MemoryStream> RealPng(int width, int height)
|
|
{
|
|
using var image = new Image<Rgba32>(width, height);
|
|
var stream = new MemoryStream();
|
|
await image.SaveAsync(stream, new PngEncoder());
|
|
stream.Position = 0;
|
|
return stream;
|
|
}
|
|
|
|
/// <summary>
|
|
/// A PNG signature plus a single valid IHDR chunk declaring <paramref name="width" /> x
|
|
/// <paramref name="height" /> and nothing else — enough for Identify, far too little to
|
|
/// decode. This is what a decompression bomb looks like at the point we have to reject it.
|
|
/// </summary>
|
|
private static MemoryStream PngHeaderDeclaring(int width, int height)
|
|
{
|
|
var stream = new MemoryStream();
|
|
stream.Write([0x89, (byte)'P', (byte)'N', (byte)'G', 0x0D, 0x0A, 0x1A, 0x0A]);
|
|
|
|
var ihdr = new byte[17];
|
|
"IHDR"u8.CopyTo(ihdr);
|
|
BinaryPrimitives.WriteInt32BigEndian(ihdr.AsSpan(4), width);
|
|
BinaryPrimitives.WriteInt32BigEndian(ihdr.AsSpan(8), height);
|
|
ihdr[12] = 8; // bit depth
|
|
ihdr[13] = 6; // color type: truecolor + alpha
|
|
ihdr[14] = 0; // compression
|
|
ihdr[15] = 0; // filter
|
|
ihdr[16] = 0; // interlace
|
|
|
|
var length = new byte[4];
|
|
BinaryPrimitives.WriteInt32BigEndian(length, 13);
|
|
stream.Write(length);
|
|
stream.Write(ihdr);
|
|
|
|
var crc = new byte[4];
|
|
BinaryPrimitives.WriteUInt32BigEndian(crc, Crc32(ihdr));
|
|
stream.Write(crc);
|
|
|
|
stream.Position = 0;
|
|
return stream;
|
|
}
|
|
}
|