Skip to content

Swift bindings - #6

Draft
Mx-Iris wants to merge 10 commits into
19h:masterfrom
MxIris-Reverse-Engineering:swift-bindings
Draft

Swift bindings#6
Mx-Iris wants to merge 10 commits into
19h:masterfrom
MxIris-Reverse-Engineering:swift-bindings

Conversation

@Mx-Iris

@Mx-Iris Mx-Iris commented Aug 4, 2026

Copy link
Copy Markdown

Draft for #5.

Nine commits, each one builds on its own. The Swift package is the last; everything before it is groundwork it needs.

Two things here you might want regardless of how you feel about the Swift part:

ctree operand navigation segfaults. first() and second() deref x/y without checking op_uses_x/op_uses_y. Those slots alias other members for most opcodes — z aliases an int — so on a deep ctree you end up reading a non-pointer as a cexpr_t*. third() did guard, but only against cot_tern. There's an integration test for the traversal. Take this one on its own if nothing else here appeals.

build*/ in .gitignore isn't anchored, so it matches at any depth, and since macOS is case-insensitive it swallows BuildXCFramework/ as well. That's why that plugin directory could never be committed while Package.swift referenced it. /build*/ fixes it; the separate build/ rule still covers nested ones.

The two domains

I said in #5 that the Swift layer isn't self-contained. Concretely:

ida::microcodedecompiler only hands you microcode while a filter or optimizer callback is on the stack, and the SDK frees the mba_t the moment it returns. snapshot() deep copies the whole thing and releases the SDK object, so what comes back outlives it. It's a separate namespace rather than more decompiler surface because the lifetime story is the inverse: decompiler lends borrowed views, this hands over owned data.

ida::dyld_cache — wraps dscu, whose interface is run() argument codes. docs/IDA94DyldCacheAdaptation.md covers what moved in 9.4.

Both are here because Swift needs them, not because I'm assuming you want them. If you'd rather they landed separately, or under different names, or not at all, say the word and I'll restructure.

Left out

Per what I said in #5: no checked-in xcframework (swift package build-xcframework produces it), no third-party dependencies, nothing from our agent ledger or planning docs. Our fork has an ArgumentParser-based CLI tool for dyld cache work — that stays on our side, so Package.swift here pulls in nothing.

The Swift layer itself

Typed throws rather than a Result type, so failure shows up in the signature. Handle owners are classes releasing in deinit, value snapshots are structs, no pointer reaches the public API.

SPM can't drive the CMake build, so bindings/swift/scripts/build-libs.sh produces the .a files first and the manifest links them. It resolves the IDA runtime from $IDADIR, falling back to an installed IDA under /Applications, since the test bundle and the example executable both need libida/libidalib at link time.

Tested

SDK 9.4, macOS arm64, against a real IDA install:

  • ctest — 42/42, including all 36 integration suites (so the domains added since February are exercised, not just mine)
  • cargo test -p idax --lib — 173 passed
  • npm test — 277 passed
  • swift test — 64 tests in 32 suites, no IDA runtime needed

Also built the branch from a clean clone following the README, to be sure nothing needed is missing from the tree. The parity inventory moves 40 → 42 for the two new umbrella includes, with probes for both.

There's now a build-swift job in the bindings workflow too — macOS only, same IDA install and path resolution as the others. It won't run on this PR since forks can't see HCLI_KEY, but I ran the same command sequence locally against 9.4.

One gap I know about: the Node addon only got the two files it actually needed.

Mx-Iris added 9 commits August 4, 2026 16:18
`build*/` was unanchored, so it matched at any depth — and because macOS
mounts a case-insensitive filesystem, it also matched BuildXCFramework/. The
SPM command plugin under bindings/swift/Plugins/ therefore could never be
committed, while Package.swift referenced it, so a clean clone failed to
build the Swift package.

Anchoring to /build*/ keeps every root-level build directory ignored; the
separate unanchored `build/` rule above still covers nested ones such as
bindings/node/build/.
Walking a ctree previously meant visiting it; there was no way to ask a
statement for its own parts. Adds condition/then_branch/else_branch/body/
init_expression/step_expression/expression, block_size/block_statement and
switch_case_count/values/body, so a consumer can descend a decompiled
function structurally instead of pattern-matching a flat visit stream.

LocalVariable also gains stack_offset (vd-offset for stack variables) and
register_number (the microcode mreg_t for register variables; on ARM64 x<n>
is 8 + 8*n). Together these let an importer map lvars back to concrete ABI
locations, which a name/type-only view cannot express.

Includes a segfault fix: operand navigation now gates on op_uses_x/y/z before
dereferencing. For most opcodes the x/y/z slots alias other members — z
aliases an int (ptrsize) — so reaching for them on a deep ctree read a
non-pointer as a cexpr_t*. The existing third() guarded only cot_tern; first()
and second() were unguarded. tests/integration covers the traversal.
Recovering control flow meant string-matching mnemonics per target. Adds a
BranchCondition enum and Instruction::branch_condition() that maps the
mnemonic to a semantic condition (equal, less-than-signed, overflow, …),
so a consumer can reason about a conditional branch without knowing whether
it is reading x86 jle, ARM b.le or a PowerPC bc variant.
Adds plugin::is_plugin_available() and plugin::run_plugin(). Some IDA
functionality is only reachable through a bundled plugin's run() entry point
— the dyld shared cache utility being the case that motivated this — and
there was no way to reach it from idax without going back to the raw SDK.
save() persists in place; there was no supported way to write the analysed
database to a different path, which headless tooling needs when the input
should stay untouched.
decompiler exposes microcode only while a filter or optimizer callback is on
the stack, and the SDK frees the mba_t as soon as that returns. Anything that
wants to read a function's microcode after the fact — an IR importer, a
lifter, an analysis pass — has no way in.

ida::microcode::snapshot(address, maturity) runs gen_microcode, deep copies
every block, instruction and operand into owned storage, then releases the
SDK's mba_t. The returned FunctionSnapshot outlives the SDK object entirely
and can be moved freely, so callers own nothing of the SDK.

The snapshot keeps what an IR consumer needs and the pseudocode view drops:
stack-frame totals, retvaridx (at MMAT_LVARS the return value lives in an
lvar rather than on m_ret's operands), block predecessors and successors,
nested instructions addressable by id, and the lvar table visible at that
maturity. It is a separate namespace rather than more decompiler surface
because the lifetime model is the opposite one — decompiler hands out
borrowed views, this hands out owned data.
Analysing a macOS/iOS dyld shared cache means driving the bundled dscu
plugin, whose interface is a set of run() argument codes with no typed entry
points. ida::dyld_cache wraps it: is_available(), list_modules() for both the
current database and a cache file on disk, load_module()/load_section(), and
the region loaders (branch islands, branch mappings, GOTs, gaps, cache data).

Loading is opt-in about analysis: each entry point takes a flag for whether
to drain auto-analysis before returning, because a caller loading twenty
modules wants to wait once at the end rather than twenty times.

docs/IDA94DyldCacheAdaptation.md records what changed in IDA 9.4 — the
utility's module listing and load paths moved, so the same call sequence
does not work across both versions.
The extern "C" header lived under bindings/rust/idax-sys/shim/ because Rust
was its only consumer. It is now shared, so it moves to bindings/c/include/
and the Rust build script points at the new location; bindgen still generates
from it and nothing about the Rust build changes otherwise.

The shim gains entry points for the surface added in the preceding commits:
ctree sub-structure navigation, microcode snapshots, the dyld shared cache
utility, plugin invocation, branch conditions, lvar stack offsets and
register numbers, and database::save_to. Node and Rust wrappers follow for
the database entry points.

cargo test -p idax --lib: 173 passed.
An SPM package layered like the existing bindings: CIDAX exposes the shared
extern "C" header as a system-library target, IDAX is the safe Swift layer
over it. 30 namespace files mirror the C++ domains.

The Swift layer uses typed throws (throws(IDAError)) rather than a Result
type, so an error path is visible in the signature and callers can bind the
concrete error without casting. Handle-owning types are classes with
deinit-based release; value snapshots are structs. Nothing in the public API
exposes a pointer.

Building it needs libidax.a and libidax_shim.a in place first, via
bindings/swift/scripts/build-libs.sh — SPM cannot drive the CMake build
itself. The manifest finds the IDA runtime through $IDADIR, falling back to
an installed IDA under /Applications, because anything that links (the
example executable, the test bundle) needs libida/libidalib on the link line.

swift test: 64 tests in 32 suites, no IDA runtime required.

bindings/swift/.gitignore had its patterns written as bindings/swift/.build/
and so on. Patterns in a nested .gitignore resolve relative to that file's own
directory, so each one expanded to bindings/swift/bindings/swift/... and never
matched; the build directories were only ignored incidentally by the root
*.a/*.o rules. Rewritten relative to the file.
@19h

19h commented Aug 4, 2026

Copy link
Copy Markdown
Owner

You ok with me taking over?

macOS only, since the package targets macOS 13+. Reuses the same IDA install
and IDASDK/IDADIR resolution as the other jobs, then builds the static
libraries through bindings/swift/scripts/build-libs.sh before swift build and
swift test — SPM cannot drive the CMake build itself, so the .a files have to
exist first.

Verified locally by running the same command sequence against SDK 9.4 on
macOS arm64: 64 tests in 32 suites.
@Mx-Iris

Mx-Iris commented Aug 4, 2026

Copy link
Copy Markdown
Author

You ok with me taking over?

I don't mind, please feel free to change it.

@matan7890

Copy link
Copy Markdown

This PR is so big and so unrelated to the goal of IDAX (c++ wrapping of IDA's API) - are you sure you would like to take on this PR?

@19h

19h commented Aug 5, 2026

Copy link
Copy Markdown
Owner

This PR is so big and so unrelated to the goal of IDAX (c++ wrapping of IDA's API) - are you sure you would like to take on this PR?

@matan7890 I haven't had the time to more closely review this, so I can't really judge yet. That being said, there are some very valid additions. The only thing that really bothers me is that the LLM seemingly entirely ignored the AGENTS.md file (which usually is the case with Claude), resulting in no documentation of progress, assumptions, decisions and otherwise relevant process specification. I'd probably split this up into salvageable, semantically isolated chunks of expansions that stack on each other. The actual Swift bindings are rather isolated.

@Mx-Iris Mx-Iris closed this Aug 6, 2026
@Mx-Iris
Mx-Iris deleted the swift-bindings branch August 6, 2026 04:02
@19h

19h commented Aug 6, 2026

Copy link
Copy Markdown
Owner

@Mx-Iris Why did you delete this? It was genuinely good!

@Mx-Iris
Mx-Iris restored the swift-bindings branch August 6, 2026 04:21
@Mx-Iris Mx-Iris reopened this Aug 6, 2026
@Mx-Iris

Mx-Iris commented Aug 6, 2026

Copy link
Copy Markdown
Author

Sorry, that wasn't deliberate — I didn't delete anything.

I renamed the branch on my fork so it wouldn't be confused with my own working
branch, assuming an open PR would follow the rename. It does for same-repo PRs;
it doesn't across forks. The old branch name went away and GitHub closed this
automatically. My fault for not checking that beforehand.

Branch is back and I've reopened it — same 10 commits, same 71 files, nothing
touched.

@19h

19h commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Thanks! I'll get to it as soon as possible!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants