#!/usr/bin/env python3

#
# verify-debug-symbols
#
# Copyright (C) 2026 by Posit Software, PBC
#
# Unless you have received this program directly from Posit Software pursuant
# to the terms of a commercial license agreement with Posit Software, then
# this program is licensed to you under the terms of version 3 of the
# GNU Affero General Public License. This program is distributed WITHOUT
# ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT,
# MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the
# AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details.
#

"""Check whether a debug symbol file is usable with a released binary.

Usage: verify-debug-symbols <released-binary> <debug-file>

Debug symbols regenerated by rebuilding a release (see
prepare-symbol-backfill) always carry a different GNU build ID, since
the build ID is a hash over the freshly linked output. That alone does
not make them unusable: what symbolization actually depends on is that
code addresses line up. This tool checks the things that matter:

  - .text (and other allocatable sections) must have identical
    addresses and sizes;
  - symbols exported by the binary's .dynsym must have identical
    addresses in the debug file's .symtab.

A small size difference confined to .rodata (with the sections after
it shifted but identically sized) is expected and benign: it comes
from compiled-in build metadata such as the git commit hash, which a
backfill rebuild can never reproduce. Unwind and constant data are
read from the real binary, not the debug file, and the shift is
absorbed by page alignment before the data segment.

If the check passes, load the symbols in gdb explicitly, since the
build ID mismatch prevents automatic pairing:

    (gdb) symbol-file /path/to/rserver.debug

This is a pure ELF parser with no dependencies, so it runs anywhere
(including macOS) against Linux binaries.
"""

import struct
import sys

SHF_ALLOC = 0x2
SHT_SYMTAB = 2
SHT_NOTE = 7
SHT_DYNSYM = 11
NT_GNU_BUILD_ID = 3

# Sections whose address may legitimately shift after a backfill
# rebuild: .rodata grows or shrinks with build metadata, displacing
# the rest of the read-only segment. Sizes must still match for the
# sections after .rodata.
RODATA_TAIL = (".eh_frame_hdr", ".eh_frame", ".gcc_except_table", "protodesc_cold")


def parse_elf(path):
    with open(path, "rb") as f:
        data = f.read()

    if data[:4] != b"\x7fELF" or data[4] != 2:
        raise SystemExit(f"{path}: not a 64-bit ELF file")

    end = "<" if data[5] == 1 else ">"
    e_shoff, = struct.unpack_from(end + "Q", data, 0x28)
    e_shentsize, e_shnum, e_shstrndx = struct.unpack_from(end + "HHH", data, 0x3A)

    sections = []
    for i in range(e_shnum):
        off = e_shoff + i * e_shentsize
        sh_name, sh_type, sh_flags, sh_addr, sh_offset, sh_size, sh_link, _, _, _ = \
            struct.unpack_from(end + "IIQQQQIIQQ", data, off)
        sections.append(dict(name_off=sh_name, type=sh_type, flags=sh_flags,
                             addr=sh_addr, offset=sh_offset, size=sh_size,
                             link=sh_link))

    shstr = sections[e_shstrndx]
    strtab = data[shstr["offset"]:shstr["offset"] + shstr["size"]]
    for s in sections:
        nul = strtab.index(b"\x00", s["name_off"])
        s["name"] = strtab[s["name_off"]:nul].decode()

    return data, end, sections


def build_id(data, end, sections):
    for s in sections:
        if s["type"] != SHT_NOTE:
            continue
        pos, section_end = s["offset"], s["offset"] + s["size"]
        while pos + 12 <= section_end:
            namesz, descsz, ntype = struct.unpack_from(end + "III", data, pos)
            name = data[pos + 12:pos + 12 + namesz].rstrip(b"\x00")
            desc_off = pos + 12 + ((namesz + 3) & ~3)
            if name == b"GNU" and ntype == NT_GNU_BUILD_ID:
                return data[desc_off:desc_off + descsz].hex()
            pos = desc_off + ((descsz + 3) & ~3)
    return None


def read_symbols(data, end, sections, sh_type):
    """Return {name: addr} for FUNC/OBJECT symbols with addresses."""
    syms = {}
    for s in sections:
        if s["type"] != sh_type:
            continue
        strsec = sections[s["link"]]
        strtab = data[strsec["offset"]:strsec["offset"] + strsec["size"]]
        for i in range(s["size"] // 24):
            off = s["offset"] + i * 24
            st_name, st_info, _, _, st_value, _ = struct.unpack_from(end + "IBBHQQ", data, off)
            if st_name == 0 or st_value == 0 or (st_info & 0xF) not in (1, 2):
                continue
            nul = strtab.index(b"\x00", st_name)
            syms[strtab[st_name:nul].decode(errors="replace")] = st_value
    return syms


def main():
    if len(sys.argv) != 3:
        raise SystemExit(__doc__.strip())

    binary_path, debug_path = sys.argv[1], sys.argv[2]
    bin_data, bin_end, bin_sections = parse_elf(binary_path)
    dbg_data, dbg_end, dbg_sections = parse_elf(debug_path)

    print(f"binary build ID: {build_id(bin_data, bin_end, bin_sections)}")
    print(f"debug  build ID: {build_id(dbg_data, dbg_end, dbg_sections)}")
    print()

    print("== Allocatable sections ==")
    dbg_by_name = {s["name"]: s for s in dbg_sections}
    errors = 0
    benign = 0
    for s in bin_sections:
        if not (s["flags"] & SHF_ALLOC) or not s["name"]:
            continue
        d = dbg_by_name.get(s["name"])
        if d is None:
            print(f"  {s['name']}: missing in debug file  ERROR")
            errors += 1
            continue
        if s["addr"] == d["addr"] and s["size"] == d["size"]:
            continue

        detail = (f"binary addr=0x{s['addr']:x} size=0x{s['size']:x} "
                  f"vs debug addr=0x{d['addr']:x} size=0x{d['size']:x}")
        if s["name"] == ".rodata" and s["addr"] == d["addr"]:
            print(f"  {s['name']}: {detail}  benign (build metadata)")
            benign += 1
        elif s["name"] in RODATA_TAIL and s["size"] == d["size"]:
            print(f"  {s['name']}: {detail}  benign (shifted by .rodata)")
            benign += 1
        else:
            print(f"  {s['name']}: {detail}  ERROR")
            errors += 1

    print(f"  {errors} errors, {benign} benign differences")
    print()

    print("== Symbol addresses (.dynsym vs debug .symtab) ==")
    dynsyms = read_symbols(bin_data, bin_end, bin_sections, SHT_DYNSYM)
    debug_syms = read_symbols(dbg_data, dbg_end, dbg_sections, SHT_SYMTAB)
    common = sorted(set(dynsyms) & set(debug_syms))
    diff = [n for n in common if dynsyms[n] != debug_syms[n]]
    print(f"  {len(common)} common symbols, {len(diff)} address mismatches")
    for n in diff[:10]:
        print(f"    {n}: binary=0x{dynsyms[n]:x} debug=0x{debug_syms[n]:x}")
    print()

    if errors == 0 and not diff and common:
        print("RESULT: COMPATIBLE -- load with 'symbol-file' in gdb")
        return 0

    print("RESULT: INCOMPATIBLE -- these symbols do not match this binary")
    return 1


if __name__ == "__main__":
    sys.exit(main())
