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)
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
250 lines
10 KiB
C#
250 lines
10 KiB
C#
using ErsatzTV.Core.Domain;
|
|
using ErsatzTV.Core.FFmpeg;
|
|
using ErsatzTV.Core.Interfaces.Streaming;
|
|
using ErsatzTV.FFmpeg;
|
|
using ErsatzTV.FFmpeg.State;
|
|
using ErsatzTV.Infrastructure.Streaming.Graphics;
|
|
using ErsatzTV.Infrastructure.Tests.Images;
|
|
using LanguageExt;
|
|
using SixLabors.ImageSharp;
|
|
using SixLabors.ImageSharp.Formats.Png;
|
|
using SixLabors.ImageSharp.PixelFormats;
|
|
using Microsoft.Extensions.Logging;
|
|
using NSubstitute;
|
|
using NUnit.Framework;
|
|
using Shouldly;
|
|
|
|
namespace ErsatzTV.Infrastructure.Tests.Streaming.Graphics;
|
|
|
|
/// <summary>
|
|
/// The degradation contract for a remote watermark image: whatever the fetcher throws, the
|
|
/// element disables itself and the stream survives. (ersatztv#511)
|
|
/// </summary>
|
|
[TestFixture]
|
|
public class WatermarkElementRemoteImageTests
|
|
{
|
|
private const string RemoteLogo = "https://example.com/logo.png";
|
|
|
|
private static readonly Exception[] FetchFailures =
|
|
[
|
|
new TimeoutException("timed out fetching remote image"),
|
|
new InvalidOperationException("remote image exceeds the byte limit"),
|
|
new HttpRequestException("no route to host")
|
|
];
|
|
|
|
[TestCaseSource(nameof(FetchFailures))]
|
|
public async Task Should_Disable_The_Watermark_When_The_Fetch_Fails(Exception failure)
|
|
{
|
|
var fetcher = Substitute.For<IRemoteImageFetcher>();
|
|
fetcher.Fetch(Arg.Any<Uri>(), Arg.Any<CancellationToken>()).Returns<Task<Stream>>(_ => throw failure);
|
|
|
|
var element = new WatermarkElement(
|
|
RemoteWatermarkOptions(),
|
|
fetcher,
|
|
Substitute.For<ILogger>());
|
|
|
|
// must not throw -- a killed graphics element must never propagate into the stream
|
|
await element.InitializeAsync(Context(), CancellationToken.None);
|
|
|
|
element.IsFinished.ShouldBeTrue();
|
|
}
|
|
|
|
// the counterpart: caller cancellation is not a fetch failure, but it must still not escape
|
|
// into the streaming pipeline as an unhandled exception
|
|
[Test]
|
|
public async Task Should_Disable_The_Watermark_When_The_Caller_Cancels()
|
|
{
|
|
var fetcher = Substitute.For<IRemoteImageFetcher>();
|
|
fetcher.Fetch(Arg.Any<Uri>(), Arg.Any<CancellationToken>())
|
|
.Returns<Task<Stream>>(_ => throw new OperationCanceledException());
|
|
|
|
var element = new WatermarkElement(RemoteWatermarkOptions(), fetcher, Substitute.For<ILogger>());
|
|
|
|
await element.InitializeAsync(Context(), CancellationToken.None);
|
|
|
|
element.IsFinished.ShouldBeTrue();
|
|
}
|
|
|
|
// a remote path really does route to the fetcher -- without this the tests above would pass
|
|
// even if LoadImage stopped recognising http(s) urls
|
|
[Test]
|
|
public async Task Should_Route_A_Remote_Path_Through_The_Fetcher()
|
|
{
|
|
var fetcher = Substitute.For<IRemoteImageFetcher>();
|
|
fetcher.Fetch(Arg.Any<Uri>(), Arg.Any<CancellationToken>())
|
|
.Returns<Task<Stream>>(_ => throw new TimeoutException());
|
|
|
|
var element = new WatermarkElement(RemoteWatermarkOptions(), fetcher, Substitute.For<ILogger>());
|
|
|
|
await element.InitializeAsync(Context(), CancellationToken.None);
|
|
|
|
await fetcher.Received(1).Fetch(new Uri(RemoteLogo), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
// ...and a local path must not touch the network at all
|
|
[Test]
|
|
public async Task Should_Not_Use_The_Fetcher_For_A_Local_Path()
|
|
{
|
|
var fetcher = Substitute.For<IRemoteImageFetcher>();
|
|
|
|
var options = new WatermarkOptions(Watermark(), "/no/such/logo.png", Option<int>.None);
|
|
var element = new WatermarkElement(options, fetcher, Substitute.For<ILogger>());
|
|
|
|
await element.InitializeAsync(Context(), CancellationToken.None);
|
|
|
|
await fetcher.DidNotReceive().Fetch(Arg.Any<Uri>(), Arg.Any<CancellationToken>());
|
|
element.IsFinished.ShouldBeTrue();
|
|
}
|
|
|
|
|
|
// --- the RETENTION budget is wired in, and is remote-only (M4 from re-review) ---
|
|
//
|
|
// These two differ ONLY in the scale percent, so together they detect deletion of the
|
|
// `if (isRemoteUri) EnsureScaledFramesAffordable(...)` call site: without it the first case
|
|
// would succeed. The source is trivial to decode (300 x 64x64 = 1.2 MP, well inside the decode
|
|
// budget) but retains ~2.5 GB of SKBitmap once every frame is scaled to 1080p.
|
|
|
|
[Test]
|
|
public async Task Should_Disable_The_Watermark_When_Scaled_Frames_Blow_The_Retention_Budget()
|
|
{
|
|
var element = new WatermarkElement(
|
|
RemoteWatermarkOptions(widthPercent: 100),
|
|
FetcherReturning(Apng(64, 64, 300)),
|
|
Substitute.For<ILogger>());
|
|
|
|
await element.InitializeAsync(Context(), CancellationToken.None);
|
|
|
|
element.IsFinished.ShouldBeTrue();
|
|
}
|
|
|
|
[Test]
|
|
public async Task Should_Keep_The_Watermark_When_The_Same_Animation_Is_Scaled_Small()
|
|
{
|
|
var element = new WatermarkElement(
|
|
RemoteWatermarkOptions(widthPercent: 10),
|
|
FetcherReturning(Apng(64, 64, 300)),
|
|
Substitute.For<ILogger>());
|
|
|
|
await element.InitializeAsync(Context(), CancellationToken.None);
|
|
|
|
element.IsFinished.ShouldBeFalse();
|
|
}
|
|
|
|
// --- TIFF is not decoded on either path (ersatztv#929, security.imagesharp-3x-tiff-decoder-disabled) ---
|
|
//
|
|
// Each TIFF case has a PNG twin that differs only in the encoder, so a disabled element is the
|
|
// decoder configuration's doing, not a broken fixture or a path that never loads.
|
|
|
|
[TestCase(true, ExpectedResult = true)]
|
|
[TestCase(false, ExpectedResult = false)]
|
|
public async Task<bool> Should_Disable_The_Watermark_For_A_Remote_Tiff_Only(bool tiff)
|
|
{
|
|
MemoryStream source = tiff ? await ImageSharpDecodingTests.RealTiff(64, 64) : Apng(64, 64, 1);
|
|
var element = new WatermarkElement(RemoteWatermarkOptions(), FetcherReturning(source), Substitute.For<ILogger>());
|
|
|
|
await element.InitializeAsync(Context(), CancellationToken.None);
|
|
|
|
return element.IsFinished;
|
|
}
|
|
|
|
[TestCase(true, ExpectedResult = true)]
|
|
[TestCase(false, ExpectedResult = false)]
|
|
public async Task<bool> Should_Disable_The_Watermark_For_A_Local_Tiff_Only(bool tiff)
|
|
{
|
|
string path = Path.Combine(Path.GetTempPath(), $"etv-929-{Guid.NewGuid():N}.{(tiff ? "tif" : "png")}");
|
|
try
|
|
{
|
|
await using (MemoryStream source = tiff ? await ImageSharpDecodingTests.RealTiff(64, 64) : Apng(64, 64, 1))
|
|
{
|
|
await File.WriteAllBytesAsync(path, source.ToArray());
|
|
}
|
|
|
|
var options = new WatermarkOptions(Watermark(), path, Option<int>.None);
|
|
var element = new WatermarkElement(options, Substitute.For<IRemoteImageFetcher>(), Substitute.For<ILogger>());
|
|
|
|
await element.InitializeAsync(Context(), CancellationToken.None);
|
|
|
|
return element.IsFinished;
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(path);
|
|
}
|
|
}
|
|
|
|
// GHSA-gwg2-r3hj-4w44 over the element itself rather than a replica of its calls: decode,
|
|
// CloneFrame, Resize and CloneAs carry a hostile ICC profile through without sizing its CLUT.
|
|
// The element's images are private, so this case cannot assert the profile is still attached; it
|
|
// shares its fixture with ImageSharpDecodingTests, whose
|
|
// Decode_Clone_And_Resize_Do_Not_Parse_A_Hostile_Icc_Profile asserts attachment after each step and
|
|
// whose Hostile_Icc_Fixture_Allocates_When_Its_Entries_Are_Parsed proves the fixture hostile.
|
|
// (ersatztv#929, security.imagesharp-3x-tiff-decoder-disabled)
|
|
[TestCaseSource(typeof(ImageSharpDecodingTests), nameof(ImageSharpDecodingTests.IccCarriers))]
|
|
[NonParallelizable]
|
|
public async Task Should_Render_A_Remote_Watermark_Without_Parsing_Its_Icc_Profile(string format)
|
|
{
|
|
MemoryStream source = await ImageSharpDecodingTests.WithHostileIcc(format);
|
|
var element = new WatermarkElement(RemoteWatermarkOptions(), FetcherReturning(source), Substitute.For<ILogger>());
|
|
|
|
long before = GC.GetTotalAllocatedBytes(precise: true);
|
|
await element.InitializeAsync(Context(), CancellationToken.None);
|
|
long allocated = GC.GetTotalAllocatedBytes(precise: true) - before;
|
|
|
|
TestContext.Out.WriteLine($"{format}: watermark initialization allocated {allocated / 1024} KiB");
|
|
element.IsFinished.ShouldBeFalse();
|
|
allocated.ShouldBeLessThan(ImageSharpDecodingTests.ProductPathCeiling);
|
|
}
|
|
|
|
private static IRemoteImageFetcher FetcherReturning(Stream stream)
|
|
{
|
|
var fetcher = Substitute.For<IRemoteImageFetcher>();
|
|
fetcher.Fetch(Arg.Any<Uri>(), Arg.Any<CancellationToken>()).Returns(stream);
|
|
return fetcher;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
private static WatermarkOptions RemoteWatermarkOptions(double widthPercent = 10) =>
|
|
new(Watermark(widthPercent), RemoteLogo, Option<int>.None);
|
|
|
|
private static ChannelWatermark Watermark(double widthPercent = 10) =>
|
|
new()
|
|
{
|
|
Name = "test",
|
|
Mode = ChannelWatermarkMode.Permanent,
|
|
Location = WatermarkLocation.BottomRight,
|
|
Size = WatermarkSize.Scaled,
|
|
WidthPercent = widthPercent,
|
|
HorizontalMarginPercent = 5,
|
|
VerticalMarginPercent = 5,
|
|
Opacity = 100
|
|
};
|
|
|
|
private static GraphicsEngineContext Context() =>
|
|
new(
|
|
"1",
|
|
null,
|
|
[],
|
|
[],
|
|
new Resolution { Width = 1920, Height = 1080 },
|
|
new Resolution { Width = 1920, Height = 1080 },
|
|
new FrameRate("30"),
|
|
DateTimeOffset.UnixEpoch,
|
|
DateTimeOffset.UnixEpoch,
|
|
TimeSpan.Zero,
|
|
TimeSpan.FromMinutes(1),
|
|
TimeSpan.FromMinutes(1));
|
|
}
|