Files
Cesarus85--ringfall-mjolnir-vr/tools/codegen_blocker_classifier.py
T

608 lines
21 KiB
Python

"""Classify strict COFF codegen differences without granting match credit.
The classifier is deliberately fail-closed. It maps a small set of measured
MSVC 7.0 code-generation signatures to the production recipes documented by
the XDK 3911 codegen atlas. Anything that does not satisfy a complete rule is
``UNKNOWN``. A result is diagnostic evidence only: this tool never changes a
matching ledger or decides that a function is exact.
"""
import argparse
import itertools
import json
import re
import shutil
import subprocess
from pathlib import Path
try:
from .coff_compare import (
CoffError,
load,
relocation_infos_equal,
section_info,
section_infos_equal,
)
from . import coff_compare as _coff_compare
except ImportError:
# Script invocation: python tools/codegen_blocker_classifier.py
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from coff_compare import (
CoffError,
load,
relocation_infos_equal,
section_info,
section_infos_equal,
)
import coff_compare as _coff_compare
VERSION = 1
GENERAL_REGISTERS = ("eax", "ebx", "ecx", "edx", "esi", "edi")
REGISTER_ALIASES = {
"eax": ("eax", "ax", "al", "ah"),
"ebx": ("ebx", "bx", "bl", "bh"),
"ecx": ("ecx", "cx", "cl", "ch"),
"edx": ("edx", "dx", "dl", "dh"),
"esi": ("esi", "si"),
"edi": ("edi", "di"),
"ebp": ("ebp", "bp"),
"esp": ("esp", "sp"),
}
ALIAS_TO_REGISTER = {
alias: register
for register, aliases in REGISTER_ALIASES.items()
for alias in aliases
}
INSTRUCTION_RE = re.compile(
r"^\s*([0-9a-fA-F]+):\s+((?:[0-9a-fA-F]{2}\s+)+)"
r"([A-Za-z][A-Za-z0-9_.]*)\s*(.*?)\s*$")
REGISTER_RE = re.compile(
r"%(eax|ax|al|ah|ebx|bx|bl|bh|ecx|cx|cl|ch|edx|dx|dl|dh|"
r"esi|si|edi|di|ebp|bp|esp|sp)\b")
class ClassifierError(RuntimeError):
pass
def parse_disassembly(text):
"""Parse llvm-objdump AT&T instruction lines into stable evidence."""
instructions = []
for line in text.splitlines():
match = INSTRUCTION_RE.match(line)
if not match:
continue
instructions.append({
"address": int(match.group(1), 16),
"bytes": "".join(match.group(2).split()).lower(),
"mnemonic": match.group(3).lower(),
"operands": _normalize_operands(match.group(4)),
})
return instructions
def _normalize_operands(operands):
return re.sub(r"\s+", " ", operands.strip().lower())
def _instruction_key(instruction):
return instruction["mnemonic"], _normalize_operands(instruction["operands"])
def _split_operands(operands):
"""Split AT&T operands without treating SIB commas as separators."""
parts = []
start = 0
depth = 0
for index, character in enumerate(operands):
if character == "(":
depth += 1
elif character == ")":
depth = max(0, depth - 1)
elif character == "," and depth == 0:
parts.append(operands[start:index].strip())
start = index + 1
parts.append(operands[start:].strip())
return parts
def _relocation_identities_equal(left, right):
"""Compare ordered relocation semantics while allowing address drift."""
if len(left) != len(right):
return False
left_without_addresses = [dict(relocation, address=0) for relocation in left]
right_without_addresses = [dict(relocation, address=0) for relocation in right]
return relocation_infos_equal(left_without_addresses, right_without_addresses)
def _metadata(target, candidate):
target_relocations = target.get("relocations", [])
candidate_relocations = candidate.get("relocations", [])
return {
"size_equal": target.get("size") == candidate.get("size"),
"relocation_count_equal": (
target.get("relocation_count") == candidate.get("relocation_count")),
"relocation_identities_equal": _relocation_identities_equal(
target_relocations, candidate_relocations),
"relocations_exact": relocation_infos_equal(
target_relocations, candidate_relocations),
}
def _result(kind, blocker_class, recipe, confidence, evidence, metadata,
stop_rule, preconditions=None):
return {
"version": VERSION,
"kind": kind,
"class": blocker_class,
"recipe": recipe,
"confidence": confidence,
"evidence": evidence,
"strict_metadata": metadata,
"preconditions": preconditions or [],
"stop_rule": stop_rule,
"grants_match_credit": False,
}
def classify(target, candidate):
"""Return one conservative codegen classification for two evidence dicts."""
metadata = _metadata(target, candidate)
target_instructions = target.get("instructions", [])
candidate_instructions = candidate.get("instructions", [])
if not target_instructions or not candidate_instructions:
return _unknown(metadata, "both disassemblies are required")
if target.get("normalized_sha256") == candidate.get("normalized_sha256") \
and metadata["size_equal"] and metadata["relocations_exact"]:
return _result(
"REFUSE", "no-codegen-blocker", None, "PROVEN",
["strict measurements are already equal"], metadata,
"Use the ordinary strict comparator; this classifier does not grant credit.")
classification = _classify_return_shape(
target_instructions, candidate_instructions, metadata)
if classification:
return classification
classification = _classify_private_abi(
target_instructions, candidate_instructions, metadata)
if classification:
return classification
classification = _classify_alias_shape(
target_instructions, candidate_instructions, metadata)
if classification:
return classification
classification = _classify_x87_liveness(
target_instructions, candidate_instructions, metadata)
if classification:
return classification
classification = _classify_register_permutation(
target_instructions, candidate_instructions, metadata)
if classification:
return classification
classification = _classify_scheduling_tie(
target_instructions, candidate_instructions, metadata)
if classification:
return classification
return _unknown(metadata, "no proven recipe or strict STOP signature matched")
def _unknown(metadata, reason):
return _result(
"UNKNOWN", "unclassified", None, "NONE", [reason], metadata,
"Gather stronger disassembly/source evidence; do not infer a recipe or park class.")
def _ret_count(instructions):
return sum(instruction["mnemonic"].startswith("ret") for instruction in instructions)
def _classify_return_shape(target, candidate, metadata):
target_count = _ret_count(target)
candidate_count = _ret_count(candidate)
if target_count == candidate_count or not metadata["relocation_identities_equal"]:
return None
return _result(
"RECIPE", "control-flow-return-shape", "R5", "PROVEN", [
f"target ret count={target_count}; candidate ret count={candidate_count}",
"ordered relocation identities agree",
], metadata,
"Stop after the return count matches; any residual needs a fresh classification.", [
"Preserve the target's early-return versus single-exit structure.",
])
def _has_frame(instructions):
keys = [_instruction_key(instruction) for instruction in instructions[:8]]
push_ebp = any(mnemonic.startswith("push") and operands == "%ebp"
for mnemonic, operands in keys)
mov_frame = any(mnemonic.startswith("mov") and operands == "%esp, %ebp"
for mnemonic, operands in keys)
sub_stack = any(mnemonic.startswith("sub") and operands.endswith(", %esp")
for mnemonic, operands in keys)
return push_ebp and mov_frame and sub_stack
def _classify_private_abi(target, candidate, metadata):
if _has_frame(target) or not _has_frame(candidate):
return None
target_head = " ".join(instruction["operands"] for instruction in target[:12])
candidate_head = " ".join(instruction["operands"] for instruction in candidate[:12])
if not ("%ecx" in target_head or "%edx" in target_head):
return None
if not re.search(r"0x(?:8|c)\(%ebp\)", candidate_head):
return None
if not metadata["relocation_identities_equal"]:
return None
return _result(
"RECIPE", "private-abi-frame-convention", "R1", "STRONG", [
"target is frameless and consumes ECX/EDX near entry",
"candidate builds an EBP frame and reads stack arguments",
"ordered relocation identities agree",
], metadata,
"Stop if the helper still requires a stack frame after shortening live ranges.", [
"Confirm the source helper is static.",
"Confirm its address is never taken and it has a same-TU caller.",
])
def _has_sib_scale_eight(instructions):
for instruction in instructions:
if not instruction["mnemonic"].startswith("mov"):
continue
operands = _split_operands(instruction["operands"])
if operands and re.search(r"\([^)]*,[^)]*,8\)", operands[-1]):
return True
return False
def _has_scaled_temporary(instructions):
return any(
(instruction["mnemonic"].startswith("lea")
and re.search(r"\([^)]*,[^)]*,8\)", instruction["operands"]))
or (instruction["mnemonic"].startswith("shl")
and re.search(r"\$(?:0x)?3,", instruction["operands"]))
for instruction in instructions)
def _classify_alias_shape(target, candidate, metadata):
if not _has_sib_scale_eight(target) or not _has_scaled_temporary(candidate):
return None
return _result(
"RECIPE", "alias-global-ownership", "R3", "PROVEN", [
"target contains direct scale-8 SIB stores",
"candidate materializes a scale-8 address with LEA/SHL",
], metadata,
"Stop and classify as ownership-blocked if target relocations use one aggregate anchor.", [
"Audit target COFF ownership before splitting an aggregate global.",
"After any source change, require strict relocation semantics as well as code bytes.",
])
def _fst_kind(mnemonic):
if mnemonic.startswith("fstp"):
return "fstp", mnemonic[4:]
if mnemonic.startswith("fst"):
return "fst", mnemonic[3:]
return None
def _classify_x87_liveness(target, candidate, metadata):
if len(target) != len(candidate) or not metadata["relocation_identities_equal"]:
return None
differences = []
for index, (target_instruction, candidate_instruction) in enumerate(zip(target, candidate)):
if _instruction_key(target_instruction) == _instruction_key(candidate_instruction):
continue
target_kind = _fst_kind(target_instruction["mnemonic"])
candidate_kind = _fst_kind(candidate_instruction["mnemonic"])
if (not target_kind or not candidate_kind
or target_kind[0] == candidate_kind[0]
or target_kind[1] != candidate_kind[1]
or target_instruction["operands"] != candidate_instruction["operands"]):
return None
differences.append((index, target_kind[0], candidate_kind[0]))
if not differences:
return None
return _result(
"RECIPE", "x87-value-liveness", "R4", "PROVEN", [
"all instruction differences are FST/FSTP opcode pairs",
"ordered relocation identities agree",
*[f"instruction {index}: target {left}, candidate {right}"
for index, left, right in differences],
], metadata,
"Stop after x87 stack depth and subsequent instruction addresses realign.", [
"Target FST requires keeping the computed value live after the store.",
"Target FSTP requires letting the computed value die at the store.",
])
def _replace_registers(operands, mapping):
def replace(match):
alias = match.group(1)
register = ALIAS_TO_REGISTER[alias]
mapped = mapping.get(register, register)
aliases = REGISTER_ALIASES[mapped]
original_aliases = REGISTER_ALIASES[register]
try:
index = original_aliases.index(alias)
except ValueError:
return match.group(0)
if index >= len(aliases):
return match.group(0)
return "%" + aliases[index]
return REGISTER_RE.sub(replace, operands)
def _classify_register_permutation(target, candidate, metadata):
if (not metadata["size_equal"] or not metadata["relocations_exact"]
or len(target) != len(candidate)):
return None
if [item["mnemonic"] for item in target] != [item["mnemonic"] for item in candidate]:
return None
target_keys = [_instruction_key(instruction) for instruction in target]
candidate_keys = [_instruction_key(instruction) for instruction in candidate]
differing = [
index for index, pair in enumerate(zip(target_keys, candidate_keys))
if pair[0] != pair[1]
]
if not differing:
return None
# A register live range can begin and end inside a function. Permit one
# contiguous local window, but refuse disconnected differences because a
# single causal permutation is then unproven.
if differing != list(range(differing[0], differing[-1] + 1)):
return None
window = range(differing[0], differing[-1] + 1)
for permutation in itertools.permutations(GENERAL_REGISTERS):
mapping = dict(zip(GENERAL_REGISTERS, permutation))
if all(mapping[register] == register for register in GENERAL_REGISTERS):
continue
if all(
_replace_registers(candidate_instruction["operands"], mapping)
== target_instruction["operands"]
for target_instruction, candidate_instruction in
((target[index], candidate[index]) for index in window)
):
changed = [
f"{source}->{destination}"
for source, destination in mapping.items()
if source != destination
]
return _result(
"STOP", "pure-register-permutation", None, "PROVEN", [
"equal padded size and exact relocation semantics",
f"one contiguous instruction window {differing[0]}..{differing[-1]} "
"becomes identical under one bijective GPR mapping",
"mapping: " + ", ".join(changed),
], metadata,
"No measured legal-C control moved a pure register permutation; keep parked.")
return None
def _registers(instruction):
return {
ALIAS_TO_REGISTER[match.group(1)]
for match in REGISTER_RE.finditer(instruction["operands"])
}
def _memory_role(instruction):
operands = instruction["operands"]
if "(" not in operands:
return "none"
mnemonic = instruction["mnemonic"]
if mnemonic.startswith("lea"):
return "none"
parts = _split_operands(operands)
if mnemonic.startswith("mov") and parts and "(" in parts[-1]:
return "write"
if mnemonic.startswith("fst"):
return "write"
return "read"
def _flag_role(mnemonic):
if mnemonic.startswith(("j", "set", "cmov", "adc", "sbb")):
return "read"
if mnemonic.startswith((
"add", "sub", "cmp", "test", "and", "or", "xor", "inc", "dec",
"shl", "shr", "sar", "sal", "neg", "imul")):
return "write"
return "none"
def _independent(left, right):
if left["mnemonic"].startswith(("j", "call", "ret", "loop", "int", "push", "pop")):
return False
if right["mnemonic"].startswith(("j", "call", "ret", "loop", "int", "push", "pop")):
return False
if left["mnemonic"].startswith("f") and right["mnemonic"].startswith("f"):
return False
if _registers(left) & _registers(right):
return False
left_memory = _memory_role(left)
right_memory = _memory_role(right)
if "write" in (left_memory, right_memory) and "none" not in (left_memory, right_memory):
return False
left_flags = _flag_role(left["mnemonic"])
right_flags = _flag_role(right["mnemonic"])
if left_flags == "read" or right_flags == "read":
return False
if left_flags == right_flags == "write":
return False
return True
def _classify_scheduling_tie(target, candidate, metadata):
if (not metadata["size_equal"] or not metadata["relocations_exact"]
or len(target) != len(candidate)):
return None
target_keys = [_instruction_key(instruction) for instruction in target]
candidate_keys = [_instruction_key(instruction) for instruction in candidate]
differing = [
index for index, pair in enumerate(zip(target_keys, candidate_keys))
if pair[0] != pair[1]
]
if len(differing) != 2 or differing[1] != differing[0] + 1:
return None
first, second = differing
if not (target_keys[first] == candidate_keys[second]
and target_keys[second] == candidate_keys[first]):
return None
if not _independent(target[first], target[second]):
return None
return _result(
"STOP", "independent-instruction-scheduling", None, "PROVEN", [
"equal padded size and exact relocation semantics",
f"instructions {first} and {second} are one adjacent transposition",
"the pair has disjoint registers, no control transfer, and no memory/flag dependency",
], metadata,
"No measured legal-C control moved an independent scheduling tie; keep parked.")
_DECIMAL_IMMEDIATE_RE = re.compile(r"\$(-?)(\d+)\b")
_DECIMAL_DISPLACEMENT_RE = re.compile(r"(^|[\s,])(-?)(\d+)\(")
_PAREN_SPACE_RE = re.compile(r"\(([^)]*)\)")
def _llvm_style_operands(operands):
"""Rewrite capstone AT&T operands into llvm-objdump conventions.
The measured rule signatures were written against llvm-objdump output:
hex immediates and displacements (``$0x10``, ``0x8(%ebp)``) and no spaces
inside SIB expressions (``(%eax,%ebx,8)``). capstone prints decimal and
spaced forms. Only the spelling is changed; SIB scales stay decimal in
both producers.
"""
operands = _PAREN_SPACE_RE.sub(
lambda match: "(" + match.group(1).replace(", ", ",") + ")", operands)
operands = _DECIMAL_IMMEDIATE_RE.sub(
lambda match: "$" + match.group(1) + hex(int(match.group(2))), operands)
operands = _DECIMAL_DISPLACEMENT_RE.sub(
lambda match: match.group(1) + match.group(2)
+ hex(int(match.group(3))) + "(", operands)
return operands
def _capstone_instructions(obj, function_name):
"""Disassemble one function COMDAT with capstone, relocation slots masked.
Masking the four addend bytes at every relocation address before decoding
keeps csplit targets (which store linked addresses) and rebuilt candidates
(which store link-time addends) from producing spurious operand
differences. Both sides are masked identically, so equality remains
meaningful; the relocation identities themselves are still compared by the
strict metadata.
"""
try:
import capstone
except ImportError as error:
raise ClassifierError(
"neither llvm-objdump nor capstone is available") from error
fn = None
for item in obj["symbols"]:
if item["name"] == function_name and item["section"] > 0:
fn = item
break
if fn is None:
raise ClassifierError(
f"section-relative symbol {function_name!r} not found")
section = obj["sections"][fn["section"] - 1]
raw = bytearray(_coff_compare._section_bytes(obj, section))[:section["size"]]
for index in range(section["reloc_count"]):
offset = section["reloc"] + index * 10
address = int.from_bytes(obj["data"][offset:offset + 4], "little")
if address + 4 <= len(raw):
raw[address:address + 4] = b"\0\0\0\0"
engine = capstone.Cs(capstone.CS_ARCH_X86, capstone.CS_MODE_32)
engine.syntax = capstone.CS_OPT_SYNTAX_ATT
engine.skipdata = True
instructions = []
for decoded in engine.disasm(bytes(raw), 0):
instructions.append({
"address": decoded.address,
"bytes": decoded.bytes.hex(),
"mnemonic": decoded.mnemonic.lower(),
"operands": _normalize_operands(
_llvm_style_operands(decoded.op_str)),
})
if not instructions:
raise ClassifierError(f"no instructions found for {function_name!r}")
return instructions
def _run_objdump(objdump, object_path, function_name):
command = [
str(objdump), "-d", "-r", "--symbolize-operands",
f"--disassemble-symbols={function_name}", str(object_path),
]
completed = subprocess.run(
command, capture_output=True, text=True, check=False)
if completed.returncode:
raise ClassifierError(completed.stderr.strip() or "llvm-objdump failed")
instructions = parse_disassembly(completed.stdout)
if not instructions:
raise ClassifierError(f"no instructions found for {function_name!r}")
return instructions
def evidence_from_object(object_path, function_name, objdump=None, backend="auto"):
"""Measure one COFF function and attach disassembly evidence.
``backend`` selects the disassembler: ``"objdump"`` requires llvm-objdump,
``"capstone"`` uses the in-process capstone port, and ``"auto"`` prefers
llvm-objdump when present and falls back to capstone. Both producers of a
comparison must use the same backend; mixing them is not meaningful.
"""
object_path = Path(object_path)
try:
obj = load(object_path)
measurement = section_info(obj, function_name)
except (CoffError, OSError) as error:
raise ClassifierError(str(error)) from error
if backend != "capstone":
objdump = objdump or shutil.which("llvm-objdump") or shutil.which("llvm-objdump.exe")
if backend == "objdump" and not objdump:
raise ClassifierError("llvm-objdump was not found; pass --objdump")
if backend != "capstone" and objdump:
measurement["instructions"] = _run_objdump(objdump, object_path, function_name)
else:
measurement["instructions"] = _capstone_instructions(obj, function_name)
return measurement
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("target")
parser.add_argument("candidate")
parser.add_argument("function")
parser.add_argument("--objdump")
parser.add_argument(
"--backend", choices=("auto", "objdump", "capstone"), default="auto")
parser.add_argument("--output", type=Path)
args = parser.parse_args(argv)
try:
target = evidence_from_object(
args.target, args.function, objdump=args.objdump,
backend=args.backend)
candidate = evidence_from_object(
args.candidate, args.function, objdump=args.objdump,
backend=args.backend)
result = classify(target, candidate)
except ClassifierError as error:
parser.error(str(error))
serialized = json.dumps(result, indent=2) + "\n"
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(serialized, encoding="utf-8")
print(serialized, end="")
return 0
if __name__ == "__main__":
raise SystemExit(main())