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); } } }