"""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 from .coff_compare import ( CoffError, load, relocation_infos_equal, section_info, section_infos_equal, ) 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.") 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): """Measure one COFF function and attach llvm-objdump disassembly evidence.""" object_path = Path(object_path) try: measurement = section_info(load(object_path), function_name) except (CoffError, OSError) as error: raise ClassifierError(str(error)) from error objdump = objdump or shutil.which("llvm-objdump") or shutil.which("llvm-objdump.exe") if not objdump: raise ClassifierError("llvm-objdump was not found; pass --objdump") measurement["instructions"] = _run_objdump(objdump, object_path, 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("--output", type=Path) args = parser.parse_args(argv) try: target = evidence_from_object( args.target, args.function, objdump=args.objdump) candidate = evidence_from_object( args.candidate, args.function, objdump=args.objdump) 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())