#!/usr/bin/env python3
# SPDX-License-Identifier: LGPL-2.1-or-later

# Convert ELF static PIE to PE/EFI image.

# To do so we simply copy desired ELF sections while preserving their memory layout to ensure that
# code still runs as expected. We then translate ELF relocations to PE relocations so that the EFI
# loader/firmware can properly load the binary to any address at runtime.
#
# To make this as painless as possible we only operate on static PIEs as they should only contain
# base relocations that are easy to handle as they have a one-to-one mapping to PE relocations.
#
# EDK2 does a similar process using their GenFw tool. The main difference is that they use the
# --emit-relocs linker flag, which emits a lot of different (static) ELF relocation types that have
# to be handled differently for each architecture and is overall more work than its worth.
#
# Note that on arches where binutils has PE support (x86/x86_64 mostly, aarch64 only recently)
# objcopy can be used to convert ELF to PE. But this will still not convert ELF relocations, making
# the resulting binary useless. gnu-efi relies on this method and contains a stub that performs the
# ELF dynamic relocations at runtime.

# pylint: disable=missing-docstring,invalid-name,attribute-defined-outside-init

import argparse
import hashlib
import io
import os
import pathlib
import time
import typing
from ctypes import (
    c_char,
    c_uint8,
    c_uint16,
    c_uint32,
    c_uint64,
    LittleEndianStructure,
    sizeof,
)

from elftools.elf.constants import SH_FLAGS
from elftools.elf.elffile import ELFFile, Section as ELFSection
from elftools.elf.enums import (
    ENUM_DT_FLAGS_1,
    ENUM_RELOC_TYPE_AARCH64,
    ENUM_RELOC_TYPE_ARM,
    ENUM_RELOC_TYPE_i386,
    ENUM_RELOC_TYPE_x64,
)
from elftools.elf.relocation import (
    Relocation as ElfRelocation,
    RelocationTable as ElfRelocationTable,
)


class PeCoffHeader(LittleEndianStructure):
    _fields_ = (
        ("Machine", c_uint16),
        ("NumberOfSections", c_uint16),
        ("TimeDateStamp", c_uint32),
        ("PointerToSymbolTable", c_uint32),
        ("NumberOfSymbols", c_uint32),
        ("SizeOfOptionalHeader", c_uint16),
        ("Characteristics", c_uint16),
    )


class PeDataDirectory(LittleEndianStructure):
    _fields_ = (
        ("VirtualAddress", c_uint32),
        ("Size", c_uint32),
    )


class PeRelocationBlock(LittleEndianStructure):
    _fields_ = (
        ("PageRVA", c_uint32),
        ("BlockSize", c_uint32),
    )

    def __init__(self, PageRVA: int):
        super().__init__(PageRVA)
        self.entries: typing.List[PeRelocationEntry] = []


class PeRelocationEntry(LittleEndianStructure):
    _fields_ = (
        ("Offset", c_uint16, 12),
        ("Type", c_uint16, 4),
    )


class PeOptionalHeaderStart(LittleEndianStructure):
    _fields_ = (
        ("Magic", c_uint16),
        ("MajorLinkerVersion", c_uint8),
        ("MinorLinkerVersion", c_uint8),
        ("SizeOfCode", c_uint32),
        ("SizeOfInitializedData", c_uint32),
        ("SizeOfUninitializedData", c_uint32),
        ("AddressOfEntryPoint", c_uint32),
        ("BaseOfCode", c_uint32),
    )


class PeOptionalHeaderMiddle(LittleEndianStructure):
    _fields_ = (
        ("SectionAlignment", c_uint32),
        ("FileAlignment", c_uint32),
        ("MajorOperatingSystemVersion", c_uint16),
        ("MinorOperatingSystemVersion", c_uint16),
        ("MajorImageVersion", c_uint16),
        ("MinorImageVersion", c_uint16),
        ("MajorSubsystemVersion", c_uint16),
        ("MinorSubsystemVersion", c_uint16),
        ("Win32VersionValue", c_uint32),
        ("SizeOfImage", c_uint32),
        ("SizeOfHeaders", c_uint32),
        ("CheckSum", c_uint32),
        ("Subsystem", c_uint16),
        ("DllCharacteristics", c_uint16),
    )


class PeOptionalHeaderEnd(LittleEndianStructure):
    _fields_ = (
        ("LoaderFlags", c_uint32),
        ("NumberOfRvaAndSizes", c_uint32),
        ("ExportTable", PeDataDirectory),
        ("ImportTable", PeDataDirectory),
        ("ResourceTable", PeDataDirectory),
        ("ExceptionTable", PeDataDirectory),
        ("CertificateTable", PeDataDirectory),
        ("BaseRelocationTable", PeDataDirectory),
        ("Debug", PeDataDirectory),
        ("Architecture", PeDataDirectory),
        ("GlobalPtr", PeDataDirectory),
        ("TLSTable", PeDataDirectory),
        ("LoadConfigTable", PeDataDirectory),
        ("BoundImport", PeDataDirectory),
        ("IAT", PeDataDirectory),
        ("DelayImportDescriptor", PeDataDirectory),
        ("CLRRuntimeHeader", PeDataDirectory),
        ("Reserved", PeDataDirectory),
    )


class PeOptionalHeader(LittleEndianStructure):
    pass


class PeOptionalHeader32(PeOptionalHeader):
    _anonymous_ = ("Start", "Middle", "End")
    _fields_ = (
        ("Start", PeOptionalHeaderStart),
        ("BaseOfData", c_uint32),
        ("ImageBase", c_uint32),
        ("Middle", PeOptionalHeaderMiddle),
        ("SizeOfStackReserve", c_uint32),
        ("SizeOfStackCommit", c_uint32),
        ("SizeOfHeapReserve", c_uint32),
        ("SizeOfHeapCommit", c_uint32),
        ("End", PeOptionalHeaderEnd),
    )


class PeOptionalHeader32Plus(PeOptionalHeader):
    _anonymous_ = ("Start", "Middle", "End")
    _fields_ = (
        ("Start", PeOptionalHeaderStart),
        ("ImageBase", c_uint64),
        ("Middle", PeOptionalHeaderMiddle),
        ("SizeOfStackReserve", c_uint64),
        ("SizeOfStackCommit", c_uint64),
        ("SizeOfHeapReserve", c_uint64),
        ("SizeOfHeapCommit", c_uint64),
        ("End", PeOptionalHeaderEnd),
    )


class PeSection(LittleEndianStructure):
    _fields_ = (
        ("Name", c_char * 8),
        ("VirtualSize", c_uint32),
        ("VirtualAddress", c_uint32),
        ("SizeOfRawData", c_uint32),
        ("PointerToRawData", c_uint32),
        ("PointerToRelocations", c_uint32),
        ("PointerToLinenumbers", c_uint32),
        ("NumberOfRelocations", c_uint16),
        ("NumberOfLinenumbers", c_uint16),
        ("Characteristics", c_uint32),
    )

    def __init__(self):
        super().__init__()
        self.data = bytearray()


N_DATA_DIRECTORY_ENTRIES = 16

assert sizeof(PeSection) == 40
assert sizeof(PeCoffHeader) == 20
assert sizeof(PeOptionalHeader32) == 224
assert sizeof(PeOptionalHeader32Plus) == 240

# EFI mandates 4KiB memory pages.
SECTION_ALIGNMENT = 4096
FILE_ALIGNMENT = 512

# Nobody cares about DOS headers, so put the PE header right after.
PE_OFFSET = 64


def align_to(x: int, align: int) -> int:
    return (x + align - 1) & ~(align - 1)


def use_section(elf_s: ELFSection) -> bool:
    # These sections are either needed during conversion to PE or are otherwise not needed
    # in the final PE image.
    IGNORE_SECTIONS = [
        ".ARM.exidx",
        ".dynamic",
        ".dynstr",
        ".dynsym",
        ".eh_frame_hdr",
        ".eh_frame",
        ".gnu.hash",
        ".hash",
        ".note.gnu.build-id",
        ".rel.dyn",
        ".rela.dyn",
    ]

    # Known sections we care about and want to be in the final PE.
    COPY_SECTIONS = [
        ".data",
        ".osrel",
        ".rodata",
        ".sbat",
        ".sdmagic",
        ".text",
    ]

    # By only dealing with allocating sections we effectively filter out debug sections.
    if not elf_s["sh_flags"] & SH_FLAGS.SHF_ALLOC:
        return False

    if elf_s.name in IGNORE_SECTIONS:
        return False

    # For paranoia we only handle sections we know of. Any new sections that come up should
    # be added to IGNORE_SECTIONS/COPY_SECTIONS and/or the linker script.
    if elf_s.name not in COPY_SECTIONS:
        raise RuntimeError(f"Unknown section {elf_s.name}, refusing.")

    if elf_s["sh_addr"] % SECTION_ALIGNMENT != 0:
        raise RuntimeError(f"Section {elf_s.name} is not aligned.")
    if len(elf_s.name) > 8:
        raise RuntimeError(f"ELF section name {elf_s.name} too long.")

    return True


def convert_elf_section(elf_s: ELFSection) -> PeSection:
    pe_s = PeSection()
    pe_s.Name = elf_s.name.encode()
    pe_s.VirtualSize = elf_s.data_size
    pe_s.VirtualAddress = elf_s["sh_addr"]
    pe_s.SizeOfRawData = align_to(elf_s.data_size, FILE_ALIGNMENT)
    pe_s.data = bytearray(elf_s.data())

    if elf_s["sh_flags"] & SH_FLAGS.SHF_EXECINSTR:
        pe_s.Characteristics = 0x60000020  # CNT_CODE|MEM_READ|MEM_EXECUTE
    elif elf_s["sh_flags"] & SH_FLAGS.SHF_WRITE:
        pe_s.Characteristics = 0xC0000040  # CNT_INITIALIZED_DATA|MEM_READ|MEM_WRITE
    else:
        pe_s.Characteristics = 0x40000040  # CNT_INITIALIZED_DATA|MEM_READ

    return pe_s


def copy_sections(elf: ELFFile, opt: PeOptionalHeader) -> typing.List[PeSection]:
    sections = []

    for elf_s in elf.iter_sections():
        if not use_section(elf_s):
            continue

        pe_s = convert_elf_section(elf_s)
        if pe_s.Name == b".text":
            opt.BaseOfCode = pe_s.VirtualAddress
            opt.SizeOfCode += pe_s.VirtualSize
        else:
            opt.SizeOfInitializedData += pe_s.VirtualSize

        if pe_s.Name == b".data" and isinstance(opt, PeOptionalHeader32):
            opt.BaseOfData = pe_s.VirtualAddress

        sections.append(pe_s)

    return sections


def apply_elf_relative_relocation(
    reloc: ElfRelocation, image_base: int, sections: typing.List[PeSection], addend_size: int
):
    # fmt: off
    [target] = [
        pe_s for pe_s in sections
        if pe_s.VirtualAddress <= reloc["r_offset"] < pe_s.VirtualAddress + len(pe_s.data)
    ]
    # fmt: on

    addend_offset = reloc["r_offset"] - target.VirtualAddress

    if reloc.is_RELA():
        addend = reloc["r_addend"]
    else:
        addend = target.data[addend_offset : addend_offset + addend_size]
        addend = int.from_bytes(addend, byteorder="little")

    # This currently assumes that the ELF file has an image base of 0.
    value = (image_base + addend).to_bytes(addend_size, byteorder="little")
    target.data[addend_offset : addend_offset + addend_size] = value


def convert_elf_reloc_table(
    elf: ELFFile,
    elf_reloc_table: ElfRelocationTable,
    image_base: int,
    sections: typing.List[PeSection],
    pe_reloc_blocks: typing.Dict[int, PeRelocationBlock],
):
    NONE_RELOC = {
        "EM_386": ENUM_RELOC_TYPE_i386["R_386_NONE"],
        "EM_AARCH64": ENUM_RELOC_TYPE_AARCH64["R_AARCH64_NONE"],
        "EM_ARM": ENUM_RELOC_TYPE_ARM["R_ARM_NONE"],
        "EM_LOONGARCH": 0,
        "EM_RISCV": 0,
        "EM_X86_64": ENUM_RELOC_TYPE_x64["R_X86_64_NONE"],
    }[elf["e_machine"]]

    RELATIVE_RELOC = {
        "EM_386": ENUM_RELOC_TYPE_i386["R_386_RELATIVE"],
        "EM_AARCH64": ENUM_RELOC_TYPE_AARCH64["R_AARCH64_RELATIVE"],
        "EM_ARM": ENUM_RELOC_TYPE_ARM["R_ARM_RELATIVE"],
        "EM_LOONGARCH": 3,
        "EM_RISCV": 3,
        "EM_X86_64": ENUM_RELOC_TYPE_x64["R_X86_64_RELATIVE"],
    }[elf["e_machine"]]

    for reloc in elf_reloc_table.iter_relocations():
        if reloc["r_info_type"] == NONE_RELOC:
            continue

        if reloc["r_info_type"] == RELATIVE_RELOC:
            apply_elf_relative_relocation(
                reloc, image_base, sections, elf.elfclass // 8
            )

            # Now that the ELF relocation has been applied, we can create a PE relocation.
            block_rva = reloc["r_offset"] & ~0xFFF
            if block_rva not in pe_reloc_blocks:
                pe_reloc_blocks[block_rva] = PeRelocationBlock(block_rva)

            entry = PeRelocationEntry()
            entry.Offset = reloc["r_offset"] & 0xFFF
            # REL_BASED_HIGHLOW or REL_BASED_DIR64
            entry.Type = 3 if elf.elfclass == 32 else 10
            pe_reloc_blocks[block_rva].entries.append(entry)

            continue

        raise RuntimeError(f"Unsupported relocation {reloc}")


def convert_elf_relocations(
    elf: ELFFile, opt: PeOptionalHeader, sections: typing.List[PeSection]
) -> typing.Optional[PeSection]:
    dynamic = elf.get_section_by_name(".dynamic")
    if dynamic is None:
        raise RuntimeError("ELF .dynamic section is missing.")

    [flags_tag] = dynamic.iter_tags("DT_FLAGS_1")
    if not flags_tag["d_val"] & ENUM_DT_FLAGS_1["DF_1_PIE"]:
        raise RuntimeError("ELF file is not a PIE.")

    pe_reloc_blocks: typing.Dict[int, PeRelocationBlock] = {}
    for reloc_type, reloc_table in dynamic.get_relocation_tables().items():
        if reloc_type not in ["REL", "RELA"]:
            raise RuntimeError("Unsupported relocation type {elf_reloc_type}.")
        convert_elf_reloc_table(
            elf, reloc_table, opt.ImageBase, sections, pe_reloc_blocks
        )

    if len(pe_reloc_blocks) == 0:
        return None

    data = bytearray()
    for rva in sorted(pe_reloc_blocks):
        block = pe_reloc_blocks[rva]
        n_relocs = len(block.entries)

        # Each block must start on a 32-bit boundary. Because each entry is 16 bits
        # the len has to be even. We pad by adding a none relocation.
        if n_relocs % 2 != 0:
            n_relocs += 1
            block.entries.append(PeRelocationEntry())

        block.BlockSize = (
            sizeof(PeRelocationBlock) + sizeof(PeRelocationEntry) * n_relocs
        )
        data += block
        for entry in sorted(block.entries, key=lambda e: e.Offset):
            data += entry

    pe_reloc_s = PeSection()
    pe_reloc_s.Name = b".reloc"
    pe_reloc_s.data = data
    pe_reloc_s.VirtualSize = len(data)
    pe_reloc_s.SizeOfRawData = align_to(len(data), FILE_ALIGNMENT)
    pe_reloc_s.VirtualAddress = align_to(
        sections[-1].VirtualAddress + sections[-1].VirtualSize, SECTION_ALIGNMENT
    )
    # CNT_INITIALIZED_DATA|MEM_READ|MEM_DISCARDABLE
    pe_reloc_s.Characteristics = 0x42000040

    sections.append(pe_reloc_s)
    opt.SizeOfInitializedData += pe_reloc_s.VirtualSize
    return pe_reloc_s


def write_pe(
    file, coff: PeCoffHeader, opt: PeOptionalHeader, sections: typing.List[PeSection]
):
    file.write(b"MZ")
    file.seek(0x3C, io.SEEK_SET)
    file.write(PE_OFFSET.to_bytes(2, byteorder="little"))
    file.seek(PE_OFFSET, io.SEEK_SET)
    file.write(b"PE\0\0")
    file.write(coff)
    file.write(opt)

    offset = opt.SizeOfHeaders
    for pe_s in sorted(sections, key=lambda s: s.VirtualAddress):
        if pe_s.VirtualAddress < opt.SizeOfHeaders:
            # Linker script should make sure this does not happen.
            raise RuntimeError(f"Section {pe_s.Name} overlapping PE headers.")

        pe_s.PointerToRawData = offset
        file.write(pe_s)
        offset = align_to(offset + len(pe_s.data), FILE_ALIGNMENT)

    for pe_s in sections:
        file.seek(pe_s.PointerToRawData, io.SEEK_SET)
        file.write(pe_s.data)

    file.truncate(offset)


def elf2efi(args: argparse.Namespace):
    elf = ELFFile(args.ELF)
    if not elf.little_endian:
        raise RuntimeError("ELF file is not little-endian.")
    if elf["e_type"] not in ["ET_DYN", "ET_EXEC"]:
        raise RuntimeError("Unsupported ELF type.")

    pe_arch = {
        "EM_386": 0x014C,
        "EM_AARCH64": 0xAA64,
        "EM_ARM": 0x01C2,
        "EM_LOONGARCH": 0x6232 if elf.elfclass == 32 else 0x6264,
        "EM_RISCV": 0x5032 if elf.elfclass == 32 else 0x5064,
        "EM_X86_64": 0x8664,
    }.get(elf["e_machine"])
    if pe_arch is None:
        raise RuntimeError(f"Unuspported ELF arch {elf['e_machine']}")

    coff = PeCoffHeader()
    opt = PeOptionalHeader32() if elf.elfclass == 32 else PeOptionalHeader32Plus()

    # We relocate to a unique image base to reduce the chances for runtime relocation to occur.
    base_name = pathlib.Path(args.PE.name).name.encode()
    opt.ImageBase = int(hashlib.sha1(base_name).hexdigest()[0:8], 16)
    if elf.elfclass == 32:
        opt.ImageBase = (0x400000 + opt.ImageBase) & 0xFFFF0000
    else:
        opt.ImageBase = (0x100000000 + opt.ImageBase) & 0x1FFFF0000

    sections = copy_sections(elf, opt)
    pe_reloc_s = convert_elf_relocations(elf, opt, sections)

    coff.Machine = pe_arch
    coff.NumberOfSections = len(sections)
    coff.TimeDateStamp = int(os.environ.get("SOURCE_DATE_EPOCH", time.time()))
    coff.SizeOfOptionalHeader = sizeof(opt)
    # EXECUTABLE_IMAGE|LINE_NUMS_STRIPPED|LOCAL_SYMS_STRIPPED|DEBUG_STRIPPED
    # and (32BIT_MACHINE or LARGE_ADDRESS_AWARE)
    coff.Characteristics = 0x30E if elf.elfclass == 32 else 0x22E

    opt.AddressOfEntryPoint = elf["e_entry"]
    opt.SectionAlignment = SECTION_ALIGNMENT
    opt.FileAlignment = FILE_ALIGNMENT
    opt.MajorImageVersion = args.version_major
    opt.MinorImageVersion = args.version_minor
    opt.MajorSubsystemVersion = args.efi_major
    opt.MinorSubsystemVersion = args.efi_minor
    opt.Subsystem = args.subsystem
    opt.Magic = 0x10B if elf.elfclass == 32 else 0x20B
    opt.SizeOfImage = align_to(
        sections[-1].VirtualAddress + sections[-1].VirtualSize, SECTION_ALIGNMENT
    )

    opt.SizeOfHeaders = align_to(
        PE_OFFSET
        + coff.SizeOfOptionalHeader
        + sizeof(PeSection) * max(coff.NumberOfSections, args.minimum_sections),
        FILE_ALIGNMENT,
    )
    # DYNAMIC_BASE|NX_COMPAT|HIGH_ENTROPY_VA or DYNAMIC_BASE|NX_COMPAT
    opt.DllCharacteristics = 0x160 if elf.elfclass == 64 else 0x140

    # These values are taken from a natively built PE binary (although, unused by EDK2/EFI).
    opt.SizeOfStackReserve = 0x100000
    opt.SizeOfStackCommit = 0x001000
    opt.SizeOfHeapReserve = 0x100000
    opt.SizeOfHeapCommit = 0x001000

    opt.NumberOfRvaAndSizes = N_DATA_DIRECTORY_ENTRIES
    if pe_reloc_s:
        opt.BaseRelocationTable = PeDataDirectory(
            pe_reloc_s.VirtualAddress, pe_reloc_s.VirtualSize
        )

    write_pe(args.PE, coff, opt, sections)


def main():
    parser = argparse.ArgumentParser(description="Convert ELF binaries to PE/EFI")
    parser.add_argument(
        "--version-major",
        type=int,
        default=0,
        help="Major image version of EFI image",
    )
    parser.add_argument(
        "--version-minor",
        type=int,
        default=0,
        help="Minor image version of EFI image",
    )
    parser.add_argument(
        "--efi-major",
        type=int,
        default=0,
        help="Minimum major EFI subsystem version",
    )
    parser.add_argument(
        "--efi-minor",
        type=int,
        default=0,
        help="Minimum minor EFI subsystem version",
    )
    parser.add_argument(
        "--subsystem",
        type=int,
        default=10,
        help="PE subsystem",
    )
    parser.add_argument(
        "ELF",
        type=argparse.FileType("rb"),
        help="Input ELF file",
    )
    parser.add_argument(
        "PE",
        type=argparse.FileType("wb"),
        help="Output PE/EFI file",
    )
    parser.add_argument(
        "--minimum-sections",
        type=int,
        default=0,
        help="Minimum number of sections to leave space for",
    )

    elf2efi(parser.parse_args())


if __name__ == "__main__":
    main()
