using System.Buffers.Binary;
using System.Text;
using AvParser.Core.Collecting;
using AvParser.Infrastructure.Collecting;
namespace AvParser.Infrastructure.Tests.Collecting;
/// Builds the smallest byte sequences that are still legitimately each format.
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(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(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];
}
/// A PNG carrying acTL only after IDAT — a still image with junk appended.
public static byte[] PngWithLateAnimationChunk()
{
var bytes = new List(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(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];
}
/// An animated GIF whose frames are separated by graphic control extensions.
public static byte[] GifWithExtensions()
{
var bytes = new List(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("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("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("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 = "Image not found") =>
Encoding.UTF8.GetBytes(body);
private static byte[] Ebml(string docType)
{
var bytes = new List { 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 body)
{
var bytes = new List("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();
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("")]
[InlineData("gone")]
[InlineData(" \n")]
[InlineData("")]
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);
}
}
}