File: Win32ResourceSectionBuilder.cs
Web Access
Project: ILAssembler.csproj (ILAssembler)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// Copyright (c) .NET Foundation and Contributors.
// Adapted from Roslyn's CvtRes.cs and PEWriter/NativeResourceWriter.cs:

using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection.Metadata;
using System.Reflection.PortableExecutable;
using System.Text;

namespace ILAssembler;

// Format specifications: https://learn.microsoft.com/windows/win32/menurc/resource-file-formats
// and https://learn.microsoft.com/windows/win32/debug/pe-format#the-rsrc-section
internal sealed class Win32ResourceSectionBuilder(byte[] sectionBytes, int[] relocations) : ResourceSectionBuilder
{
    private const int DirectoryHeaderSize = 16;
    private const int DirectoryEntrySize = 8;
    private const int DataEntrySize = 16;
    private const uint SubdirectoryOrNameFlag = 0x80000000;
    private const ushort DialogIncludeResourceType = 17;

    internal static Win32ResourceSectionBuilder Create(byte[] bytes)
    {
        try
        {
            if (bytes.Length < sizeof(uint))
            {
                throw new InvalidDataException("The Win32 resource file is empty or truncated.");
            }

            return BinaryPrimitives.ReadUInt32LittleEndian(bytes) == 0 ? ReadRes(bytes) : ReadCoff(bytes);
        }
        catch (Exception ex) when (ex is EndOfStreamException or BadImageFormatException or OverflowException)
        {
            throw new InvalidDataException("The Win32 resource file is malformed.", ex);
        }
    }

    protected override void Serialize(BlobBuilder builder, SectionLocation location)
    {
        var writer = new BlobWriter(builder.ReserveBytes(sectionBytes.Length));
        writer.WriteBytes(sectionBytes);
        foreach (int offset in relocations)
        {
            writer.Offset = offset;
            writer.WriteUInt32(checked(BinaryPrimitives.ReadUInt32LittleEndian(sectionBytes.AsSpan(offset)) +
                (uint)location.RelativeVirtualAddress));
        }
    }

    private readonly record struct Identifier(ushort Id, string? Name = null) : IComparable<Identifier>
    {
        public int CompareTo(Identifier other) => Name is null
            ? other.Name is null ? Id.CompareTo(other.Id) : 1
            : other.Name is null ? -1 : StringComparer.OrdinalIgnoreCase.Compare(Name, other.Name);
    }

    private sealed class Node
    {
        internal readonly SortedDictionary<Identifier, Node> Children = new();
        internal byte[]? Data;
    }

    private static Win32ResourceSectionBuilder ReadRes(byte[] bytes)
    {
        using var stream = new MemoryStream(bytes, writable: false);
        using var reader = new BinaryReader(stream, Encoding.Unicode);
        var root = new Node();
        bool firstResource = true;
        while (stream.Position < stream.Length)
        {
            int start = checked((int)stream.Position);
            int dataSize = checked((int)reader.ReadUInt32());
            int headerSize = checked((int)reader.ReadUInt32());
            // Two empty UTF-16 identifiers are the shortest possible type and name.
            const int MinimumHeaderSize = 2 * sizeof(uint) + 2 * sizeof(ushort) + 16;
            if (headerSize < MinimumHeaderSize || (headerSize & 3) != 0)
            {
                throw new InvalidDataException($"Invalid Win32 resource header at offset {start}.");
            }
            CheckRange(start, headerSize, bytes.Length);
            int headerEnd = checked(start + headerSize);
            CheckRange(headerEnd, dataSize, bytes.Length);
            Identifier type = ReadIdentifier(reader, headerEnd - 16);
            Identifier name = ReadIdentifier(reader, headerEnd - 16);
            stream.Position = Align(checked((int)stream.Position));
            if (stream.Position > headerEnd - 16)
            {
                throw new InvalidDataException($"Invalid Win32 resource identifiers at offset {start}.");
            }
            reader.ReadUInt32(); // DataVersion
            reader.ReadUInt16(); // MemoryFlags
            ushort language = reader.ReadUInt16();
            reader.ReadUInt32(); // Version
            reader.ReadUInt32(); // Characteristics
            stream.Position = headerEnd;
            byte[] data = reader.ReadBytes(dataSize);
            stream.Position = Align(checked(headerEnd + dataSize));

            if (firstResource)
            {
                if (dataSize != 0 || type != new Identifier(0) || name != new Identifier(0))
                {
                    throw new InvalidDataException("The .res file does not begin with a null resource.");
                }
                firstResource = false;
                continue;
            }
            if (type == new Identifier(DialogIncludeResourceType))
            {
                continue;
            }

            Node languageDirectory = GetOrAdd(GetOrAdd(root, type), name);
            if (!languageDirectory.Children.TryAdd(new Identifier(language), new Node { Data = data }))
            {
                throw new InvalidDataException("The .res file contains duplicate resource type, name, and language identifiers.");
            }
        }

        var builder = new BlobBuilder();
        var relocations = new List<int>();
        WriteDirectory(root, builder, relocations);
        return new Win32ResourceSectionBuilder(builder.ToArray(), relocations.ToArray());
    }

    private static Node GetOrAdd(Node parent, Identifier key)
    {
        if (!parent.Children.TryGetValue(key, out Node? node))
        {
            node = new Node();
            parent.Children.Add(key, node);
        }

        return node;
    }

    private static Identifier ReadIdentifier(BinaryReader reader, int end)
    {
        ushort first = ReadWord();
        if (first == ushort.MaxValue)
        {
            return new Identifier(ReadWord());
        }

        var name = new StringBuilder();
        for (ushort value = first; value != 0; value = ReadWord())
        {
            name.Append((char)value);
        }

        return new Identifier(0, name.ToString());

        ushort ReadWord()
        {
            if (reader.BaseStream.Position > end - sizeof(ushort))
            {
                throw new InvalidDataException("A Win32 resource identifier extends beyond its header.");
            }

            return reader.ReadUInt16();
        }
    }

    private static void WriteDirectory(Node directory, BlobBuilder builder, List<int> relocations)
    {
        var writer = new BlobWriter(builder.ReserveBytes(checked(DirectoryHeaderSize + DirectoryEntrySize * directory.Children.Count)));
        writer.WriteUInt32(0); // Characteristics
        writer.WriteUInt32(0); // TimeDateStamp
        writer.WriteUInt32(0); // MajorVersion, MinorVersion
        int namedCount = directory.Children.Keys.Count(key => key.Name is not null);
        writer.WriteUInt16(checked((ushort)namedCount));
        writer.WriteUInt16(checked((ushort)(directory.Children.Count - namedCount)));
        foreach ((Identifier identifier, Node child) in directory.Children)
        {
            if (identifier.Name is string name)
            {
                writer.WriteUInt32((uint)builder.Count | SubdirectoryOrNameFlag);
                builder.WriteUInt16(checked((ushort)name.Length));
                builder.WriteUTF16(name);
                builder.Align(sizeof(uint));
            }
            else
            {
                writer.WriteUInt32(identifier.Id);
            }

            int offset = builder.Count;
            if (child.Data is byte[] data)
            {
                writer.WriteUInt32((uint)offset);
                relocations.Add(offset);
                builder.WriteUInt32(checked((uint)(offset + DataEntrySize)));
                builder.WriteUInt32((uint)data.Length);
                builder.WriteUInt32(0); // CodePage
                builder.WriteUInt32(0); // Reserved
                builder.WriteBytes(data);
                builder.Align(sizeof(uint));
            }
            else
            {
                writer.WriteUInt32((uint)offset | SubdirectoryOrNameFlag);
                WriteDirectory(child, builder, relocations);
            }
        }
    }

    private static Win32ResourceSectionBuilder ReadCoff(byte[] bytes)
    {
        using var stream = new MemoryStream(bytes, writable: false);
        var headers = new PEHeaders(stream);
        SectionHeader directory = default;
        SectionHeader data = default;
        int directorySection = 0;
        int dataSection = 0;
        for (int i = 0; i < headers.SectionHeaders.Length; i++)
        {
            SectionHeader section = headers.SectionHeaders[i];
            if (section.Name == ".rsrc$01" && directorySection == 0)
            {
                directory = section;
                directorySection = i + 1;
            }
            else if (section.Name == ".rsrc$02" && dataSection == 0)
            {
                data = section;
                dataSection = i + 1;
            }
            else if (section.Name is ".rsrc$01" or ".rsrc$02")
            {
                throw new InvalidDataException("The COFF resource file has duplicate resource sections.");
            }
        }
        if (headers.PEHeader is not null || directorySection == 0 || dataSection == 0 || directory.SizeOfRawData < DirectoryHeaderSize)
        {
            throw new InvalidDataException("The COFF resource file must contain .rsrc$01 and .rsrc$02 sections.");
        }

        CheckRange(directory.PointerToRawData, directory.SizeOfRawData, bytes.Length);
        CheckRange(data.PointerToRawData, data.SizeOfRawData, bytes.Length);
        const int RelocationSize = 10;
        const int SymbolSize = 18;
        CheckRange(directory.PointerToRelocations, checked(directory.NumberOfRelocations * RelocationSize), bytes.Length);
        CheckRange(headers.CoffHeader.PointerToSymbolTable, checked(headers.CoffHeader.NumberOfSymbols * SymbolSize), bytes.Length);
        if (data.NumberOfRelocations != 0)
        {
            throw new InvalidDataException("The COFF resource data section must not contain relocations.");
        }
        ushort expectedRelocation = headers.CoffHeader.Machine switch
        {
            Machine.I386 => 0x0007, // IMAGE_REL_I386_DIR32NB
            Machine.Amd64 => 0x0003, // IMAGE_REL_AMD64_ADDR32NB
            Machine.Arm64 => 0x0002, // IMAGE_REL_ARM64_ADDR32NB
            _ => throw new InvalidDataException($"Unsupported COFF resource machine '{headers.CoffHeader.Machine}'.")
        };

        byte[] sectionBytes = new byte[checked(directory.SizeOfRawData + data.SizeOfRawData)];
        bytes.AsSpan(directory.PointerToRawData, directory.SizeOfRawData).CopyTo(sectionBytes);
        bytes.AsSpan(data.PointerToRawData, data.SizeOfRawData).CopyTo(sectionBytes.AsSpan(directory.SizeOfRawData));
        int[] relocations = new int[directory.NumberOfRelocations];
        var seenOffsets = new HashSet<int>();
        for (int i = 0; i < relocations.Length; i++)
        {
            int relocation = directory.PointerToRelocations + i * RelocationSize;
            int offset = checked((int)ReadUInt32(bytes, relocation));
            uint symbolIndex = ReadUInt32(bytes, relocation + 4);
            ushort relocationType = BinaryPrimitives.ReadUInt16LittleEndian(bytes.AsSpan(relocation + 8));
            CheckRange(offset, sizeof(uint), directory.SizeOfRawData);
            if (symbolIndex >= headers.CoffHeader.NumberOfSymbols || relocationType != expectedRelocation || !seenOffsets.Add(offset))
            {
                throw new InvalidDataException("Invalid COFF resource relocation.");
            }

            int symbol = checked(headers.CoffHeader.PointerToSymbolTable + (int)symbolIndex * SymbolSize);
            uint value = ReadUInt32(bytes, symbol + 8);
            short sectionNumber = BinaryPrimitives.ReadInt16LittleEndian(bytes.AsSpan(symbol + 12));
            ushort symbolType = BinaryPrimitives.ReadUInt16LittleEndian(bytes.AsSpan(symbol + 14));
            if (sectionNumber != dataSection || symbolType != 0)
            {
                throw new InvalidDataException("A COFF resource relocation must refer to a symbol in .rsrc$02.");
            }

            uint dataOffset = checked(value + ReadUInt32(sectionBytes, offset));
            if (dataOffset > data.SizeOfRawData)
            {
                throw new InvalidDataException("A COFF resource relocation points outside .rsrc$02.");
            }
            BinaryPrimitives.WriteUInt32LittleEndian(sectionBytes.AsSpan(offset), checked(dataOffset + (uint)directory.SizeOfRawData));
            relocations[i] = offset;
        }

        ValidateCoffDirectory(sectionBytes, directory.SizeOfRawData, seenOffsets);
        return new Win32ResourceSectionBuilder(sectionBytes, relocations);
    }

    private static void ValidateCoffDirectory(byte[] bytes, int directorySize, HashSet<int> relocations)
    {
        var directories = new HashSet<int>();
        var dataEntries = new HashSet<int>();
        ValidateDirectory(0, 0);
        if (!dataEntries.SetEquals(relocations))
        {
            throw new InvalidDataException("COFF resource relocations must identify every resource data address and no other fields.");
        }

        void ValidateDirectory(int offset, int level)
        {
            CheckRange(offset, DirectoryHeaderSize, directorySize);
            if (!directories.Add(offset))
            {
                throw new InvalidDataException("The COFF resource directory contains a cycle or a shared subdirectory.");
            }

            int namedCount = BinaryPrimitives.ReadUInt16LittleEndian(bytes.AsSpan(offset + 12));
            int idCount = BinaryPrimitives.ReadUInt16LittleEndian(bytes.AsSpan(offset + 14));
            int count = namedCount + idCount;
            int entriesStart = checked(offset + DirectoryHeaderSize);
            CheckRange(entriesStart, checked(count * DirectoryEntrySize), directorySize);
            for (int i = 0; i < count; i++)
            {
                int entry = entriesStart + i * DirectoryEntrySize;
                uint name = ReadUInt32(bytes, entry);
                bool isNamed = (name & SubdirectoryOrNameFlag) != 0;
                if (isNamed != (i < namedCount) || (level == 2 && isNamed))
                {
                    throw new InvalidDataException("Invalid COFF resource directory identifier.");
                }
                if (isNamed)
                {
                    int nameOffset = (int)(name & ~SubdirectoryOrNameFlag);
                    CheckRange(nameOffset, sizeof(ushort), directorySize);
                    int length = BinaryPrimitives.ReadUInt16LittleEndian(bytes.AsSpan(nameOffset));
                    CheckRange(nameOffset + sizeof(ushort), length * sizeof(char), directorySize);
                }

                uint child = ReadUInt32(bytes, entry + sizeof(uint));
                bool isDirectory = (child & SubdirectoryOrNameFlag) != 0;
                if (isDirectory != (level < 2))
                {
                    throw new InvalidDataException("The COFF resource directory must have type, name, and language levels.");
                }
                int childOffset = (int)(child & ~SubdirectoryOrNameFlag);
                if (isDirectory)
                {
                    ValidateDirectory(childOffset, level + 1);
                }
                else
                {
                    CheckRange(childOffset, DataEntrySize, directorySize);
                    int dataOffset = checked((int)ReadUInt32(bytes, childOffset));
                    int dataSize = checked((int)ReadUInt32(bytes, childOffset + sizeof(uint)));
                    CheckRange(dataOffset - directorySize, dataSize, bytes.Length - directorySize);
                    dataEntries.Add(childOffset);
                }
            }
        }
    }

    private static uint ReadUInt32(byte[] bytes, int offset) =>
        BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(offset));

    private static int Align(int value) => checked(value + 3) & ~3;

    private static void CheckRange(int offset, int size, int length)
    {
        if (offset < 0 || size < 0 || offset > length - size)
        {
            throw new InvalidDataException("The Win32 resource file contains an invalid offset or size.");
        }
    }
}