diff --git a/declib/api/decompiler_client.py b/declib/api/decompiler_client.py index 3099b4a..47b0f54 100644 --- a/declib/api/decompiler_client.py +++ b/declib/api/decompiler_client.py @@ -437,6 +437,8 @@ def _send_request(self, request: Dict[str, Any]) -> Any: raise ValueError(error_msg) elif error_type == "AttributeError": raise AttributeError(error_msg) + elif error_type == "NotImplementedError": + raise NotImplementedError(error_msg) else: raise RuntimeError(f"{error_type}: {error_msg}") @@ -562,6 +564,14 @@ def disassemble(self, addr: int, **kwargs) -> Optional[str]: def read_memory(self, addr: int, size: int) -> Optional[bytes]: """Read raw bytes from the loaded program.""" return self._send_request({"type": "method_call", "method_name": "read_memory", "args": [addr, size]}) + + def save(self, path: Optional[str] = None) -> bool: + """Persist the backend's analysis to disk. Raises NotImplementedError for in-memory backends.""" + return self._send_request({"type": "method_call", "method_name": "save", "args": [path]}) + + def set_persist_on_close(self, value: bool) -> bool: + """Control whether the backend flushes to disk on teardown.""" + return self._send_request({"type": "method_call", "method_name": "set_persist_on_close", "args": [value]}) 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 5616f27..80c392d 100644 --- a/declib/api/decompiler_interface.py +++ b/declib/api/decompiler_interface.py @@ -528,6 +528,40 @@ def read_memory(self, addr: int, size: int) -> Optional[bytes]: """ raise NotImplementedError + # ------------------------------------------------------------------ + # Persistence + # ------------------------------------------------------------------ + #: Whether the backend should flush its analysis to disk when the + #: interface is torn down (``shutdown()`` -> ``_deinit_headless_components``). + #: Backends override the default to match their native behavior (IDA + #: discards on close, Ghidra saves on close). ``set_persist_on_close`` + #: flips it so ``decompiler stop --save/--discard`` can control the final + #: flush without an extra round-trip. + persist_on_close: bool = False + + def save(self, path: Optional[str] = None) -> bool: + """Persist the current analysis to the backend's on-disk database/project. + + This is the durable-artifact primitive: after ``save()`` returns True, + renames/retypes/comments made through DecLib survive a stop + reload of + the same project directory. Backends that hold no persistent database + (e.g. angr, which is purely in-memory) raise ``NotImplementedError``. + + @param path: Optional explicit output path. When ``None`` the backend + writes to whatever database/project it already has open. + @return: True on success. + """ + raise NotImplementedError("This backend does not support saving to disk.") + + def set_persist_on_close(self, value: bool) -> bool: + """Control whether teardown flushes analysis to disk. + + Used by ``decompiler stop --save/--discard`` to decide the final flush + for backends (like Ghidra) that persist on close by default. + """ + self.persist_on_close = bool(value) + return True + def get_callgraph(self, only_names=False) -> nx.DiGraph: """ Returns the callgraph of the binary. This is a dict of function addresses to a list of function addresses diff --git a/declib/cli/decompiler_cli.py b/declib/cli/decompiler_cli.py index 11acc23..d65e41e 100644 --- a/declib/cli/decompiler_cli.py +++ b/declib/cli/decompiler_cli.py @@ -9,7 +9,8 @@ Subcommands implemented: - load start a server on a binary - list list running servers -- stop stop one or all servers +- stop stop one or all servers (with --save/--discard) +- save persist backend analysis to disk (durable artifacts) - list_functions list functions in the binary, optionally filtered by regex - decompile decompile a function by name or address - disassemble disassemble a function by name or address @@ -43,6 +44,7 @@ EXIT_USER_ERROR = 1 # user asked for something that didn't happen EXIT_NOT_FOUND = 1 # missing function/name/binary EXIT_RUNTIME_ERROR = 1 # unhandled/unknown failure +EXIT_UNSUPPORTED = 2 # feature not implemented for the selected backend from declib.api import server_registry from declib.decompilers import SUPPORTED_DECOMPILERS @@ -310,12 +312,17 @@ def cmd_list(args) -> int: return 0 -def _stop_server_by_record(record: Dict) -> bool: +def _stop_server_by_record(record: Dict, save_mode: Optional[str] = None) -> bool: """Shut down the server process backing `record`. Asks the server to shut itself down gracefully, falling back to SIGTERM/SIGKILL on the PID if the request fails. Returns True if we believe the process is gone (or never existed) by the time we return. + + ``save_mode`` controls the final flush to disk before teardown: + - ``"save"`` — persist analysis (``client.save()``) first. + - ``"discard"`` — tell the backend not to persist on close. + - ``None`` — leave the backend's default behavior alone. """ from declib.api.decompiler_client import DecompilerClient @@ -329,6 +336,18 @@ def _stop_server_by_record(record: Dict) -> bool: _l.warning("Could not connect to server %s: %s", server_id, exc) client = None if client is not None: + if save_mode == "save": + try: + client.save() + except NotImplementedError: + _l.debug("Backend %s does not support saving; nothing to flush.", server_id) + except Exception as exc: + _l.warning("save before stop failed for %s: %s", server_id, exc) + elif save_mode == "discard": + try: + client.set_persist_on_close(False) + except Exception as exc: + _l.debug("set_persist_on_close(False) failed for %s: %s", server_id, exc) try: client._send_request({"type": "shutdown_server"}) graceful = True @@ -381,6 +400,8 @@ def _wait_for_process_exit(pid, timeout: float) -> bool: def cmd_stop(args) -> int: + if args.save and args.discard: + raise SystemExit("decompiler stop: --save and --discard are mutually exclusive") records = server_registry.list_servers() if args.all: targets = records @@ -394,14 +415,32 @@ def cmd_stop(args) -> int: if not targets: raise SystemExit("No matching server to stop") + save_mode = "save" if args.save else "discard" if args.discard else None + results = [] for record in targets: - ok = _stop_server_by_record(record) - results.append({"id": record.get("id"), "stopped": bool(ok)}) + ok = _stop_server_by_record(record, save_mode=save_mode) + results.append({"id": record.get("id"), "stopped": bool(ok), "save_mode": save_mode}) _emit(args, {"stopped": results}) return 0 +def cmd_save(args) -> int: + """Persist the selected server's analysis to its on-disk database/project.""" + with _with_client(args) as client: + try: + ok = bool(client.save(args.path)) + except NotImplementedError as exc: + # Re-raise so main() renders the friendly "not implemented" message + # and returns EXIT_UNSUPPORTED. + raise NotImplementedError( + str(exc) or "this backend has no persistent database to save " + "(e.g. angr is purely in-memory)." + ) + _emit(args, {"saved": ok, "path": args.path}) + return EXIT_OK if ok else EXIT_USER_ERROR + + # --------------------------------------------------------------------------- # decompile / disassemble # --------------------------------------------------------------------------- @@ -1289,9 +1328,28 @@ def build_parser() -> argparse.ArgumentParser: p_stop.add_argument("--id", dest="id", help="Server ID to stop.") p_stop.add_argument("--binary", dest="binary", help="Stop servers for this binary.") p_stop.add_argument("--all", action="store_true", help="Stop every running server.") + p_stop.add_argument("--save", action="store_true", + help="Persist analysis to disk before stopping (durable .i64/.gpr/.bndb).") + p_stop.add_argument("--discard", action="store_true", + help="Explicitly drop unsaved edits on stop (default for IDA; overrides Ghidra's save-on-close).") _add_output_args(p_stop) p_stop.set_defaults(func=cmd_stop) + # save + p_save = sub.add_parser( + "save", + help=( + "Persist the backend's analysis to disk so renames/types/comments " + "survive a stop + reload. IDA writes its .i64, Ghidra saves the " + "project, Binary Ninja writes a .bndb; angr (in-memory) is unsupported." + ), + ) + p_save.add_argument("--path", dest="path", default=None, + help="Explicit output path (backend-dependent; defaults to the open database).") + _add_server_filter_args(p_save) + _add_output_args(p_save) + p_save.set_defaults(func=cmd_save) + # decompile p_dec = sub.add_parser("decompile", help="Decompile a function by name or address.") p_dec.add_argument("target", help="Function name or address (hex/decimal).") @@ -1477,6 +1535,13 @@ def main(argv: Optional[List[str]] = None) -> int: return args.func(args) or EXIT_OK except SystemExit: raise + except NotImplementedError as exc: + # A backend that doesn't implement the requested capability. Surface a + # clean, distinct message + exit code so scripts can tell "unsupported + # on this backend" apart from a genuine failure. + msg = str(exc) or "not implemented for this backend" + print(f"not implemented: {msg}", file=sys.stderr) + return EXIT_UNSUPPORTED except Exception as exc: # noqa: BLE001 _l.exception("Unhandled error: %s", exc) print(f"Error: {exc}", file=sys.stderr) diff --git a/declib/decompilers/binja/interface.py b/declib/decompilers/binja/interface.py index 75db4d0..2562c9f 100644 --- a/declib/decompilers/binja/interface.py +++ b/declib/decompilers/binja/interface.py @@ -103,6 +103,25 @@ def __del__(self): if self.headless and BN_AVAILABLE: self.bv.file.close() + def save(self, path=None) -> bool: + """Persist analysis to a Binary Ninja database (``.bndb``). + + ``binaryninja.load`` opens the raw file with no backing database, so + the first save creates one; subsequent saves snapshot into it. When no + ``path`` is given we derive ``.bndb`` next to the binary. + """ + if self.bv is None: + return False + file_metadata = self.bv.file + try: + if file_metadata.filename and file_metadata.filename.endswith(".bndb"): + return bool(file_metadata.save_auto_snapshot()) + target = path or (str(self._binary_path) + ".bndb") + return bool(file_metadata.create_database(target)) + except Exception as e: + l.warning("Failed to save Binary Ninja database: %s", e) + return False + # # GUI # diff --git a/declib/decompilers/ghidra/compat/headless.py b/declib/decompilers/ghidra/compat/headless.py index d29af73..d9f39e9 100644 --- a/declib/decompilers/ghidra/compat/headless.py +++ b/declib/decompilers/ghidra/compat/headless.py @@ -138,16 +138,35 @@ def _setup_project( return project, program -def close_program(program, project) -> bool: +def save_program(program, project) -> bool: + """Flush the program's changes into its project file on disk. + + Ghidra writes edits into the in-memory ``ProgramDB`` via transactions; + ``GhidraProject.save`` is what persists them to the ``.gpr``/``.rep`` so a + later reopen sees the renames/types/comments. + """ + try: + project.save(program) + return True + except Exception as e: + _l.critical("Failed to save program: %s", e) + return False + + +def close_program(program, project, save=True) -> bool: """ Returns true if closing was successful, false otherwise. + When ``save`` is False the in-memory changes are discarded (the on-disk + project keeps whatever was last saved — the freshly-analyzed program), which + is how ``decompiler stop --discard`` reverts unsaved work. """ from ghidra.app.script import GhidraScriptUtil try: GhidraScriptUtil.releaseBundleHostReference() - project.save(program) + if save: + project.save(program) project.close() return True except Exception as e: diff --git a/declib/decompilers/ghidra/interface.py b/declib/decompilers/ghidra/interface.py index 12e256e..5595786 100644 --- a/declib/decompilers/ghidra/interface.py +++ b/declib/decompilers/ghidra/interface.py @@ -19,7 +19,7 @@ from .artifact_lifter import GhidraArtifactLifter from .compat.transaction import ghidra_transaction -from .compat.headless import close_program, open_program +from .compat.headless import close_program, open_program, save_program from .compat.state import get_current_address if typing.TYPE_CHECKING: @@ -82,6 +82,11 @@ def __init__( self._results_queue = queue.Queue() self.requires_main_thread_dispatch = not kwargs.get("headless", False) + # Ghidra flushes the ProgramDB into the project on teardown by default, + # so a reload already sees prior work. `decompiler stop --discard` flips + # this off to drop unsaved edits. + self.persist_on_close = True + super().__init__( name="ghidra", artifact_lifter=GhidraArtifactLifter(self), @@ -100,10 +105,22 @@ def _init_gui_components(self, *args, **kwargs): def _deinit_headless_components(self): if self._program is not None and self._project is not None: - close_program(self._program, self._project) + close_program(self._program, self._project, save=bool(self.persist_on_close)) self._project = None self._program = None + def save(self, path=None) -> bool: + """Persist the Ghidra program into its project on disk. + + ``path`` is ignored: Ghidra saves back into the project the binary was + imported into (set via ``--project-dir``). Returns False when running + against a caller-provided program with no owned project. + """ + if self._program is None or self._project is None: + _l.warning("No owned Ghidra project to save (program provided externally).") + return False + return save_program(self._program, self._project) + def _init_headless_components(self, *args, **kwargs): if self._program is not None: # We were already provided a program object as part of the instantiation, so just use it diff --git a/declib/decompilers/ida/compat.py b/declib/decompilers/ida/compat.py index 1ab252d..fa2fcf9 100644 --- a/declib/decompilers/ida/compat.py +++ b/declib/decompilers/ida/compat.py @@ -1831,6 +1831,26 @@ def wait_for_idc_initialization(): idc.auto_wait() +@execute_write +def save_database(path=None): + """Flush the in-memory IDB to disk. + + idalib opens the database in memory and (by default) discards it on + ``close_database(False)``; this writes it out so renames/types/comments + become a durable ``.i64``/``.idb`` artifact. With no ``path`` we save to + whatever database file IDA already has open (honoring any ``-o`` redirect + from ``project_dir``). ``idc.save_database`` returns None on success, so we + treat "no exception" as success. + """ + target = path or idc.get_idb_path() or "" + try: + idc.save_database(target, 0) + except Exception as e: + _l.warning("save_database failed for %r: %s", target, e) + return False + return True + + def initialize_decompiler(): return bool(ida_hexrays.init_hexrays_plugin()) diff --git a/declib/decompilers/ida/interface.py b/declib/decompilers/ida/interface.py index 9787e51..d4592e0 100755 --- a/declib/decompilers/ida/interface.py +++ b/declib/decompilers/ida/interface.py @@ -81,6 +81,19 @@ def _init_headless_components(self, *args, **kwargs): """ super()._init_headless_components(*args, **kwargs) binary_path = str(self.binary_path) + + # If a saved database already exists (from a prior `save`), reopen it + # directly. Passing the original binary + `-o` makes + # idalib prompt to overwrite, which hangs headless — and re-analyzing + # would clobber the user's persisted renames/types/comments. Reopening + # the .i64 with auto-analysis off is both fast and lossless. + existing_db = self._existing_database_path() + if existing_db is not None: + _l.info("Reopening existing IDA database %s", existing_db) + if idapro.open_database(existing_db, False): + raise RuntimeError(f"Failed to reopen database {existing_db}") + return + extra_args = self._ida_open_args() # IDA <= 9.1 only accepts (path, run_auto_analysis); the extra_args # parameter was added in 9.2. @@ -96,6 +109,29 @@ def _init_headless_components(self, *args, **kwargs): if failure: raise RuntimeError(f"Failed to open database {binary_path}") + def _ida_db_base(self): + """Return the database base path (no extension) IDA writes to. + + With ``project_dir`` set the database lives at + ``/ida/``; otherwise IDA drops it beside the + binary. IDA appends ``.i64``/``.idb`` itself. + """ + from pathlib import Path as _Path + + if not self._project_dir: + return _Path(str(self.binary_path)) + project_dir = _Path(self._project_dir).expanduser().resolve() + return project_dir / "ida" / _Path(str(self.binary_path)).name + + def _existing_database_path(self) -> Optional[str]: + """Path to a previously-saved IDA database for this binary, or None.""" + base = self._ida_db_base() + for ext in (".i64", ".idb"): + candidate = base.parent / (base.name + ext) + if candidate.exists(): + return str(candidate) + return None + def _ida_open_args(self) -> Optional[str]: """Build the extra args string passed to ``idapro.open_database``. @@ -106,26 +142,30 @@ def _ida_open_args(self) -> Optional[str]: user / other backends leave in the top-level project_dir (Ghidra's ``_ghidra/`` project, stale symlinks, etc.). """ - from pathlib import Path as _Path - if not self._project_dir: return None - project_dir = _Path(self._project_dir).expanduser().resolve() - ida_dir = project_dir / "ida" - ida_dir.mkdir(parents=True, exist_ok=True) - binary_name = _Path(str(self.binary_path)).name + db_base = self._ida_db_base() + db_base.parent.mkdir(parents=True, exist_ok=True) # IDA's -o takes the database base path (no extension); it picks # .idb / .i64 / .id* itself. - db_base = ida_dir / binary_name return f"-o{db_base}" def _deinit_headless_components(self): """ This function deinitializes the headless functionality of IDA through idalib. This also means that this feature is only supported in IDA versions >= 9.0 + + ``persist_on_close`` (default False) decides whether the IDB is written + out on the way down. ``decompiler stop --save`` sets it (or calls + ``save()`` explicitly) so the database survives; the default discards + the in-memory database, matching legacy behavior. """ - idapro.close_database(False) + idapro.close_database(bool(self.persist_on_close)) + + def save(self, path=None) -> bool: + """Write the current IDB to disk (durable rename/type/comment artifacts).""" + return compat.save_database(path) def _init_gui_hooks(self): """ diff --git a/declib/skills/decompiler/SKILL.md b/declib/skills/decompiler/SKILL.md index 25455f4..89323d2 100644 --- a/declib/skills/decompiler/SKILL.md +++ b/declib/skills/decompiler/SKILL.md @@ -129,7 +129,8 @@ same binary. |---|---|---| | `load ` | Start a server on the binary. Idempotent: returns existing unless `--force`/`--replace`. | `--backend`, `--id`, `--force`, `--replace`, `--project-dir`, `--json` | | `list` | Show all running servers and the registry path. | `--show-registry`, `--json` | -| `stop` | Shut down one or all servers. | `--id`, `--binary`, `--all`, `--json` | +| `stop` | Shut down one or all servers. `--save` flushes analysis to disk first; `--discard` drops unsaved edits. | `--id`, `--binary`, `--all`, `--save`, `--discard`, `--json` | +| `save` | Persist backend analysis to disk so renames/types/comments survive a reload. | `--path`, `--id`, `--binary`, `--backend`, `--json` | | `list_functions` | Enumerate every function (ADDR, SIZE, NAME). | `--filter REGEX`, `--json` | | `decompile ` | Pseudocode for a function (name or address). | `--raw`, `--id`, `--binary`, `--backend`, `--json` | | `disassemble ` | Assembly for a function. | `--raw`, same | @@ -160,6 +161,31 @@ same binary. for `get_callers`; when you want "who touches this in any way?" reach for `xref_to`. +### Persistence — durable artifacts (`save`, `stop --save`) + +By default a server holds the binary open in memory; IDA discards edits on +stop, while Ghidra persists on close. To make renames/types/comments **durable** +regardless of backend, `save` explicitly, or stop with `--save`: + +```bash +decompiler load ./fauxware --backend ida --project-dir ./proj +decompiler rename func authenticate auth_check +decompiler save # writes ./proj/ida/fauxware.i64 +decompiler stop --id +decompiler load ./fauxware --backend ida --project-dir ./proj # reopens saved DB +decompiler list_functions --filter auth_check # the rename is still there +# {"saved": true, "path": null} + +decompiler stop --all --save # flush every server before shutting down +decompiler stop --id --discard # drop unsaved edits (revert to analyzed state) +``` + +Reuse the **same `--project-dir`** across load/reload so the backend finds its +saved database (IDA `.i64`, Ghidra project, Binary Ninja `.bndb`). `angr` is +purely in-memory: `save` exits `2` (`not implemented`) and there is nothing to +reload. IDA reopens the saved `.i64` directly (no re-analysis), so a reload after +`save` is fast and lossless. + ### `read_memory` — raw bytes at an address `read_memory ` reads `` bytes from the loaded binary's @@ -270,8 +296,10 @@ decompiler decompile main --raw - **First `load` is slow** (backend analysis pass). Subsequent calls on the same server are fast. -- **`rename` exit codes**: every CLI command exits `0` on success and `1` - on any failure (including "rename didn't find the old name"). Use +- **Exit codes**: every CLI command exits `0` on success and `1` + on failure (including "rename didn't find the old name"). Exit `2` means + the feature is **not implemented for the selected backend** (e.g. `save` + on angr) — distinct from a real error so scripts can branch on it. Use `&&` safely. - **Stripped binaries**: use `list_functions` before `decompile` to find the real entry. `main` may not exist; look for non-default names diff --git a/docs/decompiler_cli.md b/docs/decompiler_cli.md index 79db1ce..11e0745 100644 --- a/docs/decompiler_cli.md +++ b/docs/decompiler_cli.md @@ -180,10 +180,36 @@ ID BACKEND PID BINARY Stop one or all servers. ```bash -decompiler stop [--id SERVER_ID] [--binary PATH] [--all] [--json] +decompiler stop [--id SERVER_ID] [--binary PATH] [--all] [--save | --discard] [--json] ``` -You must pass one of `--id`, `--binary`, or `--all`. +You must pass one of `--id`, `--binary`, or `--all`. By default the backend's +native teardown applies (IDA discards, Ghidra persists). Pass `--save` to flush +analysis to disk first (durable across a reload), or `--discard` to explicitly +drop unsaved edits. `--save` and `--discard` are mutually exclusive. + +### `save` + +Persist the backend's analysis to disk so renames, retypes, and comments +survive a `stop` + reload of the same `--project-dir`. + +```bash +decompiler save [--path PATH] [--id ID] [--binary PATH] [--backend BACKEND] [--json] +``` + +Each backend writes its native artifact: IDA a `.i64`/`.idb`, Ghidra its +project, Binary Ninja a `.bndb`. `angr` is purely in-memory and has nothing to +persist — `save` exits `2` (`not implemented`) there. On reload, DecLib reopens +the saved database (IDA does so without re-analysis), so prior work is intact. + +```bash +decompiler load ./fauxware --backend ida --project-dir ./proj +decompiler rename func authenticate auth_check +decompiler save # {"saved": true, "path": null} +decompiler stop --id +decompiler load ./fauxware --backend ida --project-dir ./proj # reopens saved .i64 +decompiler list_functions --filter auth_check # rename survived +``` ### `list_functions` @@ -421,9 +447,10 @@ Every CLI command uses these exit codes: | Code | Meaning | |---|---| | `0` | Success. | -| `1` | User-visible error — target not found, rename didn't apply, decompile failed, etc. All failure modes unify to `1` so that shell `&&` chaining works cleanly. | +| `1` | User-visible error — target not found, rename didn't apply, decompile failed, etc. Most failure modes unify to `1` so that shell `&&` chaining works cleanly. | +| `2` | The requested capability is **not implemented for the selected backend** (e.g. `save` on angr). Distinct from `1` so scripts can branch on "unsupported" vs "failed". | -Argparse-level errors (unknown subcommand, missing required argument) exit +Argparse-level errors (unknown subcommand, missing required argument) also exit with Python's standard argparse code `2`. --- diff --git a/tests/test_decompiler_cli.py b/tests/test_decompiler_cli.py index 687f58f..1e9666d 100644 --- a/tests/test_decompiler_cli.py +++ b/tests/test_decompiler_cli.py @@ -383,6 +383,81 @@ def test_project_dir_keeps_binary_dir_clean(self): self.assertTrue(project_contents, f"{self.backend} wrote nothing to the project_dir") + # ------------------------------------------------------------------- + # Persistence: explicit save + stop --save/--discard, reopen-and-verify + # ------------------------------------------------------------------- + + def _isolated_proj(self): + """A fresh, dot-free project dir (Ghidra rejects dot-prefixed paths).""" + proj = tempfile.mkdtemp(prefix="declib_persist_proj_") + self.addCleanup(shutil.rmtree, proj, ignore_errors=True) + return proj + + def test_save_unsupported_on_inmemory_backend(self): + """Backends with no on-disk database report `save` as unsupported (exit 2).""" + if self._persists_project_files: + self.skipTest(f"{self.backend} persists to disk; covered by reopen tests") + self._load_fauxware() + result = _run_cli("save", "--json", check=False) + self.assertEqual(result.returncode, 2, + f"{self.backend}: expected unsupported exit 2, got {result.returncode}: " + f"{result.stdout + result.stderr}") + self.assertIn("not implemented", (result.stdout + result.stderr).lower()) + + def test_save_command_persists_rename_across_reload(self): + """`save` then stop then reload (same project-dir) keeps a function rename.""" + if not self._persists_project_files: + self.skipTest(f"{self.backend} is in-memory; nothing to persist") + proj = self._isolated_proj() + loaded = self._load_fauxware(project_dir=proj) + server_id = loaded["id"] + + new_name = "persisted_via_save" + renamed = _run_cli("rename", "func", "authenticate", new_name, "--json") + self.assertTrue(json.loads(renamed.stdout)["success"], + f"{self.backend}: rename failed: {renamed.stdout}") + + saved = _run_cli("save", "--json") + self.assertTrue(json.loads(saved.stdout)["saved"], + f"{self.backend}: save reported failure: {saved.stdout}") + + _run_cli("stop", "--id", server_id, "--json") + + # Reopen the same project dir in a fresh server and confirm the rename + # survived the round-trip to disk. + self._load_fauxware(project_dir=proj) + listing = json.loads(_run_cli("list_functions", "--filter", new_name, "--json").stdout) + names = {e["name"] for e in listing} + self.assertIn(new_name, names, + f"{self.backend}: rename did not persist across reload; saw {names}") + + def test_stop_save_persists_rename_across_reload(self): + """`stop --save` flushes to disk so a reload sees the rename.""" + if not self._persists_project_files: + self.skipTest(f"{self.backend} is in-memory; nothing to persist") + proj = self._isolated_proj() + loaded = self._load_fauxware(project_dir=proj) + server_id = loaded["id"] + + new_name = "persisted_via_stop_save" + renamed = _run_cli("rename", "func", "authenticate", new_name, "--json") + self.assertTrue(json.loads(renamed.stdout)["success"]) + + stopped = _run_cli("stop", "--id", server_id, "--save", "--json") + self.assertTrue(json.loads(stopped.stdout)["stopped"][0]["stopped"]) + + self._load_fauxware(project_dir=proj) + listing = json.loads(_run_cli("list_functions", "--filter", new_name, "--json").stdout) + names = {e["name"] for e in listing} + self.assertIn(new_name, names, + f"{self.backend}: rename did not persist via stop --save; saw {names}") + + def test_stop_save_and_discard_are_mutually_exclusive(self): + loaded = self._load_fauxware() + result = _run_cli("stop", "--id", loaded["id"], "--save", "--discard", check=False) + self.assertNotEqual(result.returncode, 0) + self.assertIn("mutually exclusive", result.stdout + result.stderr) + # ------------------------------------------------------------------- # create-type / retype (run against every backend) # -------------------------------------------------------------------