Files
av-parser/tests/AvParser.Infrastructure.Tests/Collecting/MediaSignatureTests.cs
T
Leonid PershinandClaude Opus 5 1742c094e9 Add the download pipeline: sniffing, redirects, throttling, verdicts
Second half of the collector foundation. Still nothing in the app references
it; the pipeline is tested end to end against a deliberately badly behaved
loopback server before anything depends on it.

Types come from the bytes, never from the URL, the extension or Content-Type -
two of those three are chosen by whoever serves the file, and a host must not
get to pick the extension of a file written to the user's disk. Animation is a
separate question from kind: GIF89a proves nothing without a second image
descriptor, and a PNG is an APNG only if acTL precedes the first IDAT, so both
are walked properly rather than guessed.

Timeouts are split three ways because HttpClient.Timeout covers the whole
response: any value large enough for a 30 MB file is also large enough for a
dead connection to hang on. Connect, headers and a per-read idle deadline let
both be strict. Redirects are followed by hand since the shared proxy handler
disables them, which is what allows a hop cap, loop detection and refusing a
jump to a data: URL.

The lease verdict is a pure function, because ProxyLease's constructor is
internal to the domain and no test can fabricate one. Its rule is that the
verdict describes the transport, not the resource: a 404 is a working proxy,
and so is a 429 - blaming the proxy for an origin's rate limit would make the
pool rotate away from a good address in response to being asked to slow down.
Cancellation reports nothing at all.

Throttling exists to be obeyed. It is raised only by the host's own 429 and 503,
and never by rotating to another proxy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:06:15 +03:00

384 lines
13 KiB
C#

using System.Buffers.Binary;
using System.Text;
using AvParser.Core.Collecting;
using AvParser.Infrastructure.Collecting;
namespace AvParser.Infrastructure.Tests.Collecting;
/// <summary>Builds the smallest byte sequences that are still legitimately each format.</summary>
internal static class Samples
{
private static readonly byte[] PngSignature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
public static byte[] Jpeg() => [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, .. "JFIF\0"u8, .. new byte[24]];
public static byte[] Png(int width = 640, int height = 480)
{
var bytes = new List<byte>(PngSignature);
bytes.AddRange(Chunk("IHDR", Ihdr(width, height)));
bytes.AddRange(Chunk("IDAT", new byte[8]));
return [.. bytes];
}
public static byte[] Apng(int width = 64, int height = 64)
{
var bytes = new List<byte>(PngSignature);
bytes.AddRange(Chunk("IHDR", Ihdr(width, height)));
// acTL must precede IDAT to count; a decoder ignores it afterwards and so do we.
bytes.AddRange(Chunk("acTL", [0, 0, 0, 2, 0, 0, 0, 0]));
bytes.AddRange(Chunk("IDAT", new byte[8]));
return [.. bytes];
}
/// <summary>A PNG carrying acTL only after IDAT — a still image with junk appended.</summary>
public static byte[] PngWithLateAnimationChunk()
{
var bytes = new List<byte>(PngSignature);
bytes.AddRange(Chunk("IHDR", Ihdr(8, 8)));
bytes.AddRange(Chunk("IDAT", new byte[8]));
bytes.AddRange(Chunk("acTL", [0, 0, 0, 2, 0, 0, 0, 0]));
return [.. bytes];
}
public static byte[] Gif(int frames = 1, int width = 12, int height = 34, bool modern = true)
{
var bytes = new List<byte>(Encoding.ASCII.GetBytes(modern ? "GIF89a" : "GIF87a"));
bytes.AddRange(LittleEndian16(width));
bytes.AddRange(LittleEndian16(height));
bytes.Add(0x00); // no global colour table, which keeps the offsets simple
bytes.Add(0x00);
bytes.Add(0x00);
for (var frame = 0; frame < frames; frame++)
{
bytes.Add(0x2C);
bytes.AddRange(LittleEndian16(0));
bytes.AddRange(LittleEndian16(0));
bytes.AddRange(LittleEndian16(width));
bytes.AddRange(LittleEndian16(height));
bytes.Add(0x00); // no local colour table
bytes.Add(0x02); // LZW minimum code size
bytes.Add(0x03); // one sub-block of three bytes
bytes.AddRange([0x01, 0x02, 0x03]);
bytes.Add(0x00); // sub-block terminator
}
bytes.Add(0x3B);
return [.. bytes];
}
/// <summary>An animated GIF whose frames are separated by graphic control extensions.</summary>
public static byte[] GifWithExtensions()
{
var bytes = new List<byte>(Encoding.ASCII.GetBytes("GIF89a"));
bytes.AddRange(LittleEndian16(4));
bytes.AddRange(LittleEndian16(4));
bytes.Add(0x80 | 0x01); // global colour table, 2^(1+1) = 4 entries
bytes.Add(0x00);
bytes.Add(0x00);
bytes.AddRange(new byte[3 * 4]);
for (var frame = 0; frame < 2; frame++)
{
bytes.Add(0x21);
bytes.Add(0xF9);
bytes.Add(0x04);
bytes.AddRange([0x00, 0x0A, 0x00, 0x00]);
bytes.Add(0x00);
bytes.Add(0x2C);
bytes.AddRange(LittleEndian16(0));
bytes.AddRange(LittleEndian16(0));
bytes.AddRange(LittleEndian16(4));
bytes.AddRange(LittleEndian16(4));
bytes.Add(0x00);
bytes.Add(0x02);
bytes.Add(0x02);
bytes.AddRange([0x01, 0x02]);
bytes.Add(0x00);
}
bytes.Add(0x3B);
return [.. bytes];
}
public static byte[] WebpStill()
{
var body = new List<byte>("WEBP"u8.ToArray());
body.AddRange("VP8 "u8.ToArray());
body.AddRange(LittleEndian32(16));
body.AddRange(new byte[16]);
return Riff(body);
}
public static byte[] WebpLossless()
{
var body = new List<byte>("WEBP"u8.ToArray());
body.AddRange("VP8L"u8.ToArray());
body.AddRange(LittleEndian32(16));
body.AddRange(new byte[16]);
return Riff(body);
}
public static byte[] WebpExtended(bool animated, int width = 100, int height = 50)
{
var body = new List<byte>("WEBP"u8.ToArray());
body.AddRange("VP8X"u8.ToArray());
body.AddRange(LittleEndian32(10));
body.Add((byte)(animated ? 0x02 : 0x10)); // ANIM, versus a plain alpha flag
body.AddRange([0x00, 0x00, 0x00]);
body.AddRange(LittleEndian24(width - 1));
body.AddRange(LittleEndian24(height - 1));
body.AddRange(new byte[8]);
return Riff(body);
}
public static byte[] Mp4(string brand = "isom") =>
[0x00, 0x00, 0x00, 0x20, .. "ftyp"u8, .. Encoding.ASCII.GetBytes(brand), .. new byte[20]];
public static byte[] Avif() => Mp4("avif");
public static byte[] WebM() => Ebml("webm");
public static byte[] Matroska() => Ebml("matroska");
public static byte[] Html(string body = "<!DOCTYPE html><html><body>Image not found</body></html>") =>
Encoding.UTF8.GetBytes(body);
private static byte[] Ebml(string docType)
{
var bytes = new List<byte> { 0x1A, 0x45, 0xDF, 0xA3 };
bytes.AddRange([0x9F, 0x42, 0x86, 0x81, 0x01]); // a plausible-looking EBML header preamble
bytes.Add(0x42);
bytes.Add(0x82);
bytes.Add((byte)docType.Length);
bytes.AddRange(Encoding.ASCII.GetBytes(docType));
bytes.AddRange(new byte[16]);
return [.. bytes];
}
private static byte[] Riff(List<byte> body)
{
var bytes = new List<byte>("RIFF"u8.ToArray());
bytes.AddRange(LittleEndian32(body.Count));
bytes.AddRange(body);
return [.. bytes];
}
private static byte[] Ihdr(int width, int height)
{
var data = new byte[13];
BinaryPrimitives.WriteUInt32BigEndian(data.AsSpan(0, 4), (uint)width);
BinaryPrimitives.WriteUInt32BigEndian(data.AsSpan(4, 4), (uint)height);
data[8] = 8;
return data;
}
private static byte[] Chunk(string type, byte[] data)
{
var bytes = new List<byte>();
var length = new byte[4];
BinaryPrimitives.WriteUInt32BigEndian(length, (uint)data.Length);
bytes.AddRange(length);
bytes.AddRange(Encoding.ASCII.GetBytes(type));
bytes.AddRange(data);
bytes.AddRange(new byte[4]); // CRC, never checked here
return [.. bytes];
}
private static byte[] LittleEndian16(int value) => [(byte)(value & 0xFF), (byte)((value >> 8) & 0xFF)];
private static byte[] LittleEndian24(int value) =>
[(byte)(value & 0xFF), (byte)((value >> 8) & 0xFF), (byte)((value >> 16) & 0xFF)];
private static byte[] LittleEndian32(int value) =>
[(byte)(value & 0xFF), (byte)((value >> 8) & 0xFF), (byte)((value >> 16) & 0xFF), (byte)((value >> 24) & 0xFF)];
}
public class MediaSignatureTests
{
[Fact]
public void Jpeg_is_recognised() => MediaSignatures.Detect(Samples.Jpeg()).ShouldBe(MediaKind.Jpeg);
[Fact]
public void Png_is_recognised() => MediaSignatures.Detect(Samples.Png()).ShouldBe(MediaKind.Png);
[Theory]
[InlineData(true)]
[InlineData(false)]
public void Both_gif_versions_are_recognised(bool modern) =>
MediaSignatures.Detect(Samples.Gif(modern: modern)).ShouldBe(MediaKind.Gif);
[Fact]
public void Webp_is_recognised() => MediaSignatures.Detect(Samples.WebpStill()).ShouldBe(MediaKind.WebP);
[Theory]
[InlineData("isom")]
[InlineData("mp42")]
[InlineData("avc1")]
public void Mp4_brands_are_recognised(string brand) =>
MediaSignatures.Detect(Samples.Mp4(brand)).ShouldBe(MediaKind.Mp4);
[Fact]
public void Avif_is_told_apart_from_mp4() => MediaSignatures.Detect(Samples.Avif()).ShouldBe(MediaKind.Avif);
[Fact]
public void WebM_is_recognised() => MediaSignatures.Detect(Samples.WebM()).ShouldBe(MediaKind.WebM);
[Fact]
public void Matroska_is_not_mistaken_for_webm()
{
// Both open with the same EBML magic; only the DocType separates them.
MediaSignatures.Detect(Samples.Matroska()).ShouldBe(MediaKind.Unknown);
}
[Fact]
public void Anything_unrecognised_is_unknown()
{
MediaSignatures.Detect(Samples.Html()).ShouldBe(MediaKind.Unknown);
MediaSignatures.Detect(new byte[32]).ShouldBe(MediaKind.Unknown);
MediaSignatures.Detect([]).ShouldBe(MediaKind.Unknown);
MediaSignatures.Detect([0xFF]).ShouldBe(MediaKind.Unknown);
}
[Fact]
public void A_truncated_prefix_never_throws()
{
var png = Samples.Png();
for (var length = 0; length < png.Length; length++)
{
_ = MediaSignatures.Detect(png.AsSpan(0, length));
_ = MediaSignatures.DetectAnimation(MediaKind.Png, png.AsSpan(0, length));
_ = MediaSignatures.ReadDimensions(MediaKind.Png, png.AsSpan(0, length));
}
}
[Theory]
[InlineData("<!DOCTYPE html><html>")]
[InlineData("<html><body>gone</body></html>")]
[InlineData(" \n<!doctype HTML>")]
[InlineData("<?xml version=\"1.0\"?>")]
public void An_error_page_behind_a_200_is_spotted(string body) =>
MediaSignatures.LooksLikeHtml(Samples.Html(body)).ShouldBeTrue();
[Fact]
public void Real_media_is_not_mistaken_for_html()
{
MediaSignatures.LooksLikeHtml(Samples.Png()).ShouldBeFalse();
MediaSignatures.LooksLikeHtml(Samples.Gif()).ShouldBeFalse();
MediaSignatures.LooksLikeHtml(Samples.Jpeg()).ShouldBeFalse();
}
[Fact]
public void A_single_frame_gif_does_not_animate() =>
MediaSignatures.DetectAnimation(MediaKind.Gif, Samples.Gif(frames: 1)).ShouldBeFalse();
[Fact]
public void A_two_frame_gif_animates() =>
MediaSignatures.DetectAnimation(MediaKind.Gif, Samples.Gif(frames: 2)).ShouldBeTrue();
[Fact]
public void A_gif_with_control_extensions_between_frames_animates()
{
// Real animated GIFs interleave graphic control extensions and use a global colour table;
// walking past both is where a naive scanner goes wrong.
MediaSignatures.DetectAnimation(MediaKind.Gif, Samples.GifWithExtensions()).ShouldBeTrue();
}
[Fact]
public void A_plain_png_does_not_animate() =>
MediaSignatures.DetectAnimation(MediaKind.Png, Samples.Png()).ShouldBeFalse();
[Fact]
public void An_apng_animates() => MediaSignatures.DetectAnimation(MediaKind.Png, Samples.Apng()).ShouldBeTrue();
[Fact]
public void An_animation_chunk_after_the_first_idat_does_not_count()
{
// Decoders ignore acTL once IDAT has started, so claiming animation here would be a lie.
MediaSignatures.DetectAnimation(MediaKind.Png, Samples.PngWithLateAnimationChunk()).ShouldBeFalse();
}
[Fact]
public void A_plain_webp_does_not_animate()
{
MediaSignatures.DetectAnimation(MediaKind.WebP, Samples.WebpStill()).ShouldBeFalse();
MediaSignatures.DetectAnimation(MediaKind.WebP, Samples.WebpLossless()).ShouldBeFalse();
MediaSignatures.DetectAnimation(MediaKind.WebP, Samples.WebpExtended(animated: false)).ShouldBeFalse();
}
[Fact]
public void An_extended_webp_with_the_anim_flag_animates() =>
MediaSignatures.DetectAnimation(MediaKind.WebP, Samples.WebpExtended(animated: true)).ShouldBeTrue();
[Fact]
public void Png_dimensions_are_read()
{
var (width, height) = MediaSignatures.ReadDimensions(MediaKind.Png, Samples.Png(1920, 1080));
width.ShouldBe(1920);
height.ShouldBe(1080);
}
[Fact]
public void Gif_dimensions_are_read()
{
var (width, height) = MediaSignatures.ReadDimensions(MediaKind.Gif, Samples.Gif(width: 320, height: 240));
width.ShouldBe(320);
height.ShouldBe(240);
}
[Fact]
public void Extended_webp_dimensions_are_read()
{
// Stored as canvas-minus-one, which is the kind of off-by-one that ships unnoticed.
var (width, height) = MediaSignatures.ReadDimensions(
MediaKind.WebP,
Samples.WebpExtended(animated: true, width: 800, height: 600)
);
width.ShouldBe(800);
height.ShouldBe(600);
}
[Fact]
public void Dimensions_are_absent_rather_than_guessed()
{
MediaSignatures.ReadDimensions(MediaKind.Jpeg, Samples.Jpeg()).ShouldBe((null, null));
MediaSignatures.ReadDimensions(MediaKind.WebP, Samples.WebpStill()).ShouldBe((null, null));
}
[Fact]
public void Garbage_never_throws()
{
var random = new Random(20260813);
for (var attempt = 0; attempt < 500; attempt++)
{
var bytes = new byte[random.Next(0, 200)];
random.NextBytes(bytes);
var kind = MediaSignatures.Detect(bytes);
_ = MediaSignatures.DetectAnimation(kind, bytes);
_ = MediaSignatures.ReadDimensions(kind, bytes);
_ = MediaSignatures.LooksLikeHtml(bytes);
}
}
}