tests/HumanoidRetargeter.Tests/Formats/GltfMalformedInputTests.cs

Unit tests for the glTF importer that assert malformed or hostile inputs fail with FormatException. Tests cover node-graph cycles, invalid accessor counts (negative and huge), and excessive animation key-time spans that would cause huge allocations.

File Access
using System.Text;
using HumanoidRetargeter.Core.Formats.Gltf;
using Xunit;

namespace HumanoidRetargeter.Tests.Formats;

/// <summary>
/// Malformed/hostile glTF inputs must fail with <see cref="FormatException"/> (the importer's
/// malformed-file contract) instead of hanging, overflowing, or OOM-allocating: node graphs
/// whose children lists form a cycle (unbounded DFS / parent walks), attacker-controlled
/// accessor counts (negative → <see cref="OverflowException"/> from the array allocation,
/// huge → OOM), and unbounded animation key-time spans (resampled frame count = span × fps —
/// an allocation bomb). All documents are tiny in-test JSON with base64 data: URI buffers.
/// </summary>
public class GltfMalformedInputTests
{
    private static FormatException ImportThrows(string json)
        => Assert.Throws<FormatException>(() => GltfImporter.Import(Encoding.UTF8.GetBytes(json)));

    private static string Base64Floats(params float[] values)
    {
        var bytes = new byte[values.Length * 4];
        Buffer.BlockCopy(values, 0, bytes, 0, bytes.Length);
        return Convert.ToBase64String(bytes);
    }

    // ---------------------------------------------------------------- node-graph cycle

    [Fact]
    public void NodeGraphCycle_ThrowsFormatException()
    {
        // A → B → C → B: per spec each node has at most one parent, so ANY revisit during the
        // skeleton-selection DFS means the children lists are cyclic. Unguarded recursion
        // would overflow the stack (taking the host process down uncatchably); the importer
        // must fail cleanly instead.
        var json = """
            {
                "asset": { "version": "2.0" },
                "nodes": [
                    { "name": "A", "children": [1] },
                    { "name": "B", "children": [2] },
                    { "name": "C", "children": [1] }
                ],
                "skins": [ { "joints": [1] } ]
            }
            """;
        var e = ImportThrows(json);
        Assert.Contains("cycle", e.Message, StringComparison.OrdinalIgnoreCase);
    }

    // ---------------------------------------------------------------- accessor count validation

    /// <summary>One animated node whose sampler INPUT accessor declares the given count; the
    /// backing data: URI buffer really holds only 2 floats (8 bytes).</summary>
    private static string GltfWithInputAccessorCount(string count)
    {
        var times = Base64Floats(0f, 1f);
        var rotations = Base64Floats(0f, 0f, 0f, 1f, 0f, 0f, 0f, 1f);
        return $$"""
            {
                "asset": { "version": "2.0" },
                "nodes": [ { "name": "root" } ],
                "animations": [ {
                    "channels": [ { "sampler": 0, "target": { "node": 0, "path": "rotation" } } ],
                    "samplers": [ { "input": 0, "output": 1, "interpolation": "LINEAR" } ]
                } ],
                "buffers": [
                    { "byteLength": 8, "uri": "data:application/octet-stream;base64,{{times}}" },
                    { "byteLength": 32, "uri": "data:application/octet-stream;base64,{{rotations}}" }
                ],
                "bufferViews": [
                    { "buffer": 0, "byteOffset": 0, "byteLength": 8 },
                    { "buffer": 1, "byteOffset": 0, "byteLength": 32 }
                ],
                "accessors": [
                    { "bufferView": 0, "componentType": 5126, "count": {{count}}, "type": "SCALAR" },
                    { "bufferView": 1, "componentType": 5126, "count": 2, "type": "VEC4" }
                ]
            }
            """;
    }

    [Fact]
    public void AccessorNegativeCount_ThrowsFormatException_NotOverflow()
    {
        // A negative count would reach `new float[count * comps]` and surface as
        // OverflowException — the count must be validated BEFORE any allocation sized by it.
        var e = ImportThrows(GltfWithInputAccessorCount("-1"));
        Assert.Contains("negative count", e.Message);
    }

    [Fact]
    public void AccessorHugeCount_ThrowsFormatException_NotOutOfMemory()
    {
        // 400M SCALAR floats (~1.6 GB) "backed" by an 8-byte buffer: the long-arithmetic
        // bounds check must reject the accessor before allocating anything count-sized.
        var e = ImportThrows(GltfWithInputAccessorCount("400000000"));
        Assert.Contains("reads past the end", e.Message);
    }

    // ---------------------------------------------------------------- clip duration cap

    [Fact]
    public void KeyTimeSpanBeyondCap_ThrowsFormatException()
    {
        // Two keys spanning 1e7 s would resample to 300M frames at 30 fps (an allocation
        // bomb); the importer caps the clip key-time span at one hour and treats anything
        // larger as malformed data.
        var times = Base64Floats(0f, 1e7f);
        var rotations = Base64Floats(0f, 0f, 0f, 1f, 0f, 0f, 0f, 1f);
        var json = $$"""
            {
                "asset": { "version": "2.0" },
                "nodes": [ { "name": "root" } ],
                "animations": [ {
                    "channels": [ { "sampler": 0, "target": { "node": 0, "path": "rotation" } } ],
                    "samplers": [ { "input": 0, "output": 1, "interpolation": "LINEAR" } ]
                } ],
                "buffers": [
                    { "byteLength": 8, "uri": "data:application/octet-stream;base64,{{times}}" },
                    { "byteLength": 32, "uri": "data:application/octet-stream;base64,{{rotations}}" }
                ],
                "bufferViews": [
                    { "buffer": 0, "byteOffset": 0, "byteLength": 8 },
                    { "buffer": 1, "byteOffset": 0, "byteLength": 32 }
                ],
                "accessors": [
                    { "bufferView": 0, "componentType": 5126, "count": 2, "type": "SCALAR" },
                    { "bufferView": 1, "componentType": 5126, "count": 2, "type": "VEC4" }
                ]
            }
            """;
        var e = ImportThrows(json);
        Assert.Contains("key-time span", e.Message);
        Assert.Contains("3600", e.Message);
    }
}