diff --git a/declib/api/decompiler_client.py b/declib/api/decompiler_client.py index 0ccd9bf..a9a2aa9 100644 --- a/declib/api/decompiler_client.py +++ b/declib/api/decompiler_client.py @@ -580,6 +580,18 @@ def get_comment(self, addr: int) -> Optional[Comment]: def delete_comment(self, addr: int) -> bool: """Delete any comment at ``addr`` (lifted).""" return self._send_request({"type": "method_call", "method_name": "delete_comment", "args": [addr]}) + + def get_global_var(self, addr: int) -> Optional[GlobalVariable]: + """Return the GlobalVariable at ``addr`` (lifted), or None. Direct read, no cache.""" + return self._send_request({"type": "method_call", "method_name": "get_global_var", "args": [addr]}) + + def get_function_signature(self, func_addr: int) -> Optional[str]: + """Return the C prototype string for the function at ``func_addr`` (lifted).""" + return self._send_request({"type": "method_call", "method_name": "get_function_signature", "args": [func_addr]}) + + def set_function_signature(self, func_addr: int, prototype: str) -> bool: + """Apply a full C prototype (return type + argument types/names).""" + return self._send_request({"type": "method_call", "method_name": "set_function_signature", "args": [func_addr, prototype]}) def get_callgraph(self, only_names=False): """Get the call graph""" diff --git a/declib/api/decompiler_interface.py b/declib/api/decompiler_interface.py index 4825ef8..0b9eb1f 100644 --- a/declib/api/decompiler_interface.py +++ b/declib/api/decompiler_interface.py @@ -543,6 +543,50 @@ def delete_comment(self, addr) -> bool: lowered = self.art_lifter.lower_addr(addr) return self._del_comment(lowered) + def get_global_var(self, addr) -> Optional[GlobalVariable]: + """Return the lifted GlobalVariable at ``addr`` (lifted), or None. + + Direct backend read (bypasses the light-artifact cache), so a global + renamed/retyped moments earlier is immediately visible. + """ + lowered = self.art_lifter.lower_addr(addr) + gvar = self._get_global_var(lowered) + return self.art_lifter.lift(gvar) if gvar is not None else None + + def get_function_signature(self, func_addr) -> Optional[str]: + """Return the C prototype string for the function at ``func_addr`` (lifted).""" + from declib.api.prototype import format_prototype + + func = self.functions[func_addr] + if func is None or func.header is None: + return None + return format_prototype(func.header) + + def set_function_signature(self, func_addr, prototype: str) -> bool: + """Apply a full C prototype (return type + argument types/names). + + The generic implementation parses the prototype and overwrites the + function's existing parameters positionally, then re-applies the + header through the normal set path. It retypes/renames the parameters + the backend already recognizes; it does not add or remove parameters. + Backends with an atomic prototype-set API (IDA's ``SetType``) override + this for fidelity. + """ + from declib.api.prototype import parse_prototype + + ret_type, arg_specs = parse_prototype(prototype) + func = self.functions[func_addr] + if func is None or func.header is None: + return False + if ret_type: + func.header.type = ret_type + for (_off, arg), (atype, aname) in zip(sorted(func.header.args.items()), arg_specs): + if atype and atype != "...": + arg.type = atype + if aname: + arg.name = aname + return bool(self.set_artifact(func)) + # ------------------------------------------------------------------ # Persistence # ------------------------------------------------------------------ diff --git a/declib/api/prototype.py b/declib/api/prototype.py new file mode 100644 index 0000000..148f403 --- /dev/null +++ b/declib/api/prototype.py @@ -0,0 +1,91 @@ +"""Parsing and formatting of C function prototypes. + +Shared by the CLI (``signature`` command) and the DecompilerInterface's +``set_function_signature`` so both agree on how a prototype string maps to a +return type + argument (type, name) pairs. +""" +import re +from typing import List, Optional, Tuple + + +def split_c_args(body: str) -> List[str]: + """Split a C argument list on top-level commas (respecting () <> [] nesting).""" + parts, depth, cur = [], 0, "" + for ch in body: + if ch in "(<[": + depth += 1 + elif ch in ")>]": + depth -= 1 + if ch == "," and depth == 0: + parts.append(cur) + cur = "" + else: + cur += ch + if cur.strip(): + parts.append(cur) + return parts + + +def norm_c_type(t: str) -> str: + """Normalize whitespace and pointer spacing in a C type string.""" + return re.sub(r"\s+", " ", t.replace("*", " * ")).replace(" * ", " *").strip() + + +def parse_prototype(proto: str) -> Tuple[str, List[Tuple[str, Optional[str]]]]: + """Parse ``ret name(argtype argname, ...)`` -> (return_type, [(type, name), ...]). + + Handles pointer return/argument types, ``void`` argument lists, and varargs + (``...``). Argument names are optional. Raises ``ValueError`` on malformed + input. + """ + proto = proto.strip().rstrip(";").strip() + open_i = proto.find("(") + if open_i == -1: + raise ValueError("no argument list '(...)' found") + close_i = proto.rfind(")") + head = proto[:open_i].strip() + body = proto[open_i + 1:close_i].strip() + + m = re.search(r"[A-Za-z_]\w*\s*$", head) + if not m: + raise ValueError(f"cannot find a function name in {head!r}") + ret_type = head[:m.start()].strip() + if not ret_type: + raise ValueError("missing return type") + + args: List[Tuple[str, Optional[str]]] = [] + if body and body != "void": + for raw in split_c_args(body): + raw = raw.strip() + if raw == "...": + args.append(("...", None)) + continue + am = re.search(r"[A-Za-z_]\w*\s*$", raw) + if am and not raw.endswith("*"): + aname = am.group().strip() + atype = raw[:am.start()].strip() + if not atype: # bare type, no name (e.g. "int") + atype, aname = raw, None + else: + atype, aname = raw, None + args.append((norm_c_type(atype), aname)) + return norm_c_type(ret_type), args + + +def format_prototype(header) -> str: + """Render a declib FunctionHeader as a C prototype string.""" + ret = header.type or "void" + name = header.name or "sub" + parts = [] + for _off, arg in sorted(header.args.items()): + atype = arg.type or "int" + aname = arg.name or "" + if not aname: + parts.append(atype) + else: + # Glue a pointer type to the name: "char *" + "b" -> "char *b". + sep = "" if atype.endswith("*") else " " + parts.append(f"{atype}{sep}{aname}") + arg_str = ", ".join(parts) if parts else "void" + ret_sep = "" if ret.endswith("*") else " " + return f"{ret}{ret_sep}{name}({arg_str})" diff --git a/declib/cli/decompiler_cli.py b/declib/cli/decompiler_cli.py index f032268..6981280 100644 --- a/declib/cli/decompiler_cli.py +++ b/declib/cli/decompiler_cli.py @@ -17,6 +17,8 @@ - xref_to data + code references to a target - xref_from things a function calls (callees) - comment get/set/append/delete/list comments (annotations) +- global list/get/rename/retype global variables +- signature get/set a function's full signature (prototype) - rename rename a function or local variable - create-type define a new struct/enum/typedef from a C string - retype change the type of a function's variable or argument @@ -871,6 +873,124 @@ def cmd_comment(args) -> int: return EXIT_OK +# --------------------------------------------------------------------------- +# global (list / get / rename / retype) +# --------------------------------------------------------------------------- + +def cmd_global(args) -> int: + """CRUD-lite for global variables, keyed by address.""" + from declib.artifacts import GlobalVariable + + action = args.global_action + with _with_client(args) as client: + if action == "list": + pattern = re.compile(args.filter) if args.filter else None + entries: List[Dict] = [] + for addr, gv in sorted(client.global_vars.items(), key=lambda kv: kv[0]): + name = getattr(gv, "name", None) or "" + if pattern and not pattern.search(name): + continue + entries.append({ + "addr": addr, "name": name, + "type": getattr(gv, "type", None), "size": getattr(gv, "size", None), + }) + if args.json: + _emit_list(args, entries) + else: + if not entries: + print("No global variables.") + return EXIT_OK + print(f"{'ADDR':<12} {'SIZE':<6} {'TYPE':<20} NAME") + for e in entries: + print(f"{_format_addr_hex(e['addr']):<12} {str(e['size'] or ''):<6} " + f"{str(e['type'] or ''):<20} {e['name']}") + return EXIT_OK + + addr_value, _ = _parse_target(args.addr) + if addr_value is None: + raise SystemExit(f"Invalid address {args.addr!r}; expected hex (0x..) or decimal.") + lifted = _to_lifted_addr(client, addr_value) + + if action == "get": + gv = client.get_global_var(lifted) + if gv is None: + raise SystemExit(f"No global variable at {_format_addr_hex(lifted)}") + _emit(args, {"addr": lifted, "name": gv.name, "type": gv.type, "size": gv.size}) + return EXIT_OK + + # rename / retype: build a partial GlobalVariable and push it through. + if action == "rename": + gvar = GlobalVariable(addr=lifted, name=args.new_name) + else: # retype + gvar = GlobalVariable(addr=lifted, type_=args.new_type) + ok = bool(client.set_artifact(gvar)) + if not ok: + raise SystemExit( + f"Backend rejected global {action} at {_format_addr_hex(lifted)} " + f"(the backend may not support this — e.g. Ghidra global retype is best-effort)." + ) + refreshed = client.get_global_var(lifted) + _emit(args, { + "addr": lifted, "action": action, + "name": getattr(refreshed, "name", None), + "type": getattr(refreshed, "type", None), + "success": ok, + }) + return EXIT_OK + + +# --------------------------------------------------------------------------- +# signature (get / set) — full function prototype +# --------------------------------------------------------------------------- + +def cmd_signature(args) -> int: + """Get or set a function's full signature (return type + argument types/names).""" + from declib.api.prototype import parse_prototype, format_prototype + + action = args.signature_action + with _with_client(args) as client: + func_addr = _resolve_function_addr(client, args.function) + if func_addr is None: + raise SystemExit(f"Function not found: {args.function!r}") + known = _known_function_addrs(client) + if known and func_addr not in known: + raise SystemExit(f"Function not found: {args.function!r}") + + if action == "get": + func = client.functions[func_addr] + if func is None or func.header is None: + raise SystemExit(f"No signature available for {args.function!r}") + proto = format_prototype(func.header) + _emit(args, { + "function_addr": func_addr, + "signature": proto, + "return_type": func.header.type, + "args": [{"name": a.name, "type": a.type} + for _o, a in sorted(func.header.args.items())], + }, text_field="signature") + return EXIT_OK + + # set: validate the prototype client-side for a clean error, then let + # the backend apply it (IDA uses SetType; others use the header path). + try: + parse_prototype(args.prototype) + except ValueError as exc: + raise SystemExit(f"Could not parse prototype: {exc}") + + ok = bool(client.set_function_signature(func_addr, args.prototype)) + if not ok: + raise SystemExit(f"Backend rejected the signature for {args.function!r}.") + + refreshed = client.functions[func_addr] + _emit(args, { + "function_addr": func_addr, + "applied_signature": format_prototype(refreshed.header) + if refreshed is not None and refreshed.header else None, + "success": ok, + }) + return EXIT_OK + + # --------------------------------------------------------------------------- # create-type / retype # --------------------------------------------------------------------------- @@ -1535,6 +1655,59 @@ def _add_comment_addr_action(name, help_text, with_text=False): _add_output_args(p_clist) p_clist.set_defaults(func=cmd_comment) + # global + p_glob = sub.add_parser( + "global", + help="List/get/rename/retype global variables.", + ) + glob_sub = p_glob.add_subparsers(dest="global_action", required=True) + + p_glist = glob_sub.add_parser("list", help="List global variables.") + p_glist.add_argument("--filter", dest="filter", help="Regex to filter global names.") + _add_server_filter_args(p_glist) + _add_output_args(p_glist) + p_glist.set_defaults(func=cmd_global) + + p_gget = glob_sub.add_parser("get", help="Show a global variable's name/type/size.") + p_gget.add_argument("addr", help="Address (hex 0x.., decimal, lifted or absolute).") + _add_server_filter_args(p_gget) + _add_output_args(p_gget) + p_gget.set_defaults(func=cmd_global) + + p_gren = glob_sub.add_parser("rename", help="Rename the global variable at an address.") + p_gren.add_argument("addr", help="Address of the global variable.") + p_gren.add_argument("new_name", help="New name.") + _add_server_filter_args(p_gren) + _add_output_args(p_gren) + p_gren.set_defaults(func=cmd_global) + + p_gret = glob_sub.add_parser("retype", help="Change the type of the global variable at an address.") + p_gret.add_argument("addr", help="Address of the global variable.") + p_gret.add_argument("new_type", help='New C type, e.g. "int", "char[16]", "void *".') + _add_server_filter_args(p_gret) + _add_output_args(p_gret) + p_gret.set_defaults(func=cmd_global) + + # signature + p_sig = sub.add_parser( + "signature", + help="Get or set a function's full signature (return type + argument types/names).", + ) + sig_sub = p_sig.add_subparsers(dest="signature_action", required=True) + + p_sget = sig_sub.add_parser("get", help="Print a function's C prototype.") + p_sget.add_argument("function", help="Function name or address.") + _add_server_filter_args(p_sget) + _add_output_args(p_sget) + p_sget.set_defaults(func=cmd_signature) + + p_sset = sig_sub.add_parser("set", help="Set a function's full signature from a C prototype.") + p_sset.add_argument("function", help="Function name or address.") + p_sset.add_argument("prototype", help='C prototype, e.g. "int main(int argc, char **argv)".') + _add_server_filter_args(p_sset) + _add_output_args(p_sset) + p_sset.set_defaults(func=cmd_signature) + # create-type p_ct = sub.add_parser( "create-type", diff --git a/declib/decompilers/ghidra/interface.py b/declib/decompilers/ghidra/interface.py index a14a61f..41cca25 100644 --- a/declib/decompilers/ghidra/interface.py +++ b/declib/decompilers/ghidra/interface.py @@ -1054,8 +1054,17 @@ def _set_global_variable(self, gvar: GlobalVariable, **kwargs): type_str = str(sym_data.getDataType().getPathName()) if sym_data is not None else None if gvar.type and gvar.type != type_str: - # TODO: set type - pass + gtype = self.typestr_to_gtype(gvar.type) + if gtype is not None: + try: + from ghidra.program.model.data import DataUtilities + DataUtilities.createData( + self.currentProgram, sym.getAddress(), gtype, -1, + DataUtilities.ClearDataMode.CLEAR_ALL_CONFLICT_DATA, + ) + changes = True + except Exception as e: + self.warning(f"Failed to set global var type {gvar.type!r}: {e}") return changes @@ -1456,12 +1465,19 @@ def __g_global_variables(self): # TODO: this just does not work for bigger than 50k syms from .compat.imports import SymbolType + listing = self.currentProgram.getListing() + # Only real, memory-mapped data symbols are globals. Ghidra also emits + # symbols in synthetic/EXTERNAL address spaces (offset below the image + # base), which would lift to negative addresses and fail to round-trip; + # `memory.contains` filters those out. + memory = self.currentProgram.getMemory() return [ - (int(sym.getAddress().getOffset()), str(sym.getName()), self.currentProgram.getListing().getDataAt(sym.getAddress()), sym) + (int(sym.getAddress().getOffset()), str(sym.getName()), listing.getDataAt(sym.getAddress()), sym) for sym in self.currentProgram.getSymbolTable().getAllSymbols(True) if sym.getSymbolType() == SymbolType.LABEL and - self.currentProgram.getListing().getDataAt(sym.getAddress()) and - not self.currentProgram.getListing().getDataAt(sym.getAddress()).isStructure() + memory.contains(sym.getAddress()) and + listing.getDataAt(sym.getAddress()) and + not listing.getDataAt(sym.getAddress()).isStructure() ] diff --git a/declib/decompilers/ida/compat.py b/declib/decompilers/ida/compat.py index 08ca2f7..633ad6f 100644 --- a/declib/decompilers/ida/compat.py +++ b/declib/decompilers/ida/compat.py @@ -1490,26 +1490,27 @@ def set_ida_struct_member_types(bs_struct: Struct): @execute_write def global_vars(): + """Enumerate global variables as ``{ea: GlobalVariable}``. + + Uses ``idautils.Names()`` (every named location) rather than scanning a + hardcoded pair of segments, so data symbols across ``.data``/``.bss``/ + ``.rodata``/``.got`` all surface. Function starts and code labels are + filtered out — those aren't globals. + """ gvars = {} - known_segs = [".artifacts", ".bss"] - for seg_name in known_segs: - seg = idaapi.get_segm_by_name(seg_name) - if not seg: + for ea, name in idautils.Names(): + if not name: continue - - for seg_ea in range(seg.start_ea, seg.end_ea): - xrefs = idautils.XrefsTo(seg_ea) - try: - next(xrefs) - except StopIteration: - continue - - name = idaapi.get_name(seg_ea) - if not name: - continue - - gvars[seg_ea] = GlobalVariable(seg_ea, name) - + # Skip function entry points; they're functions, not globals. + func = ida_funcs.get_func(ea) + if func is not None and func.start_ea == ea: + continue + # Skip in-code labels; keep data (and undefined) items. + if ida_bytes.is_code(ida_bytes.get_flags(ea)): + continue + gvars[ea] = GlobalVariable( + ea, name, size=idaapi.get_item_size(ea), type_=idc.get_type(ea) + ) return gvars @@ -1975,6 +1976,18 @@ def wait_for_idc_initialization(): idc.auto_wait() +@execute_write +def set_function_prototype(func_addr, prototype): + """Apply a full function prototype atomically via ``idc.SetType``. + + IDA's own C parser handles the whole declaration (return type + argument + types + names) in one shot, which avoids the stale-``cfunc`` clobbering that + happens when return type and arguments are set through separate steps. + """ + res = idc.SetType(func_addr, prototype) + return bool(res) + + @execute_write def save_database(path=None): """Flush the in-memory IDB to disk. diff --git a/declib/decompilers/ida/interface.py b/declib/decompilers/ida/interface.py index e6bad0a..e9339bc 100755 --- a/declib/decompilers/ida/interface.py +++ b/declib/decompilers/ida/interface.py @@ -167,6 +167,11 @@ def save(self, path=None) -> bool: """Write the current IDB to disk (durable rename/type/comment artifacts).""" return compat.save_database(path) + def set_function_signature(self, func_addr, prototype: str) -> bool: + """Apply a full prototype via IDA's SetType (atomic, avoids clobbering).""" + lowered = self.art_lifter.lower_addr(func_addr) + return compat.set_function_prototype(lowered, prototype) + def _init_gui_hooks(self): """ This function can only be called from inside the compat.GenericIDAPlugin and is meant for IDA code which diff --git a/declib/skills/decompiler/SKILL.md b/declib/skills/decompiler/SKILL.md index 792fa94..5c25c1d 100644 --- a/declib/skills/decompiler/SKILL.md +++ b/declib/skills/decompiler/SKILL.md @@ -141,6 +141,10 @@ same binary. | `comment list` | List every comment in the binary. | `--filter REGEX`, same | | `rename func ` | Rename a function. | same + `--json` | | `rename var --function ` | Rename a local variable inside a function. | same | +| `global list` | List global variables (ADDR, SIZE, TYPE, NAME). | `--filter REGEX`, same | +| `global get/rename/retype [...]` | Read, rename, or retype the global at an address. | same | +| `signature get ` | Print a function's full C prototype. | same | +| `signature set ""` | Set return type + argument types/names from a prototype. | same | | `create-type ""` | Define a new `struct`/`enum`/`typedef` from a C string and add it to the type database. | same + `--json` | | `retype ` | Set the type of a function's local variable or argument. | same | | `sync --from-id ` | Copy a function's work (names, return/arg types, stack-var names+types, referenced user types) from one running server into another for the same binary. | dest: `--id`/`--binary`/`--backend`; `--json` | @@ -185,6 +189,26 @@ through the backend, so on IDA the address must be **inside a function** implements comment writes but not reads/enumeration yet — `comment get`/`list` return nothing there. Pair with `save` to make comments durable across reloads. +### `global` and `signature` — globals and full prototypes + +```bash +decompiler global list --filter 'key|flag' # ADDR SIZE TYPE NAME +decompiler global get 0x4008 --json +decompiler global rename 0x4008 g_secret_key +decompiler global retype 0x4008 "char[32]" + +decompiler signature get main # int main(int argc, char **argv) +decompiler signature set main "int main(int argc, char **argv)" +``` + +`signature set` takes a full C prototype and applies the return type plus each +argument's type and name. IDA applies the whole prototype atomically (it can +also change the parameter count); Ghidra/Binary Ninja retype and rename the +parameters they already recognize. `angr` sets argument *names* but not types. +`global retype` is fully supported on IDA/Binary Ninja and best-effort on +Ghidra; `angr` has no global-variable store (its `global` commands return +nothing). + ### Persistence — durable artifacts (`save`, `stop --save`) By default a server holds the binary open in memory; IDA discards edits on diff --git a/docs/decompiler_cli.md b/docs/decompiler_cli.md index 32f89b1..7acfaab 100644 --- a/docs/decompiler_cli.md +++ b/docs/decompiler_cli.md @@ -353,6 +353,48 @@ Ghidra and Binary Ninja are more permissive. `angr` implements comment writes but not reads/enumeration, so `get`/`list` return nothing there. Use `save` (or `stop --save`) to persist comments across a reload. +### `global` + +List, read, rename, or retype global variables. + +```bash +decompiler global list [--filter REGEX] [--id ID] [--json] +decompiler global get [--id ID] [--json] +decompiler global rename [--id ID] [--json] +decompiler global retype [--id ID] [--json] +``` + +```bash +decompiler global list --filter 'key|flag' +# ADDR SIZE TYPE NAME +# 0x4040 8 char * g_key +decompiler global rename 0x4040 g_secret_key +decompiler global retype 0x4040 "char[32]" +``` + +`rename` works everywhere globals are enumerated; `retype` is fully supported +on IDA and Binary Ninja, best-effort on Ghidra. `angr` has no global-variable +store, so its `global` commands return nothing. + +### `signature` + +Get or set a function's full signature (return type + argument types/names). + +```bash +decompiler signature get [--id ID] [--json] +decompiler signature set "" [--id ID] [--json] +``` + +```bash +decompiler signature get main +# int main(int argc, char **argv) +decompiler signature set main "int main(int argc, char **argv, char **envp)" +``` + +IDA applies the whole prototype atomically via `SetType` (it can also change the +parameter count). Ghidra and Binary Ninja retype/rename the parameters they +already recognize. `angr` sets argument names but not types. + ### `list_strings` List strings the decompiler's own string detector has identified in the diff --git a/tests/test_decompiler_cli.py b/tests/test_decompiler_cli.py index 4057da5..5bec97f 100644 --- a/tests/test_decompiler_cli.py +++ b/tests/test_decompiler_cli.py @@ -533,6 +533,79 @@ def test_comment_get_missing_exits_nonzero(self): result = _run_cli("comment", "get", _format_hex(addr), check=False) self.assertNotEqual(result.returncode, 0) + # ------------------------------------------------------------------- + # global variables + # ------------------------------------------------------------------- + + #: Backends that implement global-variable listing/reading. + _reads_globals: bool = True + + def test_global_list(self): + if not self._reads_globals: + self.skipTest(f"{self.backend} does not implement global enumeration") + self._load_fauxware_isolated() + listing = json.loads(_run_cli("global", "list", "--json").stdout) + self.assertTrue(listing, f"{self.backend}: `global list` returned nothing") + for e in listing: + self.assertIn("addr", e) + self.assertIn("name", e) + + def test_global_rename(self): + if not self._reads_globals: + self.skipTest(f"{self.backend} does not implement globals") + self._load_fauxware_isolated() + listing = json.loads(_run_cli("global", "list", "--json").stdout) + # Pick a real, addressable global (positive lifted addr + a name). + candidates = [g for g in listing if g.get("addr", -1) >= 0 and g.get("name")] + if not candidates: + self.skipTest(f"{self.backend}: no renameable globals") + target = candidates[0] + renamed = _run_cli("global", "rename", _format_hex(target["addr"]), + "renamed_global_xyz", "--json", check=False) + if renamed.returncode != 0: + self.skipTest(f"{self.backend}: global rename unsupported: {renamed.stdout + renamed.stderr}") + payload = json.loads(renamed.stdout) + self.assertTrue(payload["success"]) + self.assertEqual(payload["name"], "renamed_global_xyz", + f"{self.backend}: global rename didn't stick") + + # ------------------------------------------------------------------- + # function signatures + # ------------------------------------------------------------------- + + def test_signature_get(self): + self._load_fauxware_isolated() + name = self._resolve_main_name() + result = _run_cli("signature", "get", name, "--json") + payload = json.loads(result.stdout) + self.assertIn("signature", payload) + self.assertIn("(", payload["signature"]) + self.assertIsNotNone(payload.get("return_type")) + + #: Backends that apply return/argument *types* via signature set (angr sets + #: only argument names today). + _sets_signature_types: bool = True + + def test_signature_set(self): + self._load_fauxware_isolated() + name = self._resolve_main_name() + result = _run_cli("signature", "set", name, "long main(int argc, char **argv)", + "--json", check=False) + if result.returncode != 0: + self.skipTest(f"{self.backend}: signature set unsupported: {result.stdout + result.stderr}") + self.assertTrue(json.loads(result.stdout)["success"]) + if not self._sets_signature_types: + self.skipTest(f"{self.backend} sets arg names but not types via signature set") + # Verify the changed return type shows up on a fresh read. + got = json.loads(_run_cli("signature", "get", name, "--json").stdout) + self.assertIn("long", (got.get("return_type") or "").lower(), + f"{self.backend}: signature set return type not reflected: {got}") + + def test_signature_get_missing_exits_nonzero(self): + self._load_fauxware_isolated() + result = _run_cli("signature", "get", "no_such_function_xyz", check=False) + self.assertEqual(result.returncode, 1) + # ------------------------------------------------------------------- # create-type / retype (run against every backend) # ------------------------------------------------------------------- @@ -629,6 +702,10 @@ class TestDecompilerCLIAngr(_CLIBackendTestBase): backend = "angr" # angr implements comment *writes* but not reads/enumeration yet. _reads_comments = False + # angr has no first-class global-variable store. + _reads_globals = False + # angr's signature set applies argument names but not types/return type. + _sets_signature_types = False # angr-specific sanity checks that don't map cleanly to the other # backends live here. @@ -991,6 +1068,43 @@ def test_bad_input_raises(self): # subprocess tests so they run in isolation and are cheap to iterate on. # --------------------------------------------------------------------------- +class TestPrototypeParser(unittest.TestCase): + """Backend-free tests for the C prototype parser/formatter.""" + + def test_parse_basic(self): + from declib.api.prototype import parse_prototype + ret, args = parse_prototype("int main(int argc, char **argv)") + self.assertEqual(ret, "int") + self.assertEqual(args, [("int", "argc"), ("char **", "argv")]) + + def test_parse_pointer_return_and_varargs(self): + from declib.api.prototype import parse_prototype + ret, args = parse_prototype("char *printf(const char *fmt, ...)") + self.assertEqual(ret, "char *") + self.assertEqual(args[0], ("const char *", "fmt")) + self.assertEqual(args[1], ("...", None)) + + def test_parse_void_args(self): + from declib.api.prototype import parse_prototype + ret, args = parse_prototype("unsigned long long foo(void)") + self.assertEqual(ret, "unsigned long long") + self.assertEqual(args, []) + + def test_parse_bad_raises(self): + from declib.api.prototype import parse_prototype + for bad in ["int main", "no parens here", "(int a)"]: + with self.assertRaises(ValueError): + parse_prototype(bad) + + def test_format_roundtrip(self): + from declib.api.prototype import format_prototype + from declib.artifacts import FunctionHeader, FunctionArgument + h = FunctionHeader(name="f", addr=0x1000, type_="int", + args={0: FunctionArgument(0, "a", "int", 4), + 1: FunctionArgument(1, "b", "char *", 8)}) + self.assertEqual(format_prototype(h), "int f(int a, char *b)") + + class TestArtifactWireSerialization(unittest.TestCase): """The client↔server wire format must survive tricky decompilation text.