Files
ersatztv/ErsatzTV.Infrastructure.Tests/Images/ImageSharpDecodingTests.cs
T
timothyandClaude Opus 5.5 71d660f213
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 20s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 9s
PR Gates / Docs update reminder (pull_request) Successful in 11s
PR Gates / decisions lifecycle (pull_request) Successful in 14s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 10s
Review verdict / Set review-verdict status (pull_request_target) Successful in 16s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 12m42s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m37s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 5m56s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
review-verdict/h10 Review-verdict: MERGEABLE @ 71d660f (base: main)
test(929): the ICC carrier list includes BMP and is checked against a round trip of every configured format
Round 4 review found two gaps in the gwg2 runtime pin. The carrier list
(png, jpeg, webp) was hand-written and left out BMP, whose 3.1.12
decoder also constructs an IccProfile (BmpDecoderCore::ReadInfoHeader)
and which remote-URL and on-disk graphics can reach. And the record
said every hostile-ICC case asserts the profile is still attached,
while the WatermarkElement case asserts only allocation (its images are
private).

- IccCarriers is now bmp, jpeg, png, webp, with a BmpEncoder arm.
- Icc_Carriers_Are_Every_Configured_Format_That_Round_Trips_A_Profile
  round-trips the hostile profile through each format of
  ImageSharpDecoding.Configuration via its default encoder and requires
  the formats that come back carrying a profile to equal IccCarriers.
  Witnessed red by dropping "bmp" from the list.
- A Cecil scan of the 3.1.12 package (2026-10-28) lists the decoders that
  construct an IccProfile as BMP, JPEG, PNG, WebP and TIFF, so the
  round trip's blind spot (a decoder reading a profile its encoder never
  writes) is empty today; the record says so, dated.
- The record and the element test's comment now say only the validator
  cases assert attachment; the element case shares their fixture.

Measured: product path 38-94 KiB, element 368-834 KiB, Entries control
about 129,545 KiB for all four carriers.

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: f9ce6bc6-5b36-491a-8d72-89c1f70dd3e1
2026-10-30 00:03:25 +01:00

326 lines
14 KiB
C#

using System.Buffers.Binary;
using ErsatzTV.Infrastructure.Images;
using NUnit.Framework;
using Shouldly;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats;
using SixLabors.ImageSharp.Formats.Bmp;
using SixLabors.ImageSharp.Formats.Jpeg;
using SixLabors.ImageSharp.Formats.Png;
using SixLabors.ImageSharp.Formats.Tiff;
using SixLabors.ImageSharp.Formats.Webp;
using SixLabors.ImageSharp.Metadata.Profiles.Icc;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using Image = SixLabors.ImageSharp.Image;
namespace ErsatzTV.Infrastructure.Tests.Images;
/// <summary>
/// ImageSharp 3.1.12's TIFF decoder carries GHSA-v76p-62qx-wwq2 and GHSA-wmxv-xphr-5c9g, and the
/// patched releases need a Six Labors license key to build in Release, so every ImageSharp decode
/// goes through a configuration that cannot detect TIFF. (ersatztv#929,
/// security.imagesharp-3x-tiff-decoder-disabled)
/// </summary>
[TestFixture]
public class ImageSharpDecodingTests
{
private static readonly Uri Uri = new("https://example.com/logo.tif");
// the list is derived from Configuration.Default, not restated: a package bump that adds or
// drops a format reddens this instead of silently changing what ErsatzTV can decode
[Test]
public void Configuration_Registers_Every_Default_Format_Except_Tiff()
{
var expected = Configuration.Default.ImageFormats
.Where(f => f is not TiffFormat)
.Select(f => f.Name)
.OrderBy(n => n, StringComparer.Ordinal)
.ToList();
var actual = ImageSharpDecoding.Configuration.ImageFormats
.Select(f => f.Name)
.OrderBy(n => n, StringComparer.Ordinal)
.ToList();
actual.ShouldBe(expected);
}
// the exclusion above is only meaningful while Default really does carry TIFF, and building
// the restricted configuration must not have removed it from the process-wide Default
[Test]
public void Configuration_Default_Still_Registers_Tiff()
{
_ = ImageSharpDecoding.Configuration;
Configuration.Default.ImageFormats.ShouldContain(f => f is TiffFormat);
}
// positive control for the rejections below: the fixture is a TIFF ImageSharp can decode,
// so a rejection is the configuration's doing and not a broken fixture
[Test]
public async Task Tiff_Fixture_Decodes_Under_The_Default_Configuration()
{
await using MemoryStream stream = await RealTiff(64, 32);
using Image image = await Image.LoadAsync(stream);
image.Width.ShouldBe(64);
image.Height.ShouldBe(32);
}
// the validator as a whole (render path, save path and upload path). The Identify pass rejects
// a TIFF before the Load pass runs, so this exercises Identify; that the Load pass takes the
// same options is held by ErsatzTV.Architecture.Tests' ImageSharpSurfaceTests.
[Test]
public async Task DecodeAndValidate_Rejects_A_Tiff_As_An_Unknown_Format()
{
await using MemoryStream stream = await RealTiff(64, 32);
await Should.ThrowAsync<UnknownImageFormatException>(
() => RemoteImageValidator.DecodeAndValidate(stream, Uri, CancellationToken.None));
}
// pins the IDENTIFY pass separately. The header declares 30000x30000: an Identify that could
// read TIFF would report those dimensions and the budget check would throw
// InvalidOperationException("pixel limit") instead, so only a TIFF-blind Identify yields
// UnknownImageFormatException here. The IFD walk Identify performs is where the BigTIFF
// non-progressing loop (GHSA-wmxv-xphr-5c9g) lives, so the header pass must not reach it.
[Test]
public async Task DecodeAndValidate_Rejects_A_Tiff_Before_Reading_Its_Header()
{
await using MemoryStream stream = await TiffDeclaring(30000, 30000);
ImageInfo info = await Image.IdentifyAsync(stream);
info.Width.ShouldBe(30000);
stream.Position = 0;
await Should.ThrowAsync<UnknownImageFormatException>(
() => RemoteImageValidator.DecodeAndValidate(stream, Uri, CancellationToken.None));
}
// --- GHSA-gwg2-r3hj-4w44: the ICC CLUT parser allocates from unvalidated grid dimensions ---
//
// ErsatzTV never reads ICC entries (ImageSharpSurfaceTests bans the namespace), but decode,
// CloneFrame, Resize and CloneAs all carry the profile along. Whether any of them parses it is
// ImageSharp's own runtime behaviour, which an IL scan of ErsatzTV cannot see, so it is pinned
// here by allocation. A version that parses the profile on decode or clone reddens these
// instead of reopening the advisory silently.
private const long HostileIccEntriesFloor = 64L * 1024 * 1024;
internal const long ProductPathCeiling = 16L * 1024 * 1024;
internal static readonly string[] IccCarriers = ["bmp", "jpeg", "png", "webp"];
// the carrier list is checked, not trusted: every format the decode configuration registers is
// round-tripped with the hostile profile through its default encoder, and the formats whose
// decode still carries a profile must be exactly IccCarriers. A version that adds ICC support to
// another format reddens here. A round trip cannot see a decoder that reads a profile its own
// encoder never writes; on 3.1.12 the decoders that construct an IccProfile are BMP, JPEG, PNG,
// WebP and TIFF (Cecil scan of the package, 2026-10-28), so none is missed today.
[Test]
public async Task Icc_Carriers_Are_Every_Configured_Format_That_Round_Trips_A_Profile()
{
var carriers = new List<string>();
foreach (IImageFormat format in ImageSharpDecoding.Configuration.ImageFormats)
{
IImageEncoder encoder = Configuration.Default.ImageFormatsManager.GetEncoder(format);
await using MemoryStream stream = await WithHostileIcc(encoder);
using Image image = await Image.LoadAsync(ImageSharpDecoding.Options(), stream);
if (image.Metadata.IccProfile is not null)
{
carriers.Add(format.Name.ToLowerInvariant());
}
}
carriers.Order(StringComparer.Ordinal).ShouldBe(IccCarriers.Order(StringComparer.Ordinal));
}
// positive control: the fixture really is hostile. Reading Entries runs the CLUT reader on the
// declared 255-point, 3-input grid (about 127 MiB on 3.1.12, measured 2026-10-28); the catch
// only tolerates the truncated table throwing afterwards.
[TestCaseSource(nameof(IccCarriers))]
[NonParallelizable]
public async Task Hostile_Icc_Fixture_Allocates_When_Its_Entries_Are_Parsed(string format)
{
await using MemoryStream stream = await WithHostileIcc(format);
using Image image = await Image.LoadAsync(ImageSharpDecoding.Options(), stream);
IccProfile profile = image.Metadata.IccProfile.ShouldNotBeNull();
long allocated = AllocatedDuring(() =>
{
try
{
_ = profile.Entries;
}
catch (Exception)
{
// the allocation is what is measured, not whether the truncated table throws
}
});
TestContext.Out.WriteLine($"{format}: reading Entries allocated {allocated / 1024} KiB");
allocated.ShouldBeGreaterThan(HostileIccEntriesFloor);
}
// the product path: the validator's two decodes, then the per-frame CloneFrame + Resize +
// CloneAs that ImageElementBase runs. The profile must survive the path (so the bound is not
// vacuous because a decoder dropped it) and nothing on it may size the CLUT.
[TestCaseSource(nameof(IccCarriers))]
[NonParallelizable]
public async Task Decode_Clone_And_Resize_Do_Not_Parse_A_Hostile_Icc_Profile(string format)
{
await using MemoryStream stream = await WithHostileIcc(format);
long allocated = await AllocatedDuringAsync(async () =>
{
using Image image = await RemoteImageValidator.DecodeAndValidate(stream, Uri, CancellationToken.None);
image.Metadata.IccProfile.ShouldNotBeNull();
using Image frame = image.Frames.CloneFrame(0);
frame.Mutate(ctx => ctx.Resize(8, 8));
frame.Metadata.IccProfile.ShouldNotBeNull();
using Image<Rgba32> rgba = frame.CloneAs<Rgba32>();
rgba.Metadata.IccProfile.ShouldNotBeNull();
});
TestContext.Out.WriteLine($"{format}: decode, clone and resize allocated {allocated / 1024} KiB");
allocated.ShouldBeLessThan(ProductPathCeiling);
}
/// <summary>
/// A 16x16 image in <paramref name="format" /> carrying an ICC profile whose A2B0 tag is an
/// <c>mft2</c> (lut16) with 3 inputs, 15 outputs and 255 grid points, truncated before the
/// CLUT: a 208-byte profile.
/// </summary>
internal static Task<MemoryStream> WithHostileIcc(string format) =>
WithHostileIcc(
format switch
{
"bmp" => new BmpEncoder(),
"jpeg" => new JpegEncoder(),
"png" => new PngEncoder(),
"webp" => new WebpEncoder { FileFormat = WebpFileFormatType.Lossless },
_ => throw new ArgumentOutOfRangeException(nameof(format), format, null)
});
private static async Task<MemoryStream> WithHostileIcc(IImageEncoder encoder)
{
using var image = new Image<Rgba32>(16, 16);
image.Metadata.IccProfile = new IccProfile(HostileIccBytes());
var stream = new MemoryStream();
await image.SaveAsync(stream, encoder);
stream.Position = 0;
return stream;
}
private static byte[] HostileIccBytes()
{
// mft2: type, reserved, in/out/grid/pad, 3x3 matrix, input/output table entry counts,
// then the three 2-entry input tables, and no CLUT
var tag = new List<byte>();
tag.AddRange("mft2"u8.ToArray());
tag.AddRange(new byte[4]);
tag.AddRange([3, 15, 255, 0]);
for (var i = 0; i < 9; i++)
{
tag.AddRange(BigEndian(i % 4 == 0 ? 0x00010000u : 0u));
}
tag.AddRange([0, 2, 0, 2]);
tag.AddRange(new byte[3 * 2 * 2]);
const int TagOffset = 128 + 4 + 12;
int size = TagOffset + tag.Count;
var bytes = new byte[size];
BinaryPrimitives.WriteUInt32BigEndian(bytes.AsSpan(0), (uint)size);
BinaryPrimitives.WriteUInt32BigEndian(bytes.AsSpan(8), 0x02100000u);
"mntr"u8.CopyTo(bytes.AsSpan(12));
"RGB "u8.CopyTo(bytes.AsSpan(16));
"XYZ "u8.CopyTo(bytes.AsSpan(20));
"acsp"u8.CopyTo(bytes.AsSpan(36));
BinaryPrimitives.WriteUInt32BigEndian(bytes.AsSpan(128), 1);
"A2B0"u8.CopyTo(bytes.AsSpan(132));
BinaryPrimitives.WriteUInt32BigEndian(bytes.AsSpan(136), TagOffset);
BinaryPrimitives.WriteUInt32BigEndian(bytes.AsSpan(140), (uint)tag.Count);
tag.CopyTo(bytes, TagOffset);
return bytes;
}
private static byte[] BigEndian(uint value)
{
var bytes = new byte[4];
BinaryPrimitives.WriteUInt32BigEndian(bytes, value);
return bytes;
}
// process-wide and precise, because the async path may resume on another thread; the callers
// are [NonParallelizable] so no other test's allocations land in the count
private static long AllocatedDuring(Action action)
{
long before = GC.GetTotalAllocatedBytes(precise: true);
action();
return GC.GetTotalAllocatedBytes(precise: true) - before;
}
private static async Task<long> AllocatedDuringAsync(Func<Task> action)
{
long before = GC.GetTotalAllocatedBytes(precise: true);
await action();
return GC.GetTotalAllocatedBytes(precise: true) - before;
}
internal static async Task<MemoryStream> RealTiff(int width, int height)
{
using var image = new Image<Rgba32>(width, height);
var stream = new MemoryStream();
await image.SaveAsync(stream, new TiffEncoder());
stream.Position = 0;
return stream;
}
/// <summary>
/// A real TIFF whose ImageWidth/ImageLength IFD entries are rewritten to declare
/// <paramref name="width" /> x <paramref name="height" />, so Identify reports a size the
/// pixel data does not back.
/// </summary>
private static async Task<MemoryStream> TiffDeclaring(int width, int height)
{
await using MemoryStream source = await RealTiff(8, 8);
byte[] bytes = source.ToArray();
// TiffEncoder writes little-endian ("II"); the first IFD offset is at byte 4
bytes[0].ShouldBe((byte)'I');
var ifd = (int)BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(4));
int count = BinaryPrimitives.ReadUInt16LittleEndian(bytes.AsSpan(ifd));
var patched = 0;
for (var i = 0; i < count; i++)
{
int entry = ifd + 2 + i * 12;
ushort tag = BinaryPrimitives.ReadUInt16LittleEndian(bytes.AsSpan(entry));
ushort type = BinaryPrimitives.ReadUInt16LittleEndian(bytes.AsSpan(entry + 2));
if (tag is not (256 or 257))
{
continue;
}
int value = tag == 256 ? width : height;
Span<byte> slot = bytes.AsSpan(entry + 8, 4);
slot.Clear();
if (type == 3)
{
BinaryPrimitives.WriteUInt16LittleEndian(slot, checked((ushort)value));
}
else
{
BinaryPrimitives.WriteUInt32LittleEndian(slot, (uint)value);
}
patched++;
}
patched.ShouldBe(2);
return new MemoryStream(bytes);
}
}