_ _
| |_ ___| |_ _
| | . | | | |
|_|_|___|_|_ |
|___|
git mirror - github.com/owenewans/holy - branch master
file tests/debug.py
#!/usr/bin/env python3
"""cuts a debug output from a runtime artifact with objcopy, then proves with GDB that
the source location and stack trace a user sees come from the artifact that installs."""
import pathlib
import shutil
import subprocess
import sys
import tempfile
binary = str(pathlib.Path(sys.argv[1]).resolve())
def call(*args, status=0):
result = subprocess.run([binary, *map(str, args)], capture_output=True, text=True)
assert result.returncode == status, (args, result.returncode, result.stdout, result.stderr)
return result.stdout
def compiler():
for name in ("cc", "gcc", "clang"):
if shutil.which(name):
return name
return None
def run_gdb(cwd, session):
"""gdb reads a command file from disk, since a pipe is not seekable"""
script = cwd / "session.gdb"
script.write_text(session + "\n")
result = subprocess.run(["gdb", "--batch", "--nx", "-x", str(script)],
capture_output=True, text=True, cwd=str(cwd))
return result.stdout + result.stderr
def build_id(path, note_only=False):
"""the build-id a stripped artifact keeps, read back with the manager under test.
a separate debug file keeps the note and loses the segments a loadable artifact
needs, so it is read with the note-only reader."""
args = ("elf", path) + (("--build-id",) if note_only else ())
result = subprocess.run([binary, *args], capture_output=True, text=True)
assert result.returncode == 0, (args, result.returncode, result.stdout, result.stderr)
for line in result.stdout.splitlines():
if line.startswith("build-id "):
return line.split()[1]
return None
def main():
for tool in ("objcopy", "gdb"):
if not shutil.which(tool):
print(f"{tool} required for the debug split fixture", file=sys.stderr)
return 6
cc = compiler()
if not cc:
print("a host compiler required for the debug split fixture", file=sys.stderr)
return 6
with tempfile.TemporaryDirectory() as scratch:
root = pathlib.Path(scratch)
sources = root / "sources"
sources.mkdir()
# a program with a real frame above main, so a stack trace has a source
# location to disagree about
program = sources / "program.c"
program.write_text(
"#include <stdio.h>\n"
"static int inner(int value) { return value * 2; }\n"
"static int middle(int value) { return inner(value) + 1; }\n"
"int main(void) { printf(\"%d\\n\", middle(20)); return 0; }\n")
program.chmod(0o644)
# a debugger looks for a separate debug file beside the artifact and then in
# its search path, so the artifact, the debug file and the sources each live
# in their own directory and a case that must find no debug file is a real
# absence rather than an accident of layout
artifacts = root / "artifacts"
artifacts.mkdir()
binary_path = artifacts / "debugged"
result = subprocess.run([cc, "-g", "-O0", "-no-pie", "-Wl,--build-id",
str(program), "-o", str(binary_path)],
capture_output=True, text=True)
assert result.returncode == 0, result.stderr
identity = build_id(binary_path)
assert identity, "the fixture program states no build-id"
# the runtime artifact is stripped and given a debuglink; the debug file keeps
# the sections and the note, which is what ties the two together
debug_file = artifacts / "debugged.debug"
result = subprocess.run(["objcopy", "--only-keep-debug", str(binary_path),
str(debug_file)], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert build_id(debug_file, note_only=True) == identity, \
"the debug file lost the build-id"
stripped = artifacts / "debugged.runtime"
shutil.copyfile(binary_path, stripped)
stripped.chmod(0o755)
result = subprocess.run(["objcopy", "--strip-debug",
"--add-gnu-debuglink=" + str(debug_file), str(stripped)],
capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert build_id(stripped) == identity, "the stripped artifact lost the build-id"
assert stripped.stat().st_size < binary_path.stat().st_size, "nothing was stripped"
sections = subprocess.run(["readelf", "-S", "--wide", str(stripped)],
capture_output=True, text=True).stdout
assert ".debug_info" not in sections, sections
# the section also carries a CRC, so the dump is bytes and the name is searched
# in the printable part of it
debuglink = subprocess.run(["readelf", "--string-dump=.gnu_debuglink", str(stripped)],
capture_output=True).stdout
assert debug_file.name.encode() in debuglink, debuglink
# a stack trace from the stripped artifact resolves to the recorded line
# through the debug file, and it matches the file the build left behind
session = "\n".join([
"set pagination off",
"set confirm off",
f"file {stripped}",
f"directory {sources}",
"break inner",
"run",
"bt",
"info line *$pc",
"quit",
])
trace = run_gdb(artifacts, session)
assert "Breakpoint 1 at" in trace, trace
assert "inner" in trace, trace
assert "middle" in trace, trace
assert "main" in trace, trace
# the recorded line is the one the source states, not a guess
expected = next(number for number, line in
enumerate(program.read_text().splitlines(), 1)
if line.startswith("static int inner"))
assert f"program.c:{expected}" in trace, (expected, trace)
assert "No such file or directory" not in trace, trace
# the same artifact without its debug file has no source line, which is what
# makes the debug output load-bearing rather than a convenience copy
alone = root / "alone"
alone.mkdir()
unpaired = alone / stripped.name
shutil.copyfile(stripped, unpaired)
unpaired.chmod(0o755)
session = "\n".join([
"set pagination off",
"set confirm off",
f"file {unpaired}",
f"directory {sources}",
"break inner",
"run",
"info line *$pc",
"quit",
])
bare_trace = run_gdb(alone, session)
assert f"program.c:{expected}" not in bare_trace, bare_trace
# the symbol is still there, so what is missing is the line table and nothing
# else, which is what a separate debug file carries
assert "inner" in bare_trace, bare_trace
assert "No line number information available" in bare_trace, bare_trace
print("debug split fixtures passed")
return 0
if __name__ == "__main__":
sys.exit(main())