From 8616d7f7b9913c62d42905b31e6b7a21c66b2982 Mon Sep 17 00:00:00 2001 From: Yoann Dandine Date: Mon, 7 Sep 2026 11:13:19 +0200 Subject: [PATCH 1/2] Persist program mutations to the project DB after each command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mutating bridge commands (create_function, rename_function, symbol/type edits, tag edits, comments, patches, script/batch runs) only ended their Ghidra transaction, which commits into the bridge JVM's in-memory program. Nothing flushed to the .rep store until analyze/program switch/import, and `stop` SIGKILLs the JVM without a teardown save, so those edits were lost on teardown and never appeared when the project was opened in the Ghidra GUI. Add isMutatingCommand() and, in executeProgramRequest, call currentProgram.save() after any successful mutating command. Read-only queries (decompile, disasm, list/xref/find/graph/diff/tag list/exports) are excluded, as are import/analyze/open_program which already persist on their own paths. Save failures are logged via printerr and do not fail the command. Verified end-to-end on a freshly imported binary: `comment set` + `tag add`, then `stop` (kills the JVM, no teardown save), then restart — both the plate comment and the tag read back; without the save they were gone. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 14 ++++++ src/ghidra/scripts/GhidraCliBridge.java | 62 +++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4465a879..a5e4f7e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- Program edits are now persisted to the project database after every mutating + command. Previously, commands such as `comment set`, `symbol rename`, `patch + bytes`, `type` edits, `tag` edits and script/batch runs only ended their Ghidra + transaction, which commits into the bridge JVM's in-memory program; nothing + flushed to the `.rep` store until an `analyze`/program switch/`import`. Since + `bridge stop` kills the JVM without a teardown save, those edits were lost on + teardown and never showed up when the project was opened in the Ghidra GUI. + Read-only queries are unaffected, and a failed save is logged without failing + the command. + ## [0.2.2] ### Added diff --git a/src/ghidra/scripts/GhidraCliBridge.java b/src/ghidra/scripts/GhidraCliBridge.java index 0de4833b..65f72b82 100644 --- a/src/ghidra/scripts/GhidraCliBridge.java +++ b/src/ghidra/scripts/GhidraCliBridge.java @@ -668,12 +668,74 @@ private HandleResult executeProgramRequest(String command, JsonObject args) { return new HandleResult(errorResponse(result.get("error").getAsString()), false); } + // Persist mutations to the project database so they are visible + // when the program is later opened in the Ghidra GUI. Ending a + // transaction (endTransaction) only commits into the bridge JVM's + // in-memory program; only program.save() flushes to the .rep store. + // import/analyze/open_program already save on their own paths and + // are excluded here to avoid redundant writes. + if (isMutatingCommand(command) && currentProgram != null) { + try { + currentProgram.save("ghidra-cli: " + command, monitor); + } catch (Exception saveErr) { + printerr("Auto-save after " + command + " failed: " + saveErr.getMessage()); + } + } + return new HandleResult(successResponse(result), false); } catch (Exception e) { return new HandleResult(errorResponse(e.getMessage()), false); } } + /** + * Commands that mutate the program and must be flushed to the project's + * .rep database with program.save() so the changes survive bridge teardown + * and appear in the Ghidra GUI. Read-only queries (decompile, disasm, + * list_*, xrefs_*, find_*, get_*, graph_*, diff_*, exports) are omitted, as + * are import/analyze/open_program which persist on their own code paths. + */ + private boolean isMutatingCommand(String command) { + if (command == null) return false; + switch (command) { + case "create_function": + case "rename_function": + case "delete_function": + case "symbol_create": + case "symbol_delete": + case "symbol_rename": + case "type_create": + case "type_apply": + case "type_delete": + case "type_rename": + case "type_create_enum": + case "type_typedef": + case "type_add_field": + case "type_del_field": + case "function_set_signature": + case "function_set_return_type": + case "function_set_calling_convention": + case "set_var_type": + case "comment_set": + case "comment_delete": + case "patch_bytes": + case "patch_nop": + case "script_run": + case "script_java": + case "script_python": + case "tag_create": + case "tag_delete": + case "tag_rename": + case "tag_set_comment": + case "tag_add": + case "tag_remove": + case "batch": + return true; + default: + return false; + } + } + private JsonObject dispatchCommand(String command, JsonObject args) { if (command == null) return null; switch (command) { From cb9a8bbc8ccaf4d10bfb38134a1715ea5a5df936 Mon Sep 17 00:00:00 2001 From: Yoann Dandine Date: Mon, 7 Sep 2026 11:39:49 +0200 Subject: [PATCH 2/2] create_function: fall back to CreateFunctionCmd for flat-image gaps The strict FunctionManager.createFunction() API rejects entries in raw/flat firmware images that Ghidra's auto-analysis never reached, failing with "Function body must contain the entrypoint" even when valid instructions exist at the address. This blocked defining the many computed/indirect-call targets that are the whole point of annotating STM32/nRF/bootloader images. On strict failure, fall back to ghidra.app.cmd.function.CreateFunctionCmd (what the GUI's "Create Function" uses): it disassembles at the entry if needed and computes the body by following flow, then we apply the requested name. Callers must pass EVEN (non-Thumb-bit) addresses; an odd address is treated as a literal mid-instruction location and rejected. Co-Authored-By: Claude Opus 5 (1M context) --- src/ghidra/scripts/GhidraCliBridge.java | 32 ++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/ghidra/scripts/GhidraCliBridge.java b/src/ghidra/scripts/GhidraCliBridge.java index 65f72b82..1b360007 100644 --- a/src/ghidra/scripts/GhidraCliBridge.java +++ b/src/ghidra/scripts/GhidraCliBridge.java @@ -1472,7 +1472,37 @@ private JsonObject handleCreateFunction(JsonObject args) { int txId = currentProgram.startTransaction("Create function"); try { - Function created = fm.createFunction(functionName, addr, null, SourceType.USER_DEFINED); + Function created = null; + try { + created = fm.createFunction(functionName, addr, null, SourceType.USER_DEFINED); + } catch (Exception strictErr) { + // Strict FunctionManager API rejects raw/flat-firmware gap entries + // with "Function body must contain the entrypoint". Fall back to + // the GUI-equivalent command below. + created = null; + } + + if (created == null) { + // CreateFunctionCmd disassembles at the entry if needed and + // computes the body by following flow, matching what the Ghidra + // GUI's "Create Function" does. This is what lets computed/indirect + // gap targets (common on flat firmware images) become real functions. + ghidra.app.cmd.function.CreateFunctionCmd cmd = + new ghidra.app.cmd.function.CreateFunctionCmd(addr); + boolean applied = cmd.applyTo(currentProgram, monitor); + if (applied) { + created = fm.getFunctionAt(addr); + if (created != null && requestedName != null && !requestedName.isEmpty()) { + try { + created.setName(functionName, SourceType.USER_DEFINED); + } catch (Exception nameErr) { + printerr("Function created but naming failed at " + addr + + ": " + nameErr.getMessage()); + } + } + } + } + if (created == null) { currentProgram.endTransaction(txId, false); return errorResult("Failed to create function at " + addr.toString());