From e0568eb435d9ef9b4d736c2ccd54ea28de3ea580 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 7 Jun 2026 15:38:46 +0000 Subject: [PATCH] archive: add 1 repo prompt(s) [skip ci] --- archive/buzzer-re/ToCode.txt | 6958 ++++++++++++++++++++++++++++++++++ 1 file changed, 6958 insertions(+) create mode 100644 archive/buzzer-re/ToCode.txt diff --git a/archive/buzzer-re/ToCode.txt b/archive/buzzer-re/ToCode.txt new file mode 100644 index 00000000..713e8d41 --- /dev/null +++ b/archive/buzzer-re/ToCode.txt @@ -0,0 +1,6958 @@ +Project Path: arc_buzzer-re_ToCode_b58w3sfz + +Source Tree: + +```txt +arc_buzzer-re_ToCode_b58w3sfz +├── AGENTS.md +├── CLAUDE.md +├── README.md +├── ci-local.ps1 +├── ci-local.sh +├── examples +│ └── vuln-toy +│ ├── README.md +│ ├── semgrep.yml +│ └── vuln_toy.c +├── install.ps1 +├── install.sh +├── pyproject.toml +├── src +│ └── tocode +│ ├── __init__.py +│ ├── __main__.py +│ ├── analysis.py +│ ├── backends +│ │ ├── __init__.py +│ │ ├── base.py +│ │ ├── ida.py +│ │ └── r2.py +│ ├── cli.py +│ ├── cluster.py +│ ├── errors.py +│ ├── exporter.py +│ ├── metadata.py +│ ├── naming.py +│ ├── parallel.py +│ ├── progress.py +│ └── schema.py +├── tests +│ ├── test_algorithms.py +│ ├── test_cli.py +│ └── test_exporter.py +└── uv.lock + +``` + +`AGENTS.md`: + +```md +# AGENTS + +This repository contains ToCode, a Python-only binary exporter. ToCode takes one binary or IDA database path and writes one source-like project directory for reverse-engineering agents. + +## Scope + +- Keep the project focused on the exporter CLI and Python library modules. +- Do not add web UI, server, container wrapper, shortcut, installer, or background service behavior. +- Do not add chat, report generation, or secondary analysis stages. +- Export one project tree from one binary or IDA database: raw decompiler output, assembly, summaries, section data, JSON metadata, optional IDA database, and optional scanner-friendly C. +- Generated export trees must include their own `AGENTS.md` for agents analyzing that exported binary. + +## Project Layout + +- `src/tocode/cli.py`: command-line entry point for `tocode`. +- `src/tocode/analysis.py`: backend-neutral binary inventory and call graph normalization. +- `src/tocode/backends/`: IDA Domain and radare2 session adapters. +- `src/tocode/exporter.py`: project writer, function rendering, worker-session rendering, generated export `AGENTS.md`. +- `src/tocode/metadata.py`: JSON metadata and triage documents. +- `src/tocode/cluster.py`: call-graph clustering. +- `src/tocode/parallel.py`: worker-count selection. +- `src/tocode/schema.py`: dataclasses shared across the exporter. +- `tests/`: unit tests for algorithms, CLI helpers, and export tree generation. + +## Export Contract + +The CLI accepts a regular binary file or IDA database and writes a project directory containing: + +- `src/raw/**/*.c` +- `src/raw/**/*.asm` +- `src/raw/**/*.summary` +- `include/*.h` +- `data/*.bin` +- `data/variables.json` +- `data/variables_interesting.json` +- `function-index.json` +- `functions.json` +- `sections.json` +- `strings.json` +- `imports.json` +- `exports.json` +- `relocations.json` +- `reachable.json` +- `cluster-graph.json` +- `triage.json` +- `project.json` +- `export-manifest.json` +- generated export `AGENTS.md` +- generated export `CLAUDE.md` + +When the IDA backend is used, the export also contains: + +- `.i64` or `.idb` + +When `--tree` is passed, the export also contains: + +- `src/tree/**/*.c` +- `function-index-tree.json` + +## Development + +- Prefer `uv` for local commands. +- Package entry point: `tocode`. +- Keep generated text, environment variables, and user-visible strings branded as ToCode. +- Keep status output concise but informative. Progress bars are handled by `tqdm` through `Progress.bar(...)`. +- Use `apply_patch` for manual edits. +- Do not commit generated export directories such as `here/`, `ls/`, or `*_decompiler/`. + +## Dependencies + +- Runtime dependencies belong in `pyproject.toml` and `uv.lock`. +- IDA Domain is the preferred backend when available. +- radare2/r2pipe is a fallback backend. +- Keep dependencies minimal and tied to exporting a project. + +## Verification + +Run focused checks after changes: + +```bash +uv run --extra dev pytest -q +python3 -m compileall src tests +``` + +Run the full local CI/quality gate when changing shared behavior: + +```bash +./ci-local.sh +``` + +On Windows PowerShell: + +```powershell +powershell -ExecutionPolicy Bypass -File .\ci-local.ps1 +``` + +For backend-sensitive changes, also run a real export when IDA is available: + +```bash +uv run tocode /bin/true -o /tmp/tocode-check --backend auto -j 2 +``` + +Confirm the generated project matches the export contract above. + +``` + +`CLAUDE.md`: + +```md +# CLAUDE + +Use the repository instructions in [AGENTS.md](AGENTS.md). + +``` + +`README.md`: + +```md +# ToCode + +ToCode exports a binary or IDA database into a source-like project tree: raw recovered C, matching assembly, function summaries, section data, optional IDA database, and metadata that coding agents can read directly. + +## Why + +AI models are strong at coding, especially when they can traverse large codebases and accumulate context with subagents and other strategies. When we use these agents to assist with reverse engineering, we usually provide tools through MCP or other means so the coding agent can learn and build strategies around tools such as IDA and r2. This approach adds limitations and constraints to how the agent behaves, and it increases the need for deep, complex reasoning. + +There should be a better way to improve this scenario so that even smaller models can perform well on this kind of work. + +The idea behind ToCode is simple: use a disassembler such as IDA to create a source-code-like project for a given binary, with a pre-built `AGENTS.md` so most coding agents start with precomputed context. ToCode also produces rich `.json` files with important metadata. + +With this approach, even tiny models can perform well without being connected to MCP-like tool calls, because ToCode provides exactly what coding agents are good at working with: code. + +### Export layout + +The exported project contains the following structure: + +```text +sample_decompiler/ + AGENTS.md + CLAUDE.md + src/raw/**/*.c + src/raw/**/*.asm + src/raw/**/*.summary + include/*.h + data/*.bin + data/variables.json + data/variables_interesting.json + function-index.json + functions.json + sections.json + strings.json + imports.json + exports.json + relocations.json + reachable.json + cluster-graph.json + triage.json + project.json + export-manifest.json +``` + +| Path | Description | +| --- | --- | +| `src/raw` | Decompiled C-like output, assembly, and summaries grouped by cluster. | +| `include` | Generated headers for the exported project. | +| `data` | Raw section dumps and variable metadata. | +| `*.json` | Functions, sections, strings, imports, exports, relocations, reachability, clusters, triage, project metadata, and export manifest. | +| `AGENTS.md` / `CLAUDE.md` | Instructions for agents analyzing the exported binary. | +| `src/tree` | Optional scanner-friendly C output when `--tree` is used. | + + +### Supported backends + +Currently, IDA (using the ida-domain/idapro Python libraries) and radare2 are supported. Other disassemblers may be added in the future. + +### Using + +ToCode supports Windows, Linux, and macOS with Python 3.10 or newer. + +On Windows PowerShell: + +```powershell +powershell -ExecutionPolicy Bypass -File .\install.ps1 +``` + +On Linux or macOS: + +```bash +bash ./install.sh +``` + +Manual setup (requires [uv](https://docs.astral.sh/uv/)): + +```bash +git clone https://github.com/buzzer-re/ToCode +cd ToCode +uv sync --locked +uv tool install --force --editable . +``` + +### Example + +``` +tocode firmwareX.bin -o firmwareX_decompiled/ +cd firmwareX_decompiled/ +codex + +# Inside your agent shell, type your goals, e.g.: "Give me a brief overview of the boot process of this firmware." +``` + +#### From an ongoing RE work +``` +tocode firmwareX.bin.i64 -o firmwareX_decompiled/ +... +``` + +## Development + +This tool was built using agentic coding, so if you plan to help, I strongly advise doing the same. + +Before changing ToCode, have Python, uv, ruff, mypy, pytest, and compileall available. For backend work, also have IDA or radare2 installed, depending on what you are touching. + +The main instructions for agents are in `AGENTS.md`. Read it before starting, and make sure the local quality gate passes before proceeding. + +### Quality Gate + +Run the local CI gate before opening a PR: + +```bash +./ci-local.sh +``` + +On Windows PowerShell: + +```powershell +powershell -ExecutionPolicy Bypass -File .\ci-local.ps1 +``` + +``` + +`ci-local.ps1`: + +```ps1 +# Local CI simulation. Mirrors .github/workflows/ci.yml. +# Usage: .\ci-local.ps1 [-Fix] +[CmdletBinding()] +param( + [switch]$Fix +) + +$ErrorActionPreference = "Continue" +$Root = Split-Path -Parent $MyInvocation.MyCommand.Path +Set-Location $Root + +$Pass = 0 +$Fail = 0 +$Results = New-Object System.Collections.Generic.List[string] + +function Add-Pass([string]$Name) { + $script:Pass += 1 + $script:Results.Add("PASS $Name") +} + +function Add-Fail([string]$Name, [string]$Reason) { + $script:Fail += 1 + $script:Results.Add("FAIL ${Name}: $Reason") +} + +function Add-Warn([string]$Message) { + $script:Results.Add("WARN $Message") +} + +function Write-Step([string]$Message) { + Write-Host "> $Message" -ForegroundColor Yellow +} + +function Test-Command([string]$Name) { + $null -ne (Get-Command $Name -ErrorAction SilentlyContinue) +} + +function Invoke-Tool { + param([Parameter(ValueFromRemainingArguments = $true)][string[]]$Args) + if (Test-Command "uv") { + & uv run --locked @Args + } else { + & $Args[0] @($Args | Select-Object -Skip 1) + } +} + +function Invoke-With { + param( + [string[]]$Packages, + [Parameter(ValueFromRemainingArguments = $true)][string[]]$Args + ) + if (Test-Command "uv") { + $uvArgs = @("run", "--locked") + foreach ($Package in $Packages) { + $uvArgs += @("--with", $Package) + } + $uvArgs += $Args + & uv @uvArgs + } else { + & $Args[0] @($Args | Select-Object -Skip 1) + } +} + +Write-Step "Checking CI tools" +if (-not (Test-Command "uv")) { + Add-Warn "uv not found; using current Python environment for tools" +} + +Write-Step "[1/5] Ruff format" +if ($Fix) { + Invoke-With -Packages @("ruff==0.15.13") python -m ruff format src tests +} else { + Invoke-With -Packages @("ruff==0.15.13") python -m ruff format --check src tests +} +if ($LASTEXITCODE -eq 0) { + Add-Pass "ruff format" +} else { + Add-Fail "ruff format" "run .\ci-local.ps1 -Fix" +} + +Write-Step "[2/5] Ruff lint" +if ($Fix) { + Invoke-With -Packages @("ruff==0.15.13") python -m ruff check src tests --fix +} else { + Invoke-With -Packages @("ruff==0.15.13") python -m ruff check src tests +} +if ($LASTEXITCODE -eq 0) { + Add-Pass "ruff lint" +} else { + Add-Fail "ruff lint" "see output above" +} + +Write-Step "[3/5] Mypy" +Invoke-With -Packages @("mypy==2.1.0", "tomli==2.4.1", "types-tqdm==4.67.3.20260518", "pytest==8.4.2") python -m mypy src tests --pretty +if ($LASTEXITCODE -eq 0) { + Add-Pass "mypy" +} else { + Add-Fail "mypy" "see output above" +} + +Write-Step "[4/5] Pytest" +Invoke-Tool --extra dev pytest -q +if ($LASTEXITCODE -eq 0) { + Add-Pass "pytest" +} else { + Add-Fail "pytest" "see output above" +} + +Write-Step "[5/5] Compile Python" +Invoke-Tool python -m compileall src tests +if ($LASTEXITCODE -eq 0) { + Add-Pass "compileall" +} else { + Add-Fail "compileall" "see output above" +} + +Write-Host "" +Write-Host "CI Results" -ForegroundColor White +foreach ($Result in $Results) { + Write-Host " $Result" +} + +if ($Fail -gt 0) { + Write-Host "FAILED - $Fail check(s) failed, $Pass passed" -ForegroundColor Red + exit 1 +} + +Write-Host "ALL REQUIRED CHECKS PASSED - $Pass checks" -ForegroundColor Green + +``` + +`ci-local.sh`: + +```sh +#!/usr/bin/env bash +# Local CI simulation. Mirrors .github/workflows/ci.yml. +# Usage: ./ci-local.sh [--fix] +set -euo pipefail + +FIX=false +if [[ "${1:-}" == "--fix" ]]; then + FIX=true +fi + +ROOT="$(cd "$(dirname "$0")" && pwd)" +cd "$ROOT" + +PASS=0 +FAIL=0 +RESULTS=() + +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +BOLD='\033[1m' +RESET='\033[0m' + +ok() { + PASS=$((PASS + 1)) + RESULTS+=("${GREEN}PASS${RESET} $1") +} + +fail() { + FAIL=$((FAIL + 1)) + RESULTS+=("${RED}FAIL${RESET} $1: $2") +} + +warn() { + RESULTS+=("${YELLOW}WARN${RESET} $1") +} + +info() { + echo -e "${YELLOW}> $1${RESET}" +} + +find_uv() { + if command -v uv >/dev/null 2>&1; then + command -v uv + return 0 + fi + if [[ -n "${USERPROFILE:-}" ]] && command -v cygpath >/dev/null 2>&1; then + local candidate + candidate="$(cygpath -u "$USERPROFILE")/.local/bin/uv.exe" + if [[ -x "$candidate" ]]; then + printf '%s\n' "$candidate" + return 0 + fi + fi + if command -v powershell.exe >/dev/null 2>&1 && command -v cygpath >/dev/null 2>&1; then + local win_candidate + win_candidate="$(powershell.exe -NoProfile -Command "(Get-Command uv -ErrorAction SilentlyContinue).Source" 2>/dev/null | tr -d '\r')" + if [[ -n "$win_candidate" ]]; then + local candidate + candidate="$(cygpath -u "$win_candidate")" + if [[ -x "$candidate" ]]; then + printf '%s\n' "$candidate" + return 0 + fi + fi + fi + if [[ -x "$HOME/.local/bin/uv" ]]; then + printf '%s\n' "$HOME/.local/bin/uv" + return 0 + fi + if [[ -x "$HOME/.local/bin/uv.exe" ]]; then + printf '%s\n' "$HOME/.local/bin/uv.exe" + return 0 + fi + local mounted_candidate + for mounted_candidate in /mnt/c/Users/*/.local/bin/uv.exe /c/Users/*/.local/bin/uv.exe; do + if [[ -x "$mounted_candidate" ]]; then + printf '%s\n' "$mounted_candidate" + return 0 + fi + done + return 1 +} + +find_python() { + if command -v python3 >/dev/null 2>&1; then + command -v python3 + return 0 + fi + if command -v python >/dev/null 2>&1; then + command -v python + return 0 + fi + return 1 +} + +UV_BIN="$(find_uv || true)" +PYTHON_BIN="$(find_python || true)" + +run_tool() { + if [[ -n "$UV_BIN" ]]; then + "$UV_BIN" run --locked "$@" + else + "$PYTHON_BIN" "$@" + fi +} + +run_with() { + local package="$1" + shift + if [[ -n "$UV_BIN" ]]; then + "$UV_BIN" run --locked --with "$package" "$@" + else + while [[ "${1:-}" == "--with" ]]; do + shift 2 + done + if [[ "${1:-}" == "python" ]]; then + shift + "$PYTHON_BIN" "$@" + else + "$@" + fi + fi +} + +info "Checking CI tools" +if [[ -z "$UV_BIN" ]]; then + warn "uv not found; using current Python environment for tools" +fi +if [[ -z "$UV_BIN" && -z "$PYTHON_BIN" ]]; then + fail "tool bootstrap" "neither uv nor Python is available" + echo "No usable Python toolchain found" + exit 1 +fi +if [[ -z "$UV_BIN" ]]; then + info "Bootstrapping Python CI tools with pip" + "$PYTHON_BIN" -m pip install --user --quiet \ + ruff==0.15.13 \ + mypy==2.1.0 \ + tomli==2.4.1 \ + types-tqdm==4.67.3.20260518 \ + pytest==8.4.2 || { + fail "tool bootstrap" "pip install failed" + echo "Could not install CI tools" + exit 1 + } +fi + +info "[1/5] Ruff format" +if "$FIX"; then + if run_with ruff==0.15.13 python -m ruff format src tests; then + ok "ruff format" + else + fail "ruff format" "see output above" + fi +else + if run_with ruff==0.15.13 python -m ruff format --check src tests; then + ok "ruff format" + else + fail "ruff format" "run ./ci-local.sh --fix" + fi +fi + +info "[2/5] Ruff lint" +if "$FIX"; then + if run_with ruff==0.15.13 python -m ruff check src tests --fix; then + ok "ruff lint" + else + fail "ruff lint" "see output above" + fi +else + if run_with ruff==0.15.13 python -m ruff check src tests; then + ok "ruff lint" + else + fail "ruff lint" "see output above" + fi +fi + +info "[3/5] Mypy" +if run_with mypy==2.1.0 --with tomli==2.4.1 --with types-tqdm==4.67.3.20260518 --with pytest==8.4.2 python -m mypy \ + src tests --pretty; then + ok "mypy" +else + fail "mypy" "see output above" +fi + +info "[4/5] Pytest" +if [[ -n "$UV_BIN" ]]; then + TEST_CMD=(--extra dev pytest -q) +else + TEST_CMD=(-m pytest -q) +fi +if run_tool "${TEST_CMD[@]}"; then + ok "pytest" +else + fail "pytest" "see output above" +fi + +info "[5/5] Compile Python" +if [[ -n "$PYTHON_BIN" ]]; then + COMPILE_CMD=("$PYTHON_BIN" -m compileall src tests) +else + COMPILE_CMD=("$UV_BIN" run python -m compileall src tests) +fi +if "${COMPILE_CMD[@]}"; then + ok "compileall" +else + fail "compileall" "see output above" +fi + +echo "" +echo -e "${BOLD}CI Results${RESET}" +for result in "${RESULTS[@]}"; do + echo -e " $result" +done + +if [[ $FAIL -gt 0 ]]; then + echo -e "${RED}${BOLD}FAILED${RESET} - $FAIL check(s) failed, $PASS passed" + exit 1 +fi + +echo -e "${GREEN}${BOLD}ALL REQUIRED CHECKS PASSED${RESET} - $PASS checks" + +``` + +`examples/vuln-toy/README.md`: + +```md +# Vulnerability Toy + +This fixture is for end-to-end scanner verification: + +```bash +gcc -O0 -fno-stack-protector -no-pie -o /tmp/tocode-vuln-toy examples/vuln-toy/vuln_toy.c +strip /tmp/tocode-vuln-toy +uv run tocode /tmp/tocode-vuln-toy -o /tmp/tocode-vuln-toy-export --backend auto -j 2 --tree +semgrep --config examples/vuln-toy/semgrep.yml /tmp/tocode-vuln-toy-export/src/tree +``` + +Semgrep should report the recovered `strcpy(...)` call in `src/tree`. + +``` + +`examples/vuln-toy/semgrep.yml`: + +```yml +rules: + - id: tocode-toy-strcpy + languages: + - c + severity: ERROR + message: Unbounded strcpy on recovered source + pattern: strcpy(...) + +``` + +`examples/vuln-toy/vuln_toy.c`: + +```c +#include +#include + +__attribute__((noinline)) +static int copy_name(const char *input) { + char name[16]; + strcpy(name, input); + return puts(name); +} + +int main(int argc, char **argv) { + if (argc < 2) { + return 1; + } + return copy_name(argv[1]); +} + +``` + +`install.ps1`: + +```ps1 +[CmdletBinding()] +param( + [string]$InstallDir = (Join-Path $HOME "ToCode"), + [string]$Repo = "https://github.com/buzzer-re/ToCode.git", + [string]$Branch = "main", + [switch]$Dev +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Write-Step { + param([string]$Message) + Write-Host "==> $Message" +} + +function Fail { + param([string]$Message) + Write-Error "install.ps1: $Message" + exit 1 +} + +function Test-Command { + param([string]$Name) + return $null -ne (Get-Command $Name -ErrorAction SilentlyContinue) +} + +function Get-PythonCommand { + $candidates = @( + @("py", "-3"), + @("python3"), + @("python") + ) + + foreach ($candidate in $candidates) { + $name = $candidate[0] + if (-not (Test-Command $name)) { + continue + } + + $args = @() + if ($candidate.Count -gt 1) { + $args = $candidate[1..($candidate.Count - 1)] + } + + & $name @args -c "import sys; raise SystemExit(0 if sys.version_info >= (3, 10) else 1)" 2>$null + if ($LASTEXITCODE -eq 0) { + return @{ + Name = $name + Args = $args + } + } + } + + return $null +} + +function Test-PathEntry { + param( + [string]$PathValue, + [string]$Entry + ) + + $parts = $PathValue -split ';' | Where-Object { $_ } + foreach ($part in $parts) { + if ($part.TrimEnd('\') -ieq $Entry.TrimEnd('\')) { + return $true + } + } + return $false +} + +function Add-UserPath { + param([string]$BinDir) + + if (-not $BinDir) { + return + } + + if (-not (Test-Path $BinDir)) { + New-Item -ItemType Directory -Path $BinDir -Force | Out-Null + } + + if (-not (Test-PathEntry -PathValue $env:PATH -Entry $BinDir)) { + $env:PATH = "$BinDir;$env:PATH" + } + + $userPath = [Environment]::GetEnvironmentVariable("Path", "User") + if (-not $userPath) { + $userPath = "" + } + $machinePath = [Environment]::GetEnvironmentVariable("Path", "Machine") + if (-not $machinePath) { + $machinePath = "" + } + + $missingFromPersistentPath = -not (Test-PathEntry -PathValue $userPath -Entry $BinDir) -and -not (Test-PathEntry -PathValue $machinePath -Entry $BinDir) + if ($missingFromPersistentPath) { + $newUserPath = if ($userPath) { "$userPath;$BinDir" } else { $BinDir } + [Environment]::SetEnvironmentVariable("Path", $newUserPath, "User") + Write-Step "Added $BinDir to your user Path" + Write-Host "Open a new PowerShell or cmd session before running tocode outside this installer." + } +} + +if (-not (Test-Command "git")) { + Fail "git is required but was not found on PATH" +} + +$gitDir = Join-Path $InstallDir ".git" +if (Test-Path $gitDir) { + Write-Step "Updating ToCode at $InstallDir" + git -C $InstallDir fetch origin $Branch + git -C $InstallDir checkout $Branch + git -C $InstallDir pull --ff-only origin $Branch +} +elseif (Test-Path $InstallDir) { + Fail "$InstallDir already exists and is not a Git checkout" +} +else { + Write-Step "Cloning ToCode into $InstallDir" + git clone --branch $Branch $Repo $InstallDir +} + +if (Test-Command "uv") { + Write-Step "Syncing local project environment with uv" + if ($Dev) { + uv --directory $InstallDir sync --locked --extra dev + } + else { + uv --directory $InstallDir sync --locked + } + + Write-Step "Installing the tocode command with uv" + uv tool install --force --editable $InstallDir + + if (-not (Test-Command "tocode")) { + $toolBin = (& uv tool dir --bin 2>$null) + if ($LASTEXITCODE -eq 0 -and $toolBin) { + Add-UserPath -BinDir $toolBin + } + } +} +else { + $python = Get-PythonCommand + if ($null -eq $python) { + Fail "Python 3.10 or newer is required when uv is not installed" + } + + Write-Step "Installing the tocode command with pip" + $package = $InstallDir + if ($Dev) { + $package = "$InstallDir[dev]" + } + & $python.Name @($python.Args) -m pip install --user --editable $package + + $userBase = (& $python.Name @($python.Args) -c "import site; print(site.USER_BASE)") + if ($LASTEXITCODE -eq 0 -and $userBase) { + Add-UserPath -BinDir (Join-Path $userBase 'Scripts') + } +} + +if (-not (Test-Command "tocode")) { + Fail "tocode was installed, but its bin directory is not on PATH" +} + +tocode --help | Out-Null + +Write-Step "ToCode is installed" +Write-Host "Run: tocode -o " + +``` + +`install.sh`: + +```sh +#!/usr/bin/env bash +set -euo pipefail + +repo_url="${TOCODE_REPO_URL:-https://github.com/buzzer-re/ToCode.git}" +install_dir="${TOCODE_INSTALL_DIR:-$HOME/ToCode}" +branch="${TOCODE_BRANCH:-main}" +with_dev=false + +usage() { + cat <<'EOF' +Install ToCode on Linux or macOS. + +Usage: + ./install.sh [options] + +Options: + --dir PATH Clone or update ToCode at PATH. Default: $HOME/ToCode + --repo URL Git repository URL. Default: https://github.com/buzzer-re/ToCode.git + --branch NAME Branch to install. Default: main + --dev Also install development extras in the local checkout + -h, --help Show this help +EOF +} + +info() { + printf '==> %s\n' "$1" +} + +die() { + printf 'install.sh: %s\n' "$1" >&2 + exit 1 +} + +find_python() { + if command -v python3 >/dev/null 2>&1; then + command -v python3 + return 0 + fi + if command -v python >/dev/null 2>&1; then + command -v python + return 0 + fi + return 1 +} + +path_contains() { + case ":$PATH:" in + *":$1:"*) return 0 ;; + *) return 1 ;; + esac +} + +shell_rc_file() { + shell_name="$(basename "${SHELL:-}")" + case "$shell_name" in + zsh) printf '%s\n' "$HOME/.zshrc" ;; + bash) printf '%s\n' "$HOME/.bashrc" ;; + ksh) printf '%s\n' "$HOME/.kshrc" ;; + *) printf '%s\n' "$HOME/.profile" ;; + esac +} + +ensure_path() { + bin_dir="$1" + [ -n "$bin_dir" ] || return 0 + mkdir -p "$bin_dir" + + missing_from_path=false + if ! path_contains "$bin_dir"; then + missing_from_path=true + export PATH="$bin_dir:$PATH" + fi + + if [ "$missing_from_path" = false ]; then + return 0 + fi + + rc_file="$(shell_rc_file)" + marker="# ToCode installer: add user Python/uv tools to PATH" + path_line="export PATH=\"$bin_dir:\$PATH\"" + + touch "$rc_file" + if ! grep -Fqs "$path_line" "$rc_file"; then + { + printf '\n%s\n' "$marker" + printf '%s\n' "$path_line" + } >>"$rc_file" + info "Added $bin_dir to $rc_file" + info "Open a new shell session, or run: source $rc_file" + fi +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --dir) + [ "$#" -ge 2 ] || die "--dir requires a path" + install_dir="$2" + shift 2 + ;; + --repo) + [ "$#" -ge 2 ] || die "--repo requires a URL" + repo_url="$2" + shift 2 + ;; + --branch) + [ "$#" -ge 2 ] || die "--branch requires a name" + branch="$2" + shift 2 + ;; + --dev) + with_dev=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + die "unknown option: $1" + ;; + esac +done + +command -v git >/dev/null 2>&1 || die "git is required but was not found on PATH" + +if [ -d "$install_dir/.git" ]; then + info "Updating ToCode at $install_dir" + git -C "$install_dir" fetch origin "$branch" + git -C "$install_dir" checkout "$branch" + git -C "$install_dir" pull --ff-only origin "$branch" +elif [ -e "$install_dir" ]; then + die "$install_dir already exists and is not a Git checkout" +else + info "Cloning ToCode into $install_dir" + git clone --branch "$branch" "$repo_url" "$install_dir" +fi + +if command -v uv >/dev/null 2>&1; then + info "Syncing local project environment with uv" + if [ "$with_dev" = true ]; then + uv --directory "$install_dir" sync --locked --extra dev + else + uv --directory "$install_dir" sync --locked + fi + + info "Installing the tocode command with uv" + uv tool install --force --editable "$install_dir" + + if ! command -v tocode >/dev/null 2>&1; then + tool_bin="$(uv tool dir --bin 2>/dev/null || true)" + ensure_path "$tool_bin" + fi +else + python_bin="$(find_python || true)" + [ -n "$python_bin" ] || die "Python 3.10 or newer is required when uv is not installed" + + "$python_bin" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 10) else 1)' \ + || die "Python 3.10 or newer is required" + + info "Installing the tocode command with pip" + if [ "$with_dev" = true ]; then + "$python_bin" -m pip install --user --editable "${install_dir}[dev]" + else + "$python_bin" -m pip install --user --editable "$install_dir" + fi + + user_bin="$("$python_bin" -c 'import os, site; print(os.path.join(site.USER_BASE, "bin"))')" + ensure_path "$user_bin" +fi + +command -v tocode >/dev/null 2>&1 || die "tocode was installed, but its bin directory is not on PATH" +tocode --help >/dev/null + +info "ToCode is installed" +printf 'Run: tocode -o \n' + +``` + +`pyproject.toml`: + +```toml +[build-system] +requires = ["setuptools==82.0.1"] +build-backend = "setuptools.build_meta" + +[project] +name = "tocode-cli" +version = "0.1.0" +description = "Export compiled binaries as source-like projects for reverse engineering agents." +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "ida-domain==0.5.0", + "idapro==0.0.9", + "r2pipe==1.9.8", + "tqdm==4.67.3", +] +license = "MIT" +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX :: Linux", + "Operating System :: MacOS", + "Topic :: Security", + "Topic :: Software Development :: Disassemblers", +] + +[project.scripts] +tocode = "tocode.cli:main" + +[project.optional-dependencies] +dev = [ + "pytest==8.4.2", +] + +[tool.setuptools] +package-dir = { "" = "src" } + +[tool.setuptools.packages.find] +where = ["src"] + +``` + +`src/tocode/__init__.py`: + +```py +"""ToCode binary exporter.""" + +__all__ = ["__version__"] + +__version__ = "0.1.0" + +``` + +`src/tocode/__main__.py`: + +```py +from .cli import main + +raise SystemExit(main()) + +``` + +`src/tocode/analysis.py`: + +```py +from __future__ import annotations + +from pathlib import Path +import time +from typing import Any + +from .backends.base import BackendRequest, DecompilerSession, choose_backend +from .backends.ida import IdaSession +from .backends.r2 import R2Session +from .cluster import describe_imports +from .progress import Progress +from .schema import ( + BinaryFacts, + ExportEntry, + FlagEntry, + ImportEntry, + ProgramAnalysis, + RelocationEntry, + Routine, + Segment, + StringEntry, + SymbolEntry, +) + + +class BinaryAnalyzer: + def __init__( + self, + binary: Path, + *, + session: DecompilerSession, + progress: Progress | None = None, + ) -> None: + self.binary = Path(binary).resolve() + self.session = session + self.progress = progress or Progress() + self.analysis: ProgramAnalysis | None = None + self.analysis_seconds: float | None = None + self.progress.log(f"Loading {self.binary}") + + @property + def backend_name(self) -> str: + return self.session.backend_name + + @property + def backend_label(self) -> str: + return self.session.backend_label + + @property + def decompiler_label(self) -> str: + return self.session.decompiler_label + + @property + def supports_parallel(self) -> bool: + return self.session.parallel_safe + + def close(self) -> None: + self.session.close() + + def __enter__(self) -> "BinaryAnalyzer": + return self + + def __exit__(self, _exc_type, _exc, _tb) -> None: + self.close() + + def collect(self) -> ProgramAnalysis: + started = time.monotonic() + label = ( + self.session.analysis_command + or f"{self.session.backend_label} auto-analysis" + ) + self.progress.log(f"Analyzing with {label}") + with self.progress.bar(total=15, desc="analyze", unit="step") as bar: + self.session.analyze() + bar.update(1) + info = self.session.info() + bar.update(1) + entries = self.session.entries() + bar.update(1) + segments = [ + self._segment(row) for row in self.session.sections() if row.get("name") + ] + bar.update(1) + imports = self._imports(self.session.imports()) + bar.update(1) + exports = [self._export(row) for row in self.session.exports()] + bar.update(1) + symbols = [self._symbol(row) for row in self.session.symbols()] + bar.update(1) + relocations = [self._relocation(row) for row in self.session.relocations()] + bar.update(1) + strings = [self._string(row) for row in self.session.strings()] + bar.update(1) + flags = [self._flag(row) for row in self.session.flags()] + bar.update(1) + routines = self._routines(self.session.functions(), imports, segments) + bar.update(1) + callees, callers, import_calls = self._call_graph(routines, imports) + bar.update(1) + roots = self._roots(routines, exports, callers, entries) + bar.update(1) + thunks = self._thunks(routines, callees, import_calls) + bar.update(1) + self.session.ensure_decompiler() + bar.update(1) + + for address in thunks: + if address in routines: + routines[address].thunk = True + for address, targets in callees.items(): + if address in routines: + routines[address].outdegree = len(targets) + for address, sources in callers.items(): + if address in routines: + routines[address].indegree = len(sources) + + analysis = ProgramAnalysis( + binary=self._binary_facts(info, entries), + segments=segments, + routines=routines, + imports=imports, + exports=exports, + symbols=symbols, + relocations=relocations, + strings=strings, + flags=flags, + callees=callees, + callers=callers, + import_calls=import_calls, + roots=roots, + thunks=thunks, + ) + self.analysis = analysis + self.analysis_seconds = time.monotonic() - started + self.progress.log( + f"Inventory: functions={len(routines)} imports={len(imports)} " + f"sections={len(segments)} time={self.analysis_seconds:.2f}s" + ) + return analysis + + def disasm(self, address: int) -> str: + return self.session.disasm(address) + + def decompile(self, address: int) -> str: + return self.session.decompile(address) + + def function_summary(self, address: int) -> str: + return self.session.function_summary(address) + + def cluster_description_from_imports(self, members: list[int]) -> str: + names: list[str] = [] + if self.analysis is not None: + for address in members: + names.extend(self.analysis.import_calls.get(address, [])) + return describe_imports(names) + + def prepare_parallel_workers(self) -> None: + prepare = getattr(self.session, "prepare_parallel_workers", None) + if callable(prepare): + prepare() + + def _binary_facts( + self, info: dict[str, Any], entries: list[dict[str, Any]] + ) -> BinaryFacts: + binary = info.get("bin", {}) + tocode = info.get("tocode", {}) + source_path = self.binary + if isinstance(tocode, dict): + raw_path = _first_text(tocode, "input_path") + if raw_path: + candidate = Path(raw_path).expanduser() + if candidate.is_file(): + source_path = candidate.resolve() + return BinaryFacts( + path=source_path, + arch=str(binary.get("arch", "unknown")), + bits=int(binary.get("bits", 0) or 0), + image_base=int(binary.get("baddr", 0) or 0), + os_name=str(binary.get("os", "unknown")), + format_name=str(binary.get("format", "unknown")), + file_type=str(binary.get("class", binary.get("type", "unknown"))), + entrypoints=[ + int(row.get("vaddr", 0)) + for row in entries + if row.get("vaddr") is not None + ], + ) + + def _imports(self, rows: list[dict[str, Any]]) -> dict[int, ImportEntry]: + result: dict[int, ImportEntry] = {} + for row in rows: + address = int(row.get("plt", 0) or 0) + if not address: + continue + result[address] = ImportEntry( + name=str(row.get("name", f"imp_{address:x}")), + address=address, + bind=row.get("bind"), + kind=row.get("type"), + dll=_first_text(row, "dll", "libname", "library", "module", "bind"), + delay=bool(row.get("delay", row.get("is_delay", False))), + ) + return result + + def _routines( + self, + rows: list[dict[str, Any]], + imports: dict[int, ImportEntry], + segments: list[Segment], + ) -> dict[int, Routine]: + result: dict[int, Routine] = {} + for row in rows: + address = int(row.get("addr", row.get("offset", 0)) or 0) + if not address: + continue + name = str(row.get("name", f"sub_{address:x}")) + result[address] = Routine( + address=address, + name=name, + size=int(row.get("size", 0) or 0), + signature=row.get("signature"), + calltype=row.get("calltype"), + noreturn=bool(row.get("noreturn", False)), + stack_size=int(row.get("stackframe", 0) or 0), + locals_count=int(row.get("nlocals", 0) or 0), + args_count=int(row.get("nargs", 0) or 0), + outdegree=int(row.get("outdegree", 0) or 0), + indegree=int(row.get("indegree", 0) or 0), + imported=address in imports + or name.startswith(("sym.imp.", "loc.imp.", "__imp_")), + library=bool(row.get("is_library", False)), + thunk=bool(row.get("is_thunk", False)), + code_kind=str(row.get("source_kind", "app") or "app"), + segment=self._segment_name(address, segments), + ) + return result + + def _call_graph( + self, + routines: dict[int, Routine], + imports: dict[int, ImportEntry], + ) -> tuple[dict[int, list[int]], dict[int, list[int]], dict[int, list[str]]]: + callees: dict[int, list[int]] = {address: [] for address in routines} + callers: dict[int, list[int]] = {address: [] for address in routines} + import_calls: dict[int, list[str]] = {address: [] for address in routines} + for address, routine in routines.items(): + if routine.imported: + continue + targets, imported = self.session.calls_from(address, imports, routines) + callees[address] = targets + import_calls[address] = imported + for target in targets: + callers.setdefault(target, []).append(address) + for values in callers.values(): + values.sort() + return callees, callers, import_calls + + def _roots( + self, + routines: dict[int, Routine], + exports: list[ExportEntry], + callers: dict[int, list[int]], + entries: list[dict[str, Any]], + ) -> list[int]: + roots: list[int] = [] + for row in entries: + address = int(row.get("vaddr", 0) or 0) + if address in routines and address not in roots: + roots.append(address) + for item in exports: + if item.address in routines and item.address not in roots: + roots.append(item.address) + for address, routine in routines.items(): + if ( + not routine.imported + and not callers.get(address) + and address not in roots + ): + roots.append(address) + return roots + + def _thunks( + self, + routines: dict[int, Routine], + callees: dict[int, list[int]], + import_calls: dict[int, list[str]], + ) -> set[int]: + result: set[int] = set() + for address, routine in routines.items(): + if routine.imported: + result.add(address) + continue + if routine.size <= 16: + targets = callees.get(address, []) + if ( + len(targets) == 1 + and routines.get(targets[0]) is not None + and routines[targets[0]].imported + ): + result.add(address) + if not targets and len(import_calls.get(address, [])) == 1: + result.add(address) + return result + + def _segment_name(self, address: int, segments: list[Segment]) -> str | None: + for segment in segments: + end = segment.vaddr + max(segment.vsize, segment.size) + if segment.vaddr <= address < end: + return segment.name + return None + + @staticmethod + def _segment(row: dict[str, Any]) -> Segment: + return Segment( + name=str(row.get("name", "")), + size=int(row.get("size", 0) or 0), + vsize=int(row.get("vsize", 0) or 0), + kind=str(row.get("type", "")), + perms=str(row.get("perm", "----")), + paddr=int(row.get("paddr", 0) or 0), + vaddr=int(row.get("vaddr", 0) or 0), + ) + + @staticmethod + def _export(row: dict[str, Any]) -> ExportEntry: + forwarder_target = _first_text( + row, "forwarder_target", "forwarder", "forwarder_name", "target" + ) + return ExportEntry( + name=str(row.get("name", "")), + address=int(row.get("vaddr", 0) or 0), + bind=row.get("bind"), + kind=row.get("type"), + ordinal=_first_int(row, "ordinal", "ord"), + forwarder=bool(row.get("is_forwarder", False) or forwarder_target), + forwarder_target=forwarder_target, + ) + + @staticmethod + def _symbol(row: dict[str, Any]) -> SymbolEntry: + return SymbolEntry( + name=str(row.get("name", "")), + flag_name=str(row.get("flagname", row.get("name", ""))), + real_name=str(row.get("realname", row.get("name", ""))), + size=int(row.get("size", 0) or 0), + kind=str(row.get("type", "")), + vaddr=int(row.get("vaddr", 0) or 0), + paddr=int(row.get("paddr", 0) or 0), + imported=bool(row.get("is_imported", False)), + ) + + @staticmethod + def _relocation(row: dict[str, Any]) -> RelocationEntry: + return RelocationEntry( + name=str(row.get("name", "")), + kind=str(row.get("type", "")), + vaddr=int(row.get("vaddr", 0) or 0), + paddr=int(row.get("paddr", 0) or 0), + ifunc=bool(row.get("is_ifunc", False)), + ) + + @staticmethod + def _string(row: dict[str, Any]) -> StringEntry: + return StringEntry( + vaddr=int(row.get("vaddr", 0) or 0), + paddr=int(row.get("paddr", 0) or 0), + size=int(row.get("size", 0) or 0), + length=int(row.get("length", 0) or 0), + segment=str(row.get("section", "")), + kind=str(row.get("type", "")), + value=str(row.get("string", "")), + ) + + @staticmethod + def _flag(row: dict[str, Any]) -> FlagEntry: + return FlagEntry( + name=str(row.get("name", "")), + offset=int(row.get("offset", 0) or 0), + size=int(row.get("size", 0) or 0), + real_name=row.get("realname"), + ) + + +def create_analyzer( + binary: Path, + *, + backend: BackendRequest = "auto", + analysis_command: str = "aaa", + progress: Progress | None = None, + idadir: Path | None = None, + ida_domain_path: Path | None = None, +) -> BinaryAnalyzer: + choice = choose_backend( + backend, + input_path=binary, + idadir=idadir, + ida_domain_path=ida_domain_path, + ) + if progress is not None: + progress.log(f"Using {choice.selected.upper()} as backend.") + if choice.selected == "ida": + return BinaryAnalyzer( + binary, + session=IdaSession(binary, idadir=idadir, ida_domain_path=ida_domain_path), + progress=progress, + ) + return BinaryAnalyzer( + binary, + session=R2Session(binary, analysis_command=analysis_command), + progress=progress, + ) + + +def _first_text(row: dict[str, Any], *keys: str) -> str | None: + for key in keys: + value = row.get(key) + if value is None: + continue + text = str(value).strip() + if text: + return text + return None + + +def _first_int(row: dict[str, Any], *keys: str) -> int | None: + for key in keys: + value = row.get(key) + if value is None: + continue + try: + return int(value) + except (TypeError, ValueError): + continue + return None + +``` + +`src/tocode/backends/__init__.py`: + +```py +from .base import BackendChoice, choose_backend + +__all__ = ["BackendChoice", "choose_backend"] + +``` + +`src/tocode/backends/base.py`: + +```py +from __future__ import annotations + +from dataclasses import dataclass +import importlib +import importlib.util +import os +from pathlib import Path +import sys +from typing import Any, Literal, Protocol + +from ..errors import ToCodeError + + +BackendRequest = Literal["auto", "ida", "r2"] +BackendName = Literal["ida", "r2"] +IDA_DATABASE_SUFFIXES = frozenset({".i64", ".idb"}) + + +class DecompilerSession(Protocol): + backend_name: BackendName + backend_label: str + decompiler_label: str + analysis_command: str | None + parallel_safe: bool + + def analyze(self) -> None: ... + + def close(self) -> None: ... + + def info(self) -> dict[str, Any]: ... + + def entries(self) -> list[dict[str, Any]]: ... + + def sections(self) -> list[dict[str, Any]]: ... + + def imports(self) -> list[dict[str, Any]]: ... + + def exports(self) -> list[dict[str, Any]]: ... + + def symbols(self) -> list[dict[str, Any]]: ... + + def relocations(self) -> list[dict[str, Any]]: ... + + def strings(self) -> list[dict[str, Any]]: ... + + def flags(self) -> list[dict[str, Any]]: ... + + def functions(self) -> list[dict[str, Any]]: ... + + def disasm(self, address: int) -> str: ... + + def decompile(self, address: int) -> str: ... + + def function_summary(self, address: int) -> str: ... + + def ensure_decompiler(self) -> None: ... + + def calls_from( + self, address: int, imports: dict[int, Any], functions: dict[int, Any] + ) -> tuple[list[int], list[str]]: ... + + +@dataclass(slots=True) +class IdaProbe: + available: bool + reason: str + idadir: Path | None = None + ida_domain_path: Path | None = None + + +@dataclass(slots=True) +class BackendChoice: + requested: BackendRequest + selected: BackendName + reason: str + + +def choose_backend( + requested: BackendRequest, + *, + input_path: Path | None = None, + idadir: Path | None = None, + ida_domain_path: Path | None = None, +) -> BackendChoice: + if input_path is not None and is_ida_database(input_path): + if requested == "r2": + raise ToCodeError("IDA database input requires the IDA backend") + requested = "ida" + + if requested == "r2": + return BackendChoice(requested, "r2", "selected by CLI") + + probe = probe_ida(idadir=idadir, ida_domain_path=ida_domain_path) + if requested == "ida": + if not probe.available: + raise ToCodeError(f"IDA backend requested but unavailable: {probe.reason}") + return BackendChoice(requested, "ida", probe.reason) + + if probe.available: + return BackendChoice(requested, "ida", probe.reason) + return BackendChoice(requested, "r2", f"IDA unavailable ({probe.reason}); using r2") + + +def is_ida_database(path: Path) -> bool: + return path.suffix.lower() in IDA_DATABASE_SUFFIXES + + +@dataclass(slots=True) +class IdaRuntime: + idapro: Any + ida_domain: Any + idadir: Path | None + ida_domain_path: Path | None + + +def probe_ida( + *, idadir: Path | None = None, ida_domain_path: Path | None = None +) -> IdaProbe: + try: + runtime = bootstrap_ida(idadir=idadir, ida_domain_path=ida_domain_path) + except ToCodeError as exc: + return IdaProbe(False, str(exc)) + if runtime.idadir is not None: + return IdaProbe( + True, + f"ida-domain available via IDADIR={runtime.idadir}", + runtime.idadir, + runtime.ida_domain_path, + ) + return IdaProbe( + True, "ida-domain importable", runtime.idadir, runtime.ida_domain_path + ) + + +def bootstrap_ida( + *, idadir: Path | None = None, ida_domain_path: Path | None = None +) -> IdaRuntime: + resolved_idadir = discover_idadir(idadir) + if resolved_idadir is not None: + os.environ.setdefault("IDADIR", str(resolved_idadir)) + + _add_idapro_wheel(resolved_idadir) + resolved_domain = discover_ida_domain(ida_domain_path) + _add_python_root(resolved_domain) + + try: + idapro = importlib.import_module("idapro") + except ImportError as exc: + raise ToCodeError( + "unable to import idapro; pass --idadir or install IDA Python support" + ) from exc + + try: + ida_domain = importlib.import_module("ida_domain") + except ImportError as exc: + raise ToCodeError( + "unable to import ida_domain; pass --ida-domain-path or install ida-domain" + ) from exc + + return IdaRuntime( + idapro=idapro, + ida_domain=ida_domain, + idadir=resolved_idadir, + ida_domain_path=resolved_domain, + ) + + +def discover_idadir(explicit: Path | None = None) -> Path | None: + candidates: list[Path] = [] + if explicit is not None: + candidates.append(explicit.expanduser()) + env_value = os.environ.get("IDADIR") + if env_value: + candidates.append(Path(env_value).expanduser()) + + home = Path.home() + candidates.extend(sorted(home.glob("ida-pro-*"), reverse=True)) + candidates.extend(sorted(home.glob("IDA*"), reverse=True)) + + for env_name in ("ProgramFiles", "ProgramFiles(x86)"): + install_root = os.environ.get(env_name) + if not install_root: + continue + root = Path(install_root) + if root.is_dir(): + candidates.extend(sorted(root.glob("IDA*"), reverse=True)) + + applications = Path("/Applications") + if applications.is_dir(): + candidates.extend( + sorted(applications.glob("IDA*.app/Contents/MacOS"), reverse=True) + ) + + for candidate in candidates: + resolved = candidate.expanduser().resolve() + if _looks_like_ida(resolved): + return resolved + return None + + +def discover_ida_domain(explicit: Path | None = None) -> Path | None: + candidates: list[Path] = [] + if explicit is not None: + candidates.append(explicit.expanduser()) + env_value = os.environ.get("TOCODE_IDA_DOMAIN_PATH") + if env_value: + candidates.append(Path(env_value).expanduser()) + + home = Path.home() + candidates.extend( + [ + home / "ida-domain", + home / "ida_domain", + home / "src" / "ida-domain", + home / "src" / "ida_domain", + ] + ) + for candidate in candidates: + if not candidate.exists(): + continue + resolved = candidate.resolve() + if (resolved / "ida_domain" / "__init__.py").is_file(): + return resolved + if resolved.name == "ida_domain" and (resolved / "__init__.py").is_file(): + return resolved.parent + return None + + +def _looks_like_ida(path: Path) -> bool: + return ( + (path / "idalib").exists() + or (path / "idalib" / "python").exists() + or (path / "idat64").exists() + or (path / "idat").exists() + ) + + +def _add_idapro_wheel(idadir: Path | None) -> None: + if importlib.util.find_spec("idapro") is not None or idadir is None: + return + wheel_dir = idadir / "idalib" / "python" + if not wheel_dir.is_dir(): + return + for wheel in sorted(wheel_dir.glob("idapro-*.whl"), reverse=True): + _prepend(wheel) + if importlib.util.find_spec("idapro") is not None: + return + + +def _add_python_root(path: Path | None) -> None: + if importlib.util.find_spec("ida_domain") is not None or path is None: + return + _prepend(path) + + +def _prepend(path: Path) -> None: + text = str(path) + if text not in sys.path: + sys.path.insert(0, text) + +``` + +`src/tocode/backends/ida.py`: + +```py +from __future__ import annotations + +from collections import Counter +import hashlib +import os +from pathlib import Path +from typing import Any + +from .base import BackendName, bootstrap_ida, is_ida_database +from ..errors import BackendError + + +_MAX_THUNK_HOPS = 5 + + +def _cache_root() -> Path: + explicit = os.environ.get("TOCODE_IDA_CACHE_DIR", "").strip() + if explicit: + return Path(explicit).expanduser() + xdg = os.environ.get("XDG_CACHE_HOME", "").strip() + base = Path(xdg).expanduser() if xdg else Path.home() / ".cache" + return base / "tocode" / "ida" + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _database_path(binary: Path) -> tuple[Path, bool]: + if is_ida_database(binary): + return binary, False + root = _cache_root() + root.mkdir(parents=True, exist_ok=True) + path = root / f"{_sha256(binary)}.i64" + return path, not path.exists() + + +class IdaSession: + backend_name: BackendName = "ida" + backend_label = "IDA Domain" + decompiler_label = "Hex-Rays" + analysis_command: str | None = None + parallel_safe = True + + def __init__( + self, + binary: Path, + *, + idadir: Path | None = None, + ida_domain_path: Path | None = None, + db_path: Path | None = None, + needs_analysis: bool | None = None, + ) -> None: + self.binary = Path(binary).resolve() + self.idadir = idadir.resolve() if idadir is not None else None + self.ida_domain_path = ( + ida_domain_path.resolve() if ida_domain_path is not None else None + ) + runtime = bootstrap_ida( + idadir=self.idadir, ida_domain_path=self.ida_domain_path + ) + self._Database = runtime.ida_domain.Database + self._Options = runtime.ida_domain.database.IdaCommandOptions + + self._ida_hexrays = self._optional_import("ida_hexrays") + self._ida_loader = __import__("ida_loader") + self._ida_segment = __import__("ida_segment") + self._ida_bytes = __import__("ida_bytes") + self._ida_entry = self._optional_import("ida_entry") + self._ida_fixup = self._optional_import("ida_fixup") + self._ida_auto = self._optional_import("ida_auto") + self._ida_nalt = self._optional_import("ida_nalt") + + if db_path is None: + resolved_db, first_open = _database_path(self.binary) + needs_analysis = first_open + else: + resolved_db = db_path + needs_analysis = bool(needs_analysis) + + self._cache_db = None if is_ida_database(self.binary) else resolved_db + if needs_analysis: + self._opened_for_analysis = True + options = self._Options( + auto_analysis=True, + new_database=True, + output_database=str(resolved_db), + plugin_options="lumina:host=0.0.0.0 -Osecondary_lumina:host=0.0.0.0", + ) + try: + self._db = self._Database.open( + str(self.binary), args=options, save_on_close=True + ) + except Exception as exc: # noqa: BLE001 + raise BackendError( + f"failed to open IDA database for {self.binary}" + ) from exc + self._wait_for_auto_analysis() + else: + self._opened_for_analysis = False + options = self._Options(auto_analysis=False, new_database=False) + try: + self._db = self._Database.open( + str(resolved_db), args=options, save_on_close=False + ) + except Exception as exc: # noqa: BLE001 + raise BackendError( + f"failed to open IDA database at {resolved_db}" + ) from exc + + self._strings_ready = False + self._decompiler_ready = False + self._disasm_cache: dict[int, str] = {} + self._decompile_cache: dict[int, str] = {} + self._summary_cache: dict[int, str] = {} + self._locals_cache: dict[int, list[Any]] = {} + self._imports_cache: list[dict[str, Any]] | None = None + self._relocs_cache: list[dict[str, Any]] | None = None + self._primed: set[int] = set() + + def _optional_import(self, module: str): + try: + return __import__(module) + except ImportError: + return None + + def _wait_for_auto_analysis(self) -> None: + if self._ida_auto is None: + return + wait = getattr(self._ida_auto, "auto_wait", None) + if callable(wait): + try: + wait() + except Exception: # noqa: BLE001 + pass + + def analyze(self) -> None: + if self._strings_ready: + return + try: + from ida_domain.strings import StringListConfig, StringType + + self._db.strings.rebuild( + StringListConfig( + string_types=[StringType.C, StringType.C_16], + min_len=4, + only_ascii_7bit=False, + ) + ) + except Exception: # noqa: BLE001 + try: + self._db.strings.rebuild() + except Exception: # noqa: BLE001 + pass + self._strings_ready = True + + def close(self) -> None: + try: + self._db.close(save=self._opened_for_analysis) + except Exception: # noqa: BLE001 + pass + finally: + self._opened_for_analysis = False + + def database_path(self) -> Path | None: + if self._cache_db is None: + return self.binary if is_ida_database(self.binary) else None + if not self._cache_db.exists() or self._opened_for_analysis: + self._save_and_reopen_database() + return self._cache_db if self._cache_db.exists() else None + + def prepare_parallel_workers(self) -> None: + if self._cache_db is None: + return + if self._cache_db.exists() and not self._opened_for_analysis: + return + self._save_and_reopen_database() + + def _save_and_reopen_database(self) -> None: + if self._cache_db is None: + return + try: + self._db.close(save=True) + except Exception as exc: # noqa: BLE001 + raise BackendError( + f"failed to save IDA database at {self._cache_db}" + ) from exc + self._opened_for_analysis = False + options = self._Options(auto_analysis=False, new_database=False) + try: + self._db = self._Database.open( + str(self._cache_db), args=options, save_on_close=False + ) + except Exception as exc: # noqa: BLE001 + raise BackendError( + f"failed to reopen IDA database at {self._cache_db}" + ) from exc + self._decompiler_ready = False + self._disasm_cache.clear() + self._decompile_cache.clear() + self._summary_cache.clear() + self._locals_cache.clear() + self._primed.clear() + self.ensure_decompiler() + + def worker(self) -> "IdaSession": + if self._cache_db is not None and self._cache_db.exists(): + return IdaSession( + self.binary, + idadir=self.idadir, + ida_domain_path=self.ida_domain_path, + db_path=self._cache_db, + needs_analysis=False, + ) + return IdaSession( + self.binary, idadir=self.idadir, ida_domain_path=self.ida_domain_path + ) + + def ensure_decompiler(self) -> None: + if self._decompiler_ready: + return + if self._ida_hexrays is None: + raise BackendError("Hex-Rays Python bindings are not available") + try: + available = bool(self._ida_hexrays.init_hexrays_plugin()) + except Exception as exc: # noqa: BLE001 + raise BackendError("failed to initialize Hex-Rays") from exc + if not available: + raise BackendError("Hex-Rays is not available in this IDA installation") + self._decompiler_ready = True + + def info(self) -> dict[str, Any]: + return { + "bin": { + "arch": self._db.architecture or "unknown", + "bits": int(self._db.bitness or 0), + "baddr": int(self._db.base_address or 0), + "os": "unknown", + "format": self._db.format or "unknown", + "class": self._db.format or "unknown", + "type": self._db.format or "unknown", + }, + "tocode": { + "input_path": str(self._input_path()), + }, + } + + def entries(self) -> list[dict[str, Any]]: + return [ + { + "ordinal": int(entry.ordinal), + "name": entry.name, + "vaddr": int(entry.address), + } + for entry in self._db.entries + ] + + def sections(self) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for segment in self._db.segments: + name = self._db.segments.get_name(segment) or f"seg_{segment.start_ea:x}" + size = int(self._db.segments.get_size(segment)) + rows.append( + { + "name": name, + "size": size, + "vsize": size, + "type": self._db.segments.get_class(segment) or "unknown", + "perm": self._segment_perms(segment), + "paddr": self._file_offset(segment.start_ea), + "vaddr": int(segment.start_ea), + } + ) + return rows + + def imports(self) -> list[dict[str, Any]]: + if self._imports_cache is not None: + return list(self._imports_cache) + rows: list[dict[str, Any]] = [] + for item in self._db.imports.get_all_imports(): + name = item.name or f"{item.module_name}!#{item.ordinal}" + rows.append( + { + "plt": int(item.address), + "name": name, + "bind": item.module_name, + "dll": item.module_name, + "type": "import", + "delay": bool(getattr(item, "is_delay", False)), + } + ) + self._imports_cache = rows + return list(rows) + + def exports(self) -> list[dict[str, Any]]: + if self._ida_entry is None: + return self._entry_rows() + try: + qty = int(self._ida_entry.get_entry_qty()) + except Exception: # noqa: BLE001 + return self._entry_rows() + rows: list[dict[str, Any]] = [] + for index in range(qty): + try: + ordinal = int(self._ida_entry.get_entry_ordinal(index)) + address = int(self._ida_entry.get_entry(ordinal)) + name = self._ida_entry.get_entry_name(ordinal) or self._db.names.get_at( + address + ) + get_forwarder = getattr(self._ida_entry, "get_entry_forwarder", None) + forwarder = ( + get_forwarder(ordinal) if get_forwarder is not None else None + ) + except Exception: # noqa: BLE001 + continue + rows.append( + { + "ordinal": ordinal, + "name": name or f"export_{ordinal}", + "vaddr": address, + "bind": None, + "type": "export", + "is_forwarder": bool(forwarder), + "forwarder_target": str(forwarder) if forwarder else None, + } + ) + return rows or self._entry_rows() + + def _entry_rows(self) -> list[dict[str, Any]]: + return [ + { + "ordinal": int(entry.ordinal), + "name": entry.name, + "vaddr": int(entry.address), + "bind": None, + "type": "entry", + "is_forwarder": False, + "forwarder_target": None, + } + for entry in self._db.entries + ] + + def symbols(self) -> list[dict[str, Any]]: + import_addrs = {int(item["plt"]) for item in self.imports()} + return [ + { + "name": name, + "flagname": name, + "realname": name, + "size": 0, + "type": "name", + "vaddr": int(address), + "paddr": self._file_offset(address), + "is_imported": int(address) in import_addrs, + } + for address, name in self._db.names + ] + + def relocations(self) -> list[dict[str, Any]]: + if self._relocs_cache is not None: + return list(self._relocs_cache) + rows: list[dict[str, Any]] = [] + if self._ida_fixup is not None: + try: + ea = self._ida_fixup.get_first_fixup_ea() + while ea != self._ida_bytes.BADADDR: + rows.append( + { + "name": self._db.names.get_at(ea) or f"fixup_{ea:x}", + "type": "fixup", + "vaddr": int(ea), + "paddr": self._file_offset(ea), + "is_ifunc": False, + } + ) + ea = self._ida_fixup.get_next_fixup_ea(ea) + except Exception: # noqa: BLE001 + rows = [] + self._relocs_cache = rows + return list(rows) + + def strings(self) -> list[dict[str, Any]]: + self.analyze() + rows: list[dict[str, Any]] = [] + for item in self._db.strings: + rows.append( + { + "vaddr": int(item.address), + "paddr": self._file_offset(item.address), + "size": len(item.contents), + "length": int(item.length), + "section": self._segment_name(item.address), + "type": getattr(item.type, "name", str(item.type)), + "string": self._string_value(item), + } + ) + return rows + + def flags(self) -> list[dict[str, Any]]: + return [ + {"name": name, "offset": int(address), "size": 0, "realname": name} + for address, name in self._db.names + ] + + def functions(self) -> list[dict[str, Any]]: + from ida_domain.functions import FunctionFlags + + rows: list[dict[str, Any]] = [] + for func in self._db.functions: + name = self._db.functions.get_name(func) or f"sub_{func.start_ea:x}" + segment_name = self._segment_name(func.start_ea) + self._prime(func.start_ea) + + flags = self._db.functions.get_flags(func) + is_library = bool(flags & FunctionFlags.LIB) + is_thunk = bool(flags & FunctionFlags.THUNK) + lvars = self._locals(func.start_ea) + args = sum(1 for item in lvars if bool(getattr(item, "is_argument", False))) + locals_count = sum( + 1 + for item in lvars + if not bool(getattr(item, "is_argument", False)) + and not bool(getattr(item, "is_result", False)) + ) + rows.append( + { + "offset": int(func.start_ea), + "name": name, + "size": int(func.end_ea - func.start_ea), + "signature": self._db.functions.get_signature(func) or None, + "calltype": None, + "noreturn": not bool(self._db.functions.does_return(func)), + "stackframe": int(getattr(func, "frsize", 0) or 0), + "nlocals": locals_count, + "nargs": args, + "outdegree": 0, + "indegree": 0, + "is_library": is_library, + "is_thunk": is_thunk, + "source_kind": self._classify( + name, segment_name, is_library=is_library, is_thunk=is_thunk + ), + } + ) + return rows + + def disasm(self, address: int) -> str: + if address not in self._disasm_cache: + func = self._need_function(address) + self._disasm_cache[address] = "\n".join(self._function_disassembly(func)) + return self._disasm_cache[address] + + def decompile(self, address: int) -> str: + if address not in self._decompile_cache: + self.ensure_decompiler() + func = self._need_function(address) + lines = self._function_pseudocode(func) + self._decompile_cache[address] = ( + "\n".join(lines) if isinstance(lines, list) else str(lines) + ) + return self._decompile_cache[address] + + def function_summary(self, address: int) -> str: + if address in self._summary_cache: + return self._summary_cache[address] + func = self._need_function(address) + signature = self._db.functions.get_signature( + func + ) or self._db.functions.get_name(func) + callers = self._db.functions.get_callers(func) + callees = self._db.functions.get_callees(func) + locals_count = Counter( + "args" if bool(getattr(item, "is_argument", False)) else "locals" + for item in self._locals(address) + if not bool(getattr(item, "is_result", False)) + ) + callee_names = [ + self._db.functions.get_name(resolved) or f"sub_{resolved.start_ea:x}" + for resolved in (self._resolve_thunk(item) for item in callees[:8]) + ] + lines = [ + f"signature: {signature or f'sub_{address:x}'}", + f"address: 0x{address:x}", + f"size: {func.end_ea - func.start_ea} bytes", + f"returns: {'no' if not self._db.functions.does_return(func) else 'yes'}", + f"callers: {len(callers)}", + f"callees: {len(callees)}", + f"args: {locals_count['args']}", + f"locals: {locals_count['locals']}", + ] + if callee_names: + lines.append(f"callee_names: {', '.join(callee_names)}") + self._summary_cache[address] = "\n".join(lines) + return self._summary_cache[address] + + def calls_from( + self, address: int, imports, functions + ) -> tuple[list[int], list[str]]: + func = self._db.functions.get_at(address) + if func is None: + return [], [] + edges: set[int] = set() + imported: set[str] = set() + for callee in self._db.functions.get_callees(func): + resolved = self._resolve_thunk(callee) + target = int(resolved.start_ea) + if target in imports: + imported.add(imports[target].name) + elif target in functions and target != address: + edges.add(target) + else: + name = self._db.names.get_at(target) + if name: + imported.add(name) + return sorted(edges), sorted(name for name in imported if name) + + def _resolve_thunk(self, func): + from ida_domain.functions import FunctionFlags + + current = func + seen: set[int] = set() + for _ in range(_MAX_THUNK_HOPS): + if not (self._db.functions.get_flags(current) & FunctionFlags.THUNK): + return current + ea = int(current.start_ea) + if ea in seen: + return current + seen.add(ea) + callees = list(self._db.functions.get_callees(current)) + if len(callees) != 1: + return current + current = callees[0] + return current + + def _need_function(self, address: int): + func = self._db.functions.get_at(address) + if func is None: + raise BackendError(f"IDA could not resolve function at 0x{address:x}") + return func + + def _prime(self, address: int) -> None: + if address in self._primed: + return + if self._ida_hexrays is not None: + try: + self.ensure_decompiler() + self._function_pseudocode(self._need_function(address)) + except Exception: # noqa: BLE001 + pass + self._locals_cache.pop(address, None) + self._summary_cache.pop(address, None) + self._primed.add(address) + + def _locals(self, address: int) -> list[Any]: + if address not in self._locals_cache: + try: + self._locals_cache[address] = list( + self._db.functions.get_local_variables(self._need_function(address)) + ) + except Exception: # noqa: BLE001 + self._locals_cache[address] = [] + return self._locals_cache[address] + + def _classify( + self, name: str, segment_name: str | None, *, is_library: bool, is_thunk: bool + ) -> str: + if is_thunk: + return "thunk" + lowered_name = (name or "").lower() + lowered_segment = (segment_name or "").lower() + if lowered_segment in { + ".init", + ".fini", + ".plt", + ".plt.sec", + ".init_array", + ".fini_array", + }: + return "runtime" + if lowered_name in { + "_init", + "_fini", + "__libc_start_main", + "__gmon_start__", + "frame_dummy", + "register_tm_clones", + "deregister_tm_clones", + "__do_global_dtors_aux", + }: + return "runtime" + if lowered_name.startswith( + ("__libc_csu_", "__scrt_", "_scrt_", "__crt", "_crt", "_global__sub_i_") + ): + return "runtime" + if is_library: + return "library" + return "app" + + def _segment_perms(self, segment) -> str: + perm = int(getattr(segment, "perm", 0) or 0) + return "".join( + [ + "r" if perm & int(self._ida_segment.SEGPERM_READ) else "-", + "w" if perm & int(self._ida_segment.SEGPERM_WRITE) else "-", + "x" if perm & int(self._ida_segment.SEGPERM_EXEC) else "-", + ] + ) + + def _file_offset(self, ea: int) -> int: + try: + value = self._ida_loader.get_fileregion_offset(int(ea)) + except Exception: # noqa: BLE001 + return 0 + return max(int(value), 0) if value is not None else 0 + + def _segment_name(self, ea: int) -> str: + try: + segment = self._db.segments.get_at(int(ea)) + except Exception: # noqa: BLE001 + return "" + if segment is None: + return "" + return self._db.segments.get_name(segment) or "" + + def _string_value(self, item) -> str: + try: + return str(item) + except Exception: # noqa: BLE001 + contents = getattr(item, "contents", b"") + if isinstance(contents, bytes): + return contents.decode("utf-8", errors="replace") + return str(contents) + + def _input_path(self) -> Path: + if self._ida_nalt is None: + return self.binary + try: + path = Path(str(self._ida_nalt.get_input_file_path())).expanduser() + except Exception: # noqa: BLE001 + return self.binary + return path.resolve() if path.is_file() else self.binary + + def _function_disassembly(self, func): + try: + return self._db.functions.get_disassembly(func, remove_tags=True) + except TypeError: + return self._db.functions.get_disassembly(func) + + def _function_pseudocode(self, func): + try: + return self._db.functions.get_pseudocode(func, remove_tags=True) + except TypeError: + return self._db.functions.get_pseudocode(func) + +``` + +`src/tocode/backends/r2.py`: + +```py +from __future__ import annotations + +from pathlib import Path +from typing import Any + +try: + import r2pipe # type: ignore[import-untyped] +except ImportError: # pragma: no cover + r2pipe = None + +from .base import BackendName +from ..errors import BackendError, BackendJsonError + + +class R2Session: + backend_name: BackendName = "r2" + backend_label = "radare2" + decompiler_label = "r2ghidra" + parallel_safe = True + + def __init__(self, binary: Path, *, analysis_command: str = "aaa") -> None: + self.binary = Path(binary).resolve() + self.analysis_command: str | None = analysis_command + if r2pipe is None: + raise BackendError("python package r2pipe is not installed") + try: + self._pipe = r2pipe.open( + str(self.binary), + flags=[ + "-2", + "-e", + "scr.color=0", + "-e", + "scr.interactive=false", + "-e", + "scr.utf8=0", + "-e", + "bin.relocs.apply=true", + ], + ) + except FileNotFoundError as exc: + raise BackendError("radare2 executable r2 was not found in PATH") from exc + self._pdfj: dict[int, dict[str, Any]] = {} + self._pdf: dict[int, str] = {} + self._pdg: dict[int, str] = {} + self.cmd("e asm.comments=false") + self.cmd("e anal.strings=true") + self.cmd("e anal.types.constraint=true") + + def analyze(self) -> None: + self.cmd(self.analysis_command or "aaa") + + def close(self) -> None: + try: + self._pipe.quit() + except Exception: # noqa: BLE001 + pass + + def cmd(self, command: str) -> str: + try: + value = self._pipe.cmd(command) + except BrokenPipeError as exc: + raise BackendError(f"radare2 terminated while running {command!r}") from exc + except Exception as exc: # noqa: BLE001 + raise BackendError(f"radare2 failed while running {command!r}") from exc + return value if isinstance(value, str) else str(value) + + def cmdj(self, command: str): + try: + return self._pipe.cmdj(command) + except ValueError as exc: + raise BackendJsonError( + f"radare2 returned invalid JSON for {command!r}" + ) from exc + except Exception as exc: # noqa: BLE001 + raise BackendError(f"radare2 JSON command failed: {command!r}") from exc + + def ensure_decompiler(self) -> None: + help_text = self.cmd("pdg?") + if "Unknown command" in help_text or "Missing plugin" in help_text: + raise BackendError("r2ghidra is not available to radare2") + if "Cannot find the sleigh home" in help_text: + raise BackendError( + "r2ghidra SLEIGH data is not available; install it with " + "`r2pm -ci r2ghidra-sleigh`" + ) + languages = self.cmd("pdgL").strip() + if not languages: + raise BackendError( + "r2ghidra SLEIGH languages are not available; install them with " + "`r2pm -ci r2ghidra-sleigh`" + ) + + def info(self) -> dict[str, Any]: + return self.cmdj("ij") or {} + + def entries(self) -> list[dict[str, Any]]: + return self.cmdj("iej") or [] + + def sections(self) -> list[dict[str, Any]]: + return self.cmdj("iSj") or [] + + def imports(self) -> list[dict[str, Any]]: + return self.cmdj("iij") or [] + + def exports(self) -> list[dict[str, Any]]: + return self.cmdj("iEj") or [] + + def symbols(self) -> list[dict[str, Any]]: + return self.cmdj("isj") or [] + + def relocations(self) -> list[dict[str, Any]]: + return self.cmdj("irj") or [] + + def strings(self) -> list[dict[str, Any]]: + return self.cmdj("izj") or [] + + def flags(self) -> list[dict[str, Any]]: + return self.cmdj("fj") or [] + + def functions(self) -> list[dict[str, Any]]: + return self.cmdj("aflj") or [] + + def disasm(self, address: int) -> str: + if address not in self._pdf: + self._pdf[address] = self.cmd(f"pdf @ 0x{address:x}") + return self._pdf[address] + + def decompile(self, address: int) -> str: + if address not in self._pdg: + self._pdg[address] = self.cmd(f"pdg @ 0x{address:x}") + return self._pdg[address] + + def function_summary(self, address: int) -> str: + return self.cmd(f"pdsf @ 0x{address:x}") + + def _disasm_json(self, address: int) -> dict[str, Any]: + if address not in self._pdfj: + self._pdfj[address] = self.cmdj(f"pdfj @ 0x{address:x}") or {} + return self._pdfj[address] + + def calls_from( + self, address: int, imports, functions + ) -> tuple[list[int], list[str]]: + body = self._disasm_json(address) + function_addrs = set(functions) + edges: set[int] = set() + imported: set[str] = set() + for op in body.get("ops", []): + op_type = str(op.get("type", "")) + target = op.get("jump") + refs = op.get("refs") or [] + if op_type == "call": + direct = self._direct_call_target(target, refs) + if direct is None: + continue + if direct in function_addrs and direct != address: + edges.add(direct) + else: + imported.add(self._import_name(refs, direct, imports, functions)) + elif op_type in {"jmp", "ujmp", "cjmp"} and isinstance(target, int): + if target in function_addrs and target != address: + edges.add(target) + return sorted(edges), sorted(name for name in imported if name) + + def _direct_call_target( + self, target: Any, refs: list[dict[str, Any]] + ) -> int | None: + if isinstance(target, int): + return target + for ref in refs: + if ref.get("type") == "CALL" and isinstance(ref.get("addr"), int): + return int(ref["addr"]) + return None + + def _import_name(self, refs, target, imports, functions) -> str: + if isinstance(target, int): + if target in imports: + return imports[target].name + routine = functions.get(target) + if routine is not None and routine.imported: + return routine.name + for ref in refs: + if ref.get("type") != "CALL": + continue + ref_addr = ref.get("addr") + if isinstance(ref_addr, int): + if ref_addr in imports: + return imports[ref_addr].name + routine = functions.get(ref_addr) + if routine is not None and routine.imported: + return routine.name + name = ref.get("name") + if isinstance(name, str): + return name + return f"sub_{target:x}" if isinstance(target, int) else "" + +``` + +`src/tocode/cli.py`: + +```py +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path + +from .analysis import create_analyzer +from .errors import ToCodeError +from .exporter import export_binary +from .progress import Progress + + +def parse_jobs(value: str) -> int | None: + text = value.strip().lower() + if text == "auto": + return None + try: + jobs = int(text) + except ValueError as exc: + raise argparse.ArgumentTypeError( + "jobs must be a positive integer or 'auto'" + ) from exc + if jobs < 1: + raise argparse.ArgumentTypeError("jobs must be at least 1") + return jobs + + +def build_parser() -> argparse.ArgumentParser: + default_backend = os.environ.get("TOCODE_BACKEND", "auto").strip().lower() + if default_backend not in {"auto", "ida", "r2"}: + default_backend = "auto" + parser = argparse.ArgumentParser( + prog="tocode", + description="Export a compiled binary into a source-like reverse-engineering project.", + ) + parser.add_argument("binary", type=Path, help="Input binary to export") + parser.add_argument( + "-o", + "--out-dir", + type=Path, + default=None, + help="Output project directory.", + ) + parser.add_argument( + "--backend", + choices=("auto", "ida", "r2"), + default=default_backend, + help="Decompiler backend: prefer IDA when available, otherwise use r2 (default: TOCODE_BACKEND or auto).", + ) + parser.add_argument( + "--idadir", + type=Path, + default=None, + help="Path to the local IDA installation.", + ) + parser.add_argument( + "--ida-domain-path", + type=Path, + default=None, + help="Path to a local ida-domain checkout.", + ) + parser.add_argument( + "--analysis", + default="aaa", + help="radare2 analysis command (default: aaa; r2 backend only).", + ) + + parser.add_argument( + "-j", + "--jobs", + type=parse_jobs, + default=None, + help="Worker sessions for decompilation: positive integer or 'auto' (default: auto).", + ) + parser.add_argument( + "--tree", + action="store_true", + help="Also write tree-sitter/Semgrep friendly source under src/tree.", + ) + parser.add_argument( + "-q", + "--quiet", + action="store_true", + help="Disable status logging.", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + progress = Progress(enabled=not args.quiet) + binary = args.binary.expanduser().resolve() + if not binary.exists(): + parser.error(f"binary not found: {binary}") + args.out_dir = ( + args.out_dir.expanduser().resolve() if args.out_dir is not None else None + ) + try: + if not binary.is_file(): + parser.error(f"input must be a regular file: {binary}") + summary = _run_one(binary, args=args, progress=progress, out_dir=args.out_dir) + except ToCodeError as exc: + print(f"tocode: {exc}", file=sys.stderr) + return 1 + if not args.quiet: + print(f"Project: {summary.root_dir}") + print( + f"Summary: functions={summary.function_count} " + f"clusters={summary.cluster_count} failures={len(summary.failed_functions)}" + ) + return 0 + + +def _run_one( + binary: Path, *, args: argparse.Namespace, progress: Progress, out_dir: Path | None +): + with create_analyzer( + binary, + backend=args.backend, + analysis_command=args.analysis, + progress=progress, + idadir=args.idadir, + ida_domain_path=args.ida_domain_path, + ) as analyzer: + return export_binary( + analyzer, + out_dir=out_dir, + progress=progress, + jobs=args.jobs, + tree=args.tree, + ) + +``` + +`src/tocode/cluster.py`: + +```py +from __future__ import annotations + +from collections import Counter, deque + +from .naming import SHARED_CLUSTER_ID +from .schema import Cluster + + +def describe_imports(names: list[str]) -> str: + if not names: + return "General functions" + return ", ".join(name for name, _count in Counter(names).most_common(3)) + + +def cluster_routines( + *, + addresses: list[int], + roots: list[int], + callees: dict[int, list[int]], + callers: dict[int, list[int]], + thunks: set[int], +) -> list[Cluster]: + ordered = [address for address in addresses if address not in thunks] + allowed = set(ordered) + if not allowed: + return [] + + components = _scc(ordered, allowed, callees, callers) + component_for: dict[int, int] = {} + for index, component in enumerate(components): + for address in component: + component_for[address] = index + + preds: list[set[int]] = [set() for _ in components] + succs: list[set[int]] = [set() for _ in components] + for address in ordered: + source = component_for[address] + for target in callees.get(address, []): + if target not in allowed: + continue + dest = component_for[target] + if dest != source: + succs[source].add(dest) + preds[dest].add(source) + + labels: dict[int, int] = {} + shared_components: set[int] = set() + indegree = [len(items) for items in preds] + ready = deque(index for index, degree in enumerate(indegree) if degree == 0) + while ready: + index = ready.popleft() + upstream = preds[index] + if not upstream: + labels[index] = index + else: + parent_labels = {labels[parent] for parent in upstream} + if len(parent_labels) == 1: + labels[index] = next(iter(parent_labels)) + else: + labels[index] = index + shared_components.add(index) + for successor in succs[index]: + indegree[successor] -= 1 + if indegree[successor] == 0: + ready.append(successor) + + buckets: dict[int, list[int]] = {} + shared_members: list[int] = [] + for index, component in enumerate(components): + if index in shared_components: + shared_members.extend(component) + else: + buckets.setdefault(labels[index], []).extend(component) + + root_set = set(roots) + clusters: list[Cluster] = [] + for label, members in buckets.items(): + member_set = set(members) + root = next( + (candidate for candidate in roots if candidate in member_set), + components[label][0], + ) + clusters.append( + Cluster( + root=root, + label=f"cluster_{root:#x}", + summary="", + members=_preorder(root, member_set, callees), + ) + ) + + if shared_members: + shared_members.sort() + clusters.append( + Cluster( + root=SHARED_CLUSTER_ID, + label="utils", + summary="Shared utility functions", + members=shared_members, + ) + ) + + clusters.sort(key=lambda c: (0 if c.root in root_set else 1, -len(c.members))) + return clusters + + +def _scc( + ordered: list[int], + allowed: set[int], + callees: dict[int, list[int]], + callers: dict[int, list[int]], +) -> list[list[int]]: + visited: set[int] = set() + finished: list[int] = [] + + for start in ordered: + if start in visited: + continue + dfs_stack: list[tuple[int, bool]] = [(start, False)] + while dfs_stack: + node, expanded = dfs_stack.pop() + if expanded: + finished.append(node) + continue + if node in visited: + continue + visited.add(node) + dfs_stack.append((node, True)) + for child in reversed(callees.get(node, [])): + if child in allowed and child not in visited: + dfs_stack.append((child, False)) + + visited.clear() + groups: list[list[int]] = [] + for start in reversed(finished): + if start in visited: + continue + group: list[int] = [] + reverse_stack: list[int] = [start] + visited.add(start) + while reverse_stack: + node = reverse_stack.pop() + group.append(node) + for parent in callers.get(node, []): + if parent in allowed and parent not in visited: + visited.add(parent) + reverse_stack.append(parent) + groups.append(group) + return groups + + +def _preorder(root: int, members: set[int], callees: dict[int, list[int]]) -> list[int]: + output: list[int] = [] + seen: set[int] = set() + stack: list[int] = [root] + while stack: + address = stack.pop() + if address not in members or address in seen: + continue + seen.add(address) + output.append(address) + for child in reversed(callees.get(address, [])): + if child in members and child not in seen: + stack.append(child) + for address in members: + if address not in seen: + output.append(address) + return output + +``` + +`src/tocode/errors.py`: + +```py +from __future__ import annotations + + +class ToCodeError(Exception): + """Base class for user-facing exporter failures.""" + + +class BackendError(ToCodeError): + """Raised when a decompiler backend cannot complete an operation.""" + + +class BackendJsonError(BackendError): + """Raised when a backend command was expected to return JSON and did not.""" + +``` + +`src/tocode/exporter.py`: + +```py +from __future__ import annotations + +import atexit +from contextlib import nullcontext +from concurrent.futures import ProcessPoolExecutor, as_completed +from concurrent.futures.process import BrokenProcessPool +from dataclasses import dataclass, field +import multiprocessing +import os +from pathlib import Path +import re +import shutil +import tempfile +from typing import Any + +from .analysis import BinaryAnalyzer +from .backends.ida import IdaSession +from .backends.r2 import R2Session +from .cluster import cluster_routines +from .metadata import ( + cluster_graph_json, + display_path, + export_variables, + exports_json, + functions_json, + imports_json, + reachable_json, + relocations_json, + sections_json, + strings_json, + triage_json, + write_json, +) +from .naming import ( + SHARED_CLUSTER_ID, + NameBook, + asm_file_name, + build_name_book, + c_file_name, + clean_c_identifier, + clean_path_component, + default_output_name, + normalize_source, + summary_file_name, +) +from .parallel import choose_jobs, describe_jobs +from .progress import Progress +from .schema import ( + Cluster, + ExportSummary, + FunctionFailure, + FunctionRange, + ProgramAnalysis, + RenderedFunction, + Routine, +) + + +MAX_FUNCTIONS_PER_FILE = 50 +FAST_CLUSTER_FUNCTIONS = 500 +TINY_CLUSTER_FUNCTIONS = 2 +TINY_CLUSTER_BYTES = 0x80 +MERGED_CLUSTER_FUNCTIONS = 12 +MERGED_CLUSTER_BYTES = 0x800 +TREE_CALLING_CONVENTION_RX = re.compile( + r"\b__(?:cdecl|fastcall|stdcall|thiscall|usercall|userpurge|noreturn)\b" +) +TREE_SPOILS_RX = re.compile(r"\s*__spoils<[^>]*>") +TREE_SPACE_RX = re.compile(r"[ \t]{2,}") +TREE_REGISTER_RX = re.compile(r"@<([^>]+)>") +TREE_VERSIONED_IMPORT_RX = re.compile( + r"\b([A-Za-z_]\w*)__(?:GLIBC(?:XX)?|CXXABI)_[0-9][A-Za-z0-9_]*\b" +) + +_WORKER_SESSION: Any = None +_WORKER_ANALYSIS: ProgramAnalysis | None = None +_WORKER_NAMES: NameBook | None = None +_TREE_WORKER_ANALYSIS: ProgramAnalysis | None = None +_TREE_WORKER_RENDERED: dict[int, RenderedFunction] | None = None +_TREE_WORKER_RAW_RANGES: dict[int, FunctionRange] | None = None + + +@dataclass(slots=True) +class ExportContext: + analyzer: BinaryAnalyzer + progress: Progress + out_dir: Path | None + jobs: int | None + tree_enabled: bool + analysis: ProgramAnalysis | None = None + root: Path | None = None + raw_dir: Path | None = None + tree_dir: Path | None = None + include_dir: Path | None = None + data_dir: Path | None = None + header_name: str = "" + header_path: Path | None = None + names: NameBook | None = None + clusters: list[Cluster] = field(default_factory=list) + addresses: list[int] = field(default_factory=list) + rendered: dict[int, RenderedFunction] = field(default_factory=dict) + prototypes: dict[int, str] = field(default_factory=dict) + failures: list[FunctionFailure] = field(default_factory=list) + raw_ranges: list[FunctionRange] = field(default_factory=list) + tree_ranges: list[FunctionRange] = field(default_factory=list) + raw_sources: list[Path] = field(default_factory=list) + tree_sources: list[Path] = field(default_factory=list) + asm_files: list[Path] = field(default_factory=list) + summary_files: list[Path] = field(default_factory=list) + function_index: Path | None = None + tree_index: Path | None = None + manifest: Path | None = None + ida_database: Path | None = None + worker_count: int = 1 + requested_jobs: int | None = None + render_mode: str = "single" + data_variable_count: int = 0 + + +@dataclass(frozen=True, slots=True) +class WorkerSpec: + backend: str + binary: Path + analysis_command: str | None + idadir: Path | None = None + ida_domain_path: Path | None = None + db_path: Path | None = None + + +@dataclass(frozen=True, slots=True) +class TreeBuildJob: + index: int + cluster: Cluster + tree_path: Path + raw_path: Path + include_path: str + + +def export_binary( + analyzer: BinaryAnalyzer, + *, + out_dir: Path | None = None, + progress: Progress | None = None, + jobs: int | None = None, + tree: bool = False, +) -> ExportSummary: + progress = progress or analyzer.progress + context = ExportContext( + analyzer=analyzer, + progress=progress, + out_dir=out_dir, + jobs=jobs, + tree_enabled=tree, + ) + _prepare_tree(context) + _cluster(context) + _render(context) + _write_raw(context) + if tree: + _write_tree(context) + _write_metadata(context) + return _summary(context) + + +def _prepare_tree(context: ExportContext) -> None: + analysis = context.analyzer.analysis or context.analyzer.collect() + root = _root_dir(context.analyzer.binary, context.out_dir) + raw_dir = root / "src" / "raw" + tree_dir = root / "src" / "tree" + include_dir = root / "include" + data_dir = root / "data" + raw_dir.mkdir(parents=True, exist_ok=True) + if context.tree_enabled: + tree_dir.mkdir(parents=True, exist_ok=True) + include_dir.mkdir(parents=True, exist_ok=True) + data_dir.mkdir(parents=True, exist_ok=True) + + context.analysis = analysis + context.root = root + context.raw_dir = raw_dir + context.tree_dir = tree_dir if context.tree_enabled else None + context.include_dir = include_dir + context.data_dir = data_dir + context.header_name = f"{clean_path_component(analysis.binary.path.stem)}.h" + context.header_path = include_dir / context.header_name + context.names = build_name_book(analysis) + + +def _cluster(context: ExportContext) -> None: + analysis = _need(context.analysis) + src_dir = _need(context.root) / "src" + clusters = _build_clusters(analysis, context.analyzer) + context.clusters = [cluster for cluster in clusters if cluster.members] + context.addresses = [ + address for cluster in context.clusters for address in cluster.members + ] + expected: list[Path] = [] + for cluster in context.clusters: + expected.extend(_expected_paths(_need(context.raw_dir), cluster)) + if context.tree_enabled: + expected.append( + _cluster_path(_need(context.tree_dir), cluster, c_file_name(cluster)) + ) + _remove_stale_sources(src_dir, expected) + + +def _render(context: ExportContext) -> None: + analysis = _need(context.analysis) + names = _need(context.names) + count = len(context.addresses) + if context.analyzer.supports_parallel: + context.requested_jobs = context.jobs + context.worker_count = choose_jobs( + function_count=count, + analysis_seconds=context.analyzer.analysis_seconds, + requested=context.jobs, + backend=context.analyzer.backend_name, + ) + context.render_mode = "process" if context.worker_count > 1 else "single" + context.progress.log( + describe_jobs( + function_count=count, + analysis_seconds=context.analyzer.analysis_seconds, + requested=context.jobs, + selected=context.worker_count, + backend=context.analyzer.backend_name, + ) + ) + else: + context.worker_count = 1 + context.render_mode = "single" + context.progress.log( + f"Rendering with one {context.analyzer.backend_label} session" + ) + + context.progress.log( + f"Rendering {count} functions in {len(context.clusters)} clusters with {context.analyzer.decompiler_label}" + ) + context.rendered = render_functions( + analyzer=context.analyzer, + analysis=analysis, + addresses=context.addresses, + names=names, + progress=context.progress, + worker_count=context.worker_count, + ) + + +def _write_raw(context: ExportContext) -> None: + context.progress.log("Writing raw source, assembly, and summaries") + written = write_source_tree( + analysis=_need(context.analysis), + clusters=context.clusters, + src_dir=_need(context.raw_dir), + asm_dir=_need(context.raw_dir), + summary_dir=_need(context.raw_dir), + include_dir=_need(context.include_dir), + header_name=context.header_name, + rendered=context.rendered, + prototypes=context.prototypes, + write_support=True, + progress=context.progress, + ) + context.raw_sources = written["sources"] + context.asm_files = written["asm"] + context.summary_files = written["summaries"] + context.failures = written["failures"] + context.raw_ranges = written["ranges"] + + +def _write_tree(context: ExportContext) -> None: + context.progress.log("Writing tree-sitter friendly source") + worker_count = choose_jobs( + function_count=len(context.clusters), + analysis_seconds=0.0, + requested=context.jobs, + backend="tree", + ) + if worker_count > 1: + context.progress.log( + f"Opening {worker_count} tree workers for {len(context.clusters)} clusters" + ) + written = write_tree_sources( + analysis=_need(context.analysis), + clusters=context.clusters, + src_dir=_need(context.tree_dir), + raw_dir=_need(context.raw_dir), + include_dir=_need(context.include_dir), + rendered=context.rendered, + raw_ranges=context.raw_ranges, + progress=context.progress, + worker_count=worker_count, + ) + context.tree_sources = written["sources"] + context.tree_ranges = written["ranges"] + + +def write_source_tree( + *, + analysis: ProgramAnalysis, + clusters: list[Cluster], + src_dir: Path, + asm_dir: Path, + summary_dir: Path, + include_dir: Path, + header_name: str, + rendered: dict[int, RenderedFunction], + prototypes: dict[int, str], + write_support: bool, + progress: Progress | None = None, +) -> dict[str, Any]: + sources: list[Path] = [] + asm_files: list[Path] = [] + summaries: list[Path] = [] + failures: list[FunctionFailure] = [] + ranges: list[FunctionRange] = [] + + bar_context = ( + progress.bar(total=len(clusters), desc="writing raw", unit="cluster") + if progress + else nullcontext() + ) + with bar_context as bar: + for cluster in clusters: + c_path = _cluster_path(src_dir, cluster, c_file_name(cluster)) + asm_path = _cluster_path(asm_dir, cluster, asm_file_name(cluster)) + summary_path = _cluster_path( + summary_dir, cluster, summary_file_name(cluster) + ) + c_path.parent.mkdir(parents=True, exist_ok=True) + asm_path.parent.mkdir(parents=True, exist_ok=True) + summary_path.parent.mkdir(parents=True, exist_ok=True) + include_path = Path( + os.path.relpath(include_dir / header_name, c_path.parent) + ).as_posix() + block = build_cluster_files( + analysis=analysis, + cluster=cluster, + header_include=include_path, + c_path=c_path, + asm_path=asm_path, + summary_path=summary_path, + rendered=rendered, + prototypes=prototypes, + ) + c_path.write_text(block["c"], encoding="utf-8") + sources.append(c_path.resolve()) + ranges.extend(block["ranges"]) + if write_support: + asm_path.write_text(block["asm"], encoding="utf-8") + summary_path.write_text(block["summary"], encoding="utf-8") + asm_files.append(asm_path.resolve()) + summaries.append(summary_path.resolve()) + failures.extend(block["failures"]) + if bar is not None: + bar.update(1) + + return { + "sources": sources, + "asm": asm_files, + "summaries": summaries, + "failures": failures, + "ranges": ranges, + } + + +def write_tree_sources( + *, + analysis: ProgramAnalysis, + clusters: list[Cluster], + src_dir: Path, + raw_dir: Path, + include_dir: Path, + rendered: dict[int, RenderedFunction], + raw_ranges: list[FunctionRange], + progress: Progress | None = None, + worker_count: int = 1, +) -> dict[str, Any]: + sources: list[Path] = [] + ranges: list[FunctionRange] = [] + raw_range_by_address = {item.address: item for item in raw_ranges} + tree_header = include_dir / "tocode_tree.h" + tree_header.write_text(build_tree_header(analysis), encoding="utf-8") + jobs = build_tree_jobs( + clusters=clusters, + src_dir=src_dir, + raw_dir=raw_dir, + tree_header=tree_header, + ) + + worker_count = max(1, min(worker_count, len(jobs) or 1)) + if worker_count > 1: + try: + return write_tree_sources_parallel( + analysis=analysis, + jobs=jobs, + rendered=rendered, + raw_ranges=raw_range_by_address, + progress=progress, + worker_count=worker_count, + ) + except Exception as exc: # noqa: BLE001 + if progress: + progress.log( + f"Warning: tree writer workers failed ({exc}); retrying in-process" + ) + + bar_context = ( + progress.bar(total=len(jobs), desc="writing tree", unit="cluster") + if progress + else nullcontext() + ) + with bar_context as bar: + for job in jobs: + block = build_tree_cluster_file( + analysis=analysis, + cluster=job.cluster, + include_path=job.include_path, + tree_path=job.tree_path, + raw_path=job.raw_path, + rendered=rendered, + raw_ranges=raw_range_by_address, + ) + job.tree_path.write_text(block["c"], encoding="utf-8") + sources.append(job.tree_path.resolve()) + ranges.extend(block["ranges"]) + if bar is not None: + bar.update(1) + + return {"sources": sources, "ranges": ranges} + + +def build_tree_jobs( + *, + clusters: list[Cluster], + src_dir: Path, + raw_dir: Path, + tree_header: Path, +) -> list[TreeBuildJob]: + jobs: list[TreeBuildJob] = [] + for index, cluster in enumerate(clusters): + tree_path = _cluster_path(src_dir, cluster, c_file_name(cluster)) + raw_path = _cluster_path(raw_dir, cluster, c_file_name(cluster)) + tree_path.parent.mkdir(parents=True, exist_ok=True) + include_path = Path(os.path.relpath(tree_header, tree_path.parent)).as_posix() + jobs.append( + TreeBuildJob( + index=index, + cluster=cluster, + tree_path=tree_path, + raw_path=raw_path, + include_path=include_path, + ) + ) + return jobs + + +def write_tree_sources_parallel( + *, + analysis: ProgramAnalysis, + jobs: list[TreeBuildJob], + rendered: dict[int, RenderedFunction], + raw_ranges: dict[int, FunctionRange], + progress: Progress | None, + worker_count: int, +) -> dict[str, Any]: + results: dict[int, tuple[Path, str, list[FunctionRange]]] = {} + ctx = multiprocessing.get_context("spawn") + bar_context = ( + progress.bar(total=len(jobs), desc="writing tree", unit="cluster") + if progress + else nullcontext() + ) + with bar_context as bar: + with ProcessPoolExecutor( + max_workers=worker_count, + mp_context=ctx, + initializer=_init_tree_worker, + initargs=(analysis, rendered, raw_ranges), + ) as executor: + futures = { + executor.submit(_build_tree_in_worker, job): job.index for job in jobs + } + for future in as_completed(futures): + index, tree_path, c_text, cluster_ranges = future.result() + results[index] = (tree_path, c_text, cluster_ranges) + if bar is not None: + bar.update(1) + + sources: list[Path] = [] + ranges: list[FunctionRange] = [] + for index in sorted(results): + tree_path, c_text, cluster_ranges = results[index] + tree_path.write_text(c_text, encoding="utf-8") + sources.append(tree_path.resolve()) + ranges.extend(cluster_ranges) + return {"sources": sources, "ranges": ranges} + + +def _init_tree_worker( + analysis: ProgramAnalysis, + rendered: dict[int, RenderedFunction], + raw_ranges: dict[int, FunctionRange], +) -> None: + global _TREE_WORKER_ANALYSIS, _TREE_WORKER_RENDERED, _TREE_WORKER_RAW_RANGES + _TREE_WORKER_ANALYSIS = analysis + _TREE_WORKER_RENDERED = rendered + _TREE_WORKER_RAW_RANGES = raw_ranges + + +def _build_tree_in_worker( + job: TreeBuildJob, +) -> tuple[int, Path, str, list[FunctionRange]]: + if ( + _TREE_WORKER_ANALYSIS is None + or _TREE_WORKER_RENDERED is None + or _TREE_WORKER_RAW_RANGES is None + ): + raise RuntimeError("tree writer worker was not initialized") + block = build_tree_cluster_file( + analysis=_TREE_WORKER_ANALYSIS, + cluster=job.cluster, + include_path=job.include_path, + tree_path=job.tree_path, + raw_path=job.raw_path, + rendered=_TREE_WORKER_RENDERED, + raw_ranges=_TREE_WORKER_RAW_RANGES, + ) + return job.index, job.tree_path, block["c"], block["ranges"] + + +def build_tree_cluster_file( + *, + analysis: ProgramAnalysis, + cluster: Cluster, + include_path: str, + tree_path: Path, + raw_path: Path, + rendered: dict[int, RenderedFunction], + raw_ranges: dict[int, FunctionRange], +) -> dict[str, Any]: + c_parts = [_tree_preamble(cluster, include_path)] + c_line = next_line(c_parts[0]) + ranges: list[FunctionRange] = [] + tree_resolved = tree_path.resolve() + raw_resolved = raw_path.resolve() + + for address in cluster.members: + routine = analysis.routines[address] + item = rendered[address] + tree_body = tree_safe_function(item.c_text, fallback_name=item.c_name) + metadata = _tree_function_metadata(routine=routine, raw_path=raw_path) + body = metadata + tree_body + start = c_line + end = start + line_count(body) - 1 + raw_range = raw_ranges.get(address) + c_parts.append(body.rstrip() + "\n\n") + ranges.append( + FunctionRange( + address=address, + name=routine.name, + c_file=tree_resolved, + c_line_start=start, + c_line_end=end, + asm_file=raw_range.asm_file + if raw_range is not None + else raw_resolved.with_suffix(".asm"), + asm_line_start=raw_range.asm_line_start if raw_range is not None else 1, + asm_line_end=raw_range.asm_line_end if raw_range is not None else 1, + ) + ) + c_line = end + 2 + + return {"c": "".join(c_parts), "ranges": ranges} + + +def build_cluster_files( + *, + analysis: ProgramAnalysis, + cluster: Cluster, + header_include: str, + c_path: Path, + asm_path: Path, + summary_path: Path, + rendered: dict[int, RenderedFunction], + prototypes: dict[int, str], +) -> dict[str, Any]: + c_parts = [_c_preamble(cluster, header_include)] + asm_parts = [_asm_preamble(cluster)] + summary_parts = [_summary_preamble(cluster)] + c_line = next_line(c_parts[0]) + asm_line = next_line(asm_parts[0]) + ranges: list[FunctionRange] = [] + failures: list[FunctionFailure] = [] + c_resolved = c_path.resolve() + asm_resolved = asm_path.resolve() + + for address in cluster.members: + routine = analysis.routines[address] + item = rendered[address] + prototypes[address] = item.prototype + if item.failure is not None: + failures.append(item.failure) + asm_start = asm_line + asm_end = asm_start + line_count(item.asm_text) - 1 + metadata = _function_metadata( + routine=routine, + rendered=item, + c_path=c_path, + asm_path=asm_path, + c_start=c_line, + c_end=c_line, + asm_start=asm_start, + asm_end=asm_end, + ) + body = metadata + item.c_text + c_start = c_line + c_end = c_start + line_count(body) - 1 + metadata = _function_metadata( + routine=routine, + rendered=item, + c_path=c_path, + asm_path=asm_path, + c_start=c_start, + c_end=c_end, + asm_start=asm_start, + asm_end=asm_end, + ) + body = metadata + item.c_text + c_parts.append(body.rstrip() + "\n\n") + asm_parts.append(item.asm_text.rstrip() + "\n\n") + summary_parts.append( + _summary_function(routine, summary_path, item.summary_text).rstrip() + + "\n\n" + ) + ranges.append( + FunctionRange( + address=address, + name=routine.name, + c_file=c_resolved, + c_line_start=c_start, + c_line_end=c_end, + asm_file=asm_resolved, + asm_line_start=asm_start, + asm_line_end=asm_end, + ) + ) + c_line = c_end + 2 + asm_line = asm_end + 2 + + return { + "c": "".join(c_parts), + "asm": "".join(asm_parts), + "summary": "".join(summary_parts), + "ranges": ranges, + "failures": failures, + } + + +def render_functions( + *, + analyzer: BinaryAnalyzer, + analysis: ProgramAnalysis, + addresses: list[int], + names: NameBook, + progress: Progress, + worker_count: int, +) -> dict[int, RenderedFunction]: + if worker_count <= 1: + return _render_serial(analyzer, analysis, addresses, names, progress) + try: + return _render_parallel( + analyzer, analysis, addresses, names, progress, worker_count + ) + except Exception as exc: # noqa: BLE001 + progress.log( + f"Warning: parallel export failed ({exc}); retrying with the primary session" + ) + return _render_serial(analyzer, analysis, addresses, names, progress) + + +def _render_serial( + analyzer: BinaryAnalyzer, + analysis: ProgramAnalysis, + addresses: list[int], + names: NameBook, + progress: Progress, +) -> dict[int, RenderedFunction]: + output: dict[int, RenderedFunction] = {} + with progress.bar(total=len(addresses), desc="exporting", unit="func") as bar: + for address in addresses: + output[address] = render_one( + analyzer, analysis, analysis.routines[address], names + ) + bar.update(1) + return output + + +def _render_parallel( + analyzer: BinaryAnalyzer, + analysis: ProgramAnalysis, + addresses: list[int], + names: NameBook, + progress: Progress, + worker_count: int, +) -> dict[int, RenderedFunction]: + progress.log(f"Opening {worker_count} workers for {len(addresses)} functions") + analyzer.prepare_parallel_workers() + spec = _worker_spec(analyzer) + output: dict[int, RenderedFunction] = {} + pending = set(addresses) + with progress.bar(total=len(addresses), desc="exporting", unit="func") as bar: + try: + ctx = multiprocessing.get_context("spawn") + with ProcessPoolExecutor( + max_workers=worker_count, + mp_context=ctx, + initializer=_init_worker, + initargs=(spec, analysis, names), + ) as executor: + futures = { + executor.submit(_render_in_worker, address): address + for address in addresses + } + for future in as_completed(futures): + address = futures[future] + result_address, result = future.result() + output[result_address] = result + pending.discard(address) + bar.update(1) + except BrokenProcessPool as exc: + progress.log( + f"Warning: worker process exited unexpectedly ({exc}); retrying remaining functions" + ) + for address in sorted(pending, key=addresses.index): + output[address] = _render_isolated(spec, analysis, address, names) + bar.update(1) + return output + + +def _worker_spec(analyzer: BinaryAnalyzer) -> WorkerSpec: + session = analyzer.session + return WorkerSpec( + backend=analyzer.backend_name, + binary=analyzer.binary, + analysis_command=getattr(session, "analysis_command", None), + idadir=getattr(session, "idadir", None), + ida_domain_path=getattr(session, "ida_domain_path", None), + db_path=_session_database_path(session), + ) + + +def _session_database_path(session: object) -> Path | None: + database_path = getattr(session, "database_path", None) + if callable(database_path): + return database_path() + return getattr(session, "_cache_db", None) + + +def _init_worker(spec: WorkerSpec, analysis: ProgramAnalysis, names: NameBook) -> None: + global _WORKER_SESSION, _WORKER_ANALYSIS, _WORKER_NAMES + _WORKER_SESSION = _open_worker(spec) + _WORKER_ANALYSIS = analysis + _WORKER_NAMES = names + atexit.register(_close_worker) + + +def _open_worker(spec: WorkerSpec): + session: Any + if spec.backend == "ida": + worker_db = ( + _copy_worker_database(spec.db_path) if spec.db_path is not None else None + ) + try: + session = IdaSession( + spec.binary, + idadir=spec.idadir, + ida_domain_path=spec.ida_domain_path, + db_path=worker_db, + needs_analysis=False if worker_db is not None else None, + ) + except Exception: + if worker_db is not None: + worker_db.unlink(missing_ok=True) + raise + if worker_db is not None: + setattr(session, "_tocode_worker_db_copy", worker_db) + elif spec.backend == "r2": + session = R2Session( + spec.binary, analysis_command=spec.analysis_command or "aaa" + ) + session.analyze() + else: + raise RuntimeError(f"unsupported backend for worker: {spec.backend}") + session.ensure_decompiler() + return session + + +def _copy_worker_database(db_path: Path) -> Path: + fd, name = tempfile.mkstemp(prefix="tocode-ida-worker-", suffix=db_path.suffix) + os.close(fd) + target = Path(name) + shutil.copy2(db_path, target) + return target + + +def _close_worker() -> None: + global _WORKER_SESSION + if _WORKER_SESSION is not None: + worker_db = getattr(_WORKER_SESSION, "_tocode_worker_db_copy", None) + try: + _WORKER_SESSION.close() + finally: + if worker_db is not None: + Path(worker_db).unlink(missing_ok=True) + _WORKER_SESSION = None + + +def _render_in_worker(address: int) -> tuple[int, RenderedFunction]: + if _WORKER_SESSION is None or _WORKER_ANALYSIS is None or _WORKER_NAMES is None: + raise RuntimeError("render worker was not initialized") + routine = _WORKER_ANALYSIS.routines[address] + return address, render_one( + _WORKER_SESSION, _WORKER_ANALYSIS, routine, _WORKER_NAMES + ) + + +def _render_isolated( + spec: WorkerSpec, + analysis: ProgramAnalysis, + address: int, + names: NameBook, +) -> RenderedFunction: + try: + ctx = multiprocessing.get_context("spawn") + with ProcessPoolExecutor( + max_workers=1, + mp_context=ctx, + initializer=_init_worker, + initargs=(spec, analysis, names), + ) as executor: + _address, result = executor.submit(_render_in_worker, address).result() + return result + except Exception as exc: # noqa: BLE001 + return _failure_stub(analysis, address, names, exc) + + +def render_one( + session_like, analysis: ProgramAnalysis, routine: Routine, names: NameBook +) -> RenderedFunction: + try: + disasm = session_like.disasm(routine.address).rstrip() + summary = session_like.function_summary(routine.address).rstrip() + if routine.thunk: + prototype = fallback_prototype(routine, analysis.binary.pointer_size, names) + c_text = asm_stub(routine, prototype) + else: + source = normalize_source(session_like.decompile(routine.address), names) + if not source: + raise RuntimeError("decompiler returned empty output") + prototype = extract_prototype(source) or fallback_prototype( + routine, analysis.binary.pointer_size, names + ) + c_text = annotate_source(source, routine) + failure = None + except Exception as exc: # noqa: BLE001 + prototype = fallback_prototype(routine, analysis.binary.pointer_size, names) + c_text = failure_stub(routine, prototype) + disasm = "" + summary = "" + failure = FunctionFailure(routine.address, routine.name, str(exc)) + return RenderedFunction( + address=routine.address, + c_name=extract_name(prototype) or clean_c_identifier(routine.name), + prototype=prototype, + c_text=c_text, + asm_text=asm_function(routine, disasm), + summary_text=summary, + failure=failure, + ) + + +def _failure_stub( + analysis: ProgramAnalysis, + address: int, + names: NameBook, + exc: BaseException, +) -> RenderedFunction: + routine = analysis.routines[address] + prototype = fallback_prototype(routine, analysis.binary.pointer_size, names) + return RenderedFunction( + address=address, + c_name=extract_name(prototype) or clean_c_identifier(routine.name), + prototype=prototype, + c_text=failure_stub(routine, prototype), + asm_text=asm_function(routine, ""), + summary_text="", + failure=FunctionFailure(address, routine.name, str(exc)), + ) + + +def fallback_prototype(routine: Routine, pointer_size: int, names: NameBook) -> str: + signature = (routine.signature or "").strip().rstrip(";") + function_name = names.function_name(routine.address, routine.name) + parsed = ( + parse_signature(signature, fallback_name=function_name) if signature else None + ) + if parsed is not None: + return parsed + default = "undefined8" if pointer_size >= 8 else "undefined4" + return f"{default} {function_name}()" + + +def import_prototype(name: str, pointer_size: int) -> str: + default = "undefined8" if pointer_size >= 8 else "undefined4" + return f"{default} {clean_c_identifier(name)}()" + + +def parse_signature(signature: str, *, fallback_name: str | None = None) -> str | None: + before, sep, after = signature.partition("(") + if not sep: + return None + before = before.rstrip() + if not before: + return None + name_start = len(before) + while name_start > 0 and before[name_start - 1] not in {" ", "\t"}: + name_start -= 1 + ret = before[:name_start].rstrip() + raw_name = before[name_start:].strip() + if not raw_name: + return None + pointer_prefix = "*" * (len(raw_name) - len(raw_name.lstrip("*"))) + name = clean_c_identifier(raw_name.lstrip("*")) + params = after.rsplit(")", 1)[0].strip() + if not ret and fallback_name is not None: + ret = clean_c_identifier(raw_name) + name = clean_c_identifier(fallback_name) + else: + ret = (ret + " " + pointer_prefix).strip() if ret else "undefined8" + return f"{ret} {name}({params})" if params else f"{ret} {name}()" + + +def extract_prototype(source: str) -> str | None: + before, sep, _after = source.partition("{") + if not sep: + return None + text = " ".join( + line.strip() for line in before.splitlines() if line.strip() + ).strip() + return text or None + + +def extract_name(prototype: str) -> str | None: + before, sep, _after = prototype.partition("(") + if not sep: + return None + parts = before.rstrip().split() + return parts[-1].lstrip("*") if parts else None + + +def annotate_source(source: str, routine: Routine) -> str: + marker = f"\n /* {routine.name} @ 0x{routine.address:x}; recovered address annotation. */\n" + brace = source.find("{") + if brace == -1: + return f"/* {routine.name} @ 0x{routine.address:x} */\n{source}" + return source[: brace + 1] + marker + source[brace + 1 :] + + +def asm_stub(routine: Routine, prototype: str) -> str: + lines = [ + prototype, + "{", + f" /* {routine.name} @ 0x{routine.address:x}; short routine, inspect paired ASM. */", + ] + if not prototype.startswith("void "): + lines.append(" return 0;") + lines.append("}") + return "\n".join(lines) + + +def failure_stub(routine: Routine, prototype: str) -> str: + lines = [ + prototype, + "{", + f" /* export failed for {routine.name} @ 0x{routine.address:x}; see export-manifest.json. */", + ] + if not prototype.startswith("void "): + lines.append(" return 0;") + lines.append("}") + return "\n".join(lines) + + +def asm_function(routine: Routine, disasm: str) -> str: + return "\n".join( + [ + f"; Function: {routine.name} @ 0x{routine.address:x}", + disasm.strip() or "; ", + ] + ) + + +def tree_safe_function(source: str, *, fallback_name: str) -> str: + text = source.strip() + if "{" not in text: + return f"tocode_word {clean_c_identifier(fallback_name)}(void)\n{{\n return 0;\n}}" + + replacements = [ + ("unsigned __int128", "unsigned long long"), + ("signed __int128", "long long"), + ("__int128", "long long"), + ("unsigned __int64", "unsigned long long"), + ("signed __int64", "long long"), + ("__int64", "long long"), + ("unsigned __int32", "unsigned int"), + ("signed __int32", "int"), + ("__int32", "int"), + ("unsigned __int16", "unsigned short"), + ("signed __int16", "short"), + ("__int16", "short"), + ("unsigned __int8", "unsigned char"), + ("signed __int8", "signed char"), + ("__int8", "char"), + ("_BOOL1", "bool"), + ("_BOOL2", "bool"), + ("_BOOL4", "bool"), + ("_BYTE", "uint8_t"), + ("_WORD", "uint16_t"), + ("_DWORD", "uint32_t"), + ("_QWORD", "uint64_t"), + ] + for old, new in replacements: + text = text.replace(old, new) + text = TREE_CALLING_CONVENTION_RX.sub("", text) + text = TREE_SPOILS_RX.sub("", text) + text = re.sub(r"\b__(?:pure|hidden|unused|noreturn)\b", "", text) + text = re.sub(r"\b([0-9]+)i64\b", r"\1LL", text) + text = re.sub(r"\b(0x[0-9A-Fa-f]+)i64\b", r"\1LL", text) + text = re.sub(r"\b__PAIR\d+__\s*\(", "tocode_pair(", text) + text = TREE_REGISTER_RX.sub(_tree_register_name, text) + text = TREE_VERSIONED_IMPORT_RX.sub(r"\1", text) + text = re.sub(r"\bnullptr\b", "NULL", text) + text = TREE_SPACE_RX.sub(" ", text) + return text.strip() + + +def _tree_register_name(match: re.Match[str]) -> str: + return "_" + clean_c_identifier(match.group(1)) + + +def _summary_function(routine: Routine, path: Path, text: str) -> str: + lines = [ + f"; Function: {routine.name} @ 0x{routine.address:x}", + f"; Summary file: {display_path(path)}", + "; Summary generator: ToCode", + ] + lines.append(text.strip() if text.strip() else "; ") + return "\n".join(lines) + + +def _write_metadata(context: ExportContext) -> None: + analysis = _need(context.analysis) + root = _need(context.root) + header = _need(context.header_path) + names = _need(context.names) + header.write_text( + build_header(analysis, context.prototypes, names), encoding="utf-8" + ) + context.data_variable_count = export_variables(analysis, root, context.raw_ranges) + context.function_index = write_function_index(root, context.raw_ranges) + context.tree_index = ( + write_function_index( + root, context.tree_ranges, file_name="function-index-tree.json" + ) + if context.tree_enabled + else None + ) + if not context.tree_enabled: + stale_tree_index = root / "function-index-tree.json" + if stale_tree_index.exists(): + stale_tree_index.unlink() + stale_tree_header = _need(context.include_dir) / "tocode_tree.h" + if stale_tree_header.exists(): + stale_tree_header.unlink() + stale = root / ("function-index-" + "ll" + "m.json") + if stale.exists(): + stale.unlink() + write_json(root / "sections.json", sections_json(analysis)) + write_json(root / "strings.json", strings_json(analysis, context.raw_ranges)) + write_json(root / "imports.json", imports_json(analysis)) + write_json(root / "exports.json", exports_json(analysis)) + write_json(root / "relocations.json", relocations_json(analysis)) + write_json( + root / "functions.json", + functions_json( + analysis, + context.raw_ranges, + context.prototypes, + names.functions, + tree_ranges=context.tree_ranges, + ), + ) + reachable = reachable_json(analysis) + write_json(root / "reachable.json", reachable) + write_json( + root / "cluster-graph.json", + cluster_graph_json(analysis, context.clusters, context.raw_ranges), + ) + write_json( + root / "triage.json", + triage_json(analysis, context.clusters, context.raw_ranges, reachable), + ) + context.ida_database = publish_backend_database(context) + write_project_json(context) + (root / "AGENTS.md").write_text( + build_export_agents( + analysis, context.header_name, tree_enabled=context.tree_enabled + ), + encoding="utf-8", + ) + (root / "CLAUDE.md").write_text("@./AGENTS.md\n", encoding="utf-8") + context.manifest = write_manifest(context) + + +def publish_backend_database(context: ExportContext) -> Path | None: + session = getattr(context.analyzer, "session", None) + database_path = getattr(session, "database_path", None) + if not callable(database_path): + return None + source = database_path() + if source is None or not source.is_file(): + return None + analysis = _need(context.analysis) + root = _need(context.root) + suffix = source.suffix.lower() + target = root / f"{clean_path_component(analysis.binary.path.stem)}{suffix}" + if source.resolve() != target.resolve(): + shutil.copy2(source, target) + context.progress.log(f"Saved IDA database to {target}") + return target.resolve() + + +def build_header( + analysis: ProgramAnalysis, prototypes: dict[int, str], names: NameBook +) -> str: + lines = [ + "#pragma once", + "", + "#include ", + "#include ", + "#include ", + "", + "typedef uint8_t byte;", + "typedef uint16_t word;", + "typedef uint32_t dword;", + "typedef uint64_t qword;", + "typedef uint8_t undefined;", + "typedef uint8_t undefined1;", + "typedef uint16_t undefined2;", + "typedef uint32_t undefined4;", + "typedef uint64_t undefined8;", + "typedef void (*code)(void);", + "", + "typedef unsigned char uchar;", + "typedef unsigned short ushort;", + "typedef unsigned int uint;", + "typedef unsigned long ulong;", + "typedef unsigned long long ulonglong;", + "typedef signed char sbyte;", + "", + "/* Generated by ToCode. */", + f"/* Source binary: {analysis.binary.path} */", + "", + "#ifdef __cplusplus", + 'extern "C" {', + "#endif", + "", + "/* Internal functions */", + ] + for address in sorted(prototypes): + routine = analysis.routines.get(address) + if routine is not None and not routine.imported: + lines.append(f"{prototypes[address]}; /* 0x{address:x} */") + imports = [ + f"{import_prototype(names.import_name(address, item.name), analysis.binary.pointer_size)}; /* 0x{address:x} */" + for address, item in sorted(analysis.imports.items()) + ] + if imports: + lines.extend(["", "/* Imported functions */", *imports]) + lines.extend(["", "#ifdef __cplusplus", "}", "#endif", ""]) + return "\n".join(lines) + + +def build_tree_header(analysis: ProgramAnalysis) -> str: + default_return = "uint64_t" if analysis.binary.pointer_size >= 8 else "uint32_t" + lines = [ + "#pragma once", + "", + "#include ", + "#include ", + "#include ", + "#include ", + "", + "typedef uint8_t byte;", + "typedef uint16_t word;", + "typedef uint32_t dword;", + "typedef uint64_t qword;", + "typedef uint8_t undefined;", + "typedef uint8_t undefined1;", + "typedef uint16_t undefined2;", + "typedef uint32_t undefined4;", + "typedef uint64_t undefined8;", + "typedef void (*code)(void);", + f"typedef {default_return} tocode_word;", + "", + "extern uintptr_t tocode_unknown;", + "", + ] + return "\n".join(lines) + + +def write_function_index( + root: Path, ranges: list[FunctionRange], *, file_name: str = "function-index.json" +) -> Path: + path = root / file_name + write_json( + path, + { + "schema_version": 2, + "functions": [ + { + "address": f"0x{item.address:x}", + "name": item.name, + "c": { + "path": str(item.c_file), + "line_start": item.c_line_start, + "line_end": item.c_line_end, + }, + "asm": { + "path": str(item.asm_file), + "line_start": item.asm_line_start, + "line_end": item.asm_line_end, + }, + } + for item in ranges + ], + }, + ) + return path + + +def write_project_json(context: ExportContext) -> None: + analysis = _need(context.analysis) + root = _need(context.root) + write_json( + root / "project.json", + { + "binary": str(analysis.binary.path), + "backend": context.analyzer.backend_name, + "decompiler": context.analyzer.decompiler_label, + "root_dir": str(root.resolve()), + "src_dir": str(_need(context.raw_dir).resolve()), + "raw_src_dir": str(_need(context.raw_dir).resolve()), + "tree_src_dir": str(context.tree_dir.resolve()) + if context.tree_dir is not None + else None, + "include_dir": str(_need(context.include_dir).resolve()), + "data_dir": str(_need(context.data_dir).resolve()), + "header": str(_need(context.header_path).resolve()), + "agents": str((root / "AGENTS.md").resolve()), + "claude": str((root / "CLAUDE.md").resolve()), + "ida_database": str(context.ida_database) + if context.ida_database is not None + else None, + "source_files": [str(item) for item in context.raw_sources], + "tree_source_files": [str(item) for item in context.tree_sources], + "summary_files": [str(item) for item in context.summary_files], + "asm_files": [str(item) for item in context.asm_files], + "function_index": str(_need(context.function_index).resolve()), + "tree_function_index": str(context.tree_index.resolve()) + if context.tree_index is not None + else None, + "function_count": len(context.raw_ranges), + "cluster_count": len(context.clusters), + "failure_count": len(context.failures), + "requested_worker_count": context.requested_jobs + if context.requested_jobs is not None + else "auto", + "worker_count": context.worker_count, + "parallel_mode": context.render_mode, + }, + ) + + +def write_manifest(context: ExportContext) -> Path: + analysis = _need(context.analysis) + root = _need(context.root) + path = root / "export-manifest.json" + write_json( + path, + { + "schema_version": 2, + "binary": str(analysis.binary.path), + "backend": context.analyzer.backend_name, + "decompiler": context.analyzer.decompiler_label, + "format": analysis.binary.format_name, + "arch": analysis.binary.arch, + "bits": analysis.binary.bits, + "entrypoints": [f"0x{item:x}" for item in analysis.binary.entrypoints], + "header": str(_need(context.header_path).resolve()), + "ida_database": str(context.ida_database) + if context.ida_database is not None + else None, + "source_files": [str(item) for item in context.raw_sources], + "raw_source_files": [str(item) for item in context.raw_sources], + "tree_source_files": [str(item) for item in context.tree_sources], + "asm_files": [str(item) for item in context.asm_files], + "summary_files": [str(item) for item in context.summary_files], + "function_index": str(_need(context.function_index).resolve()), + "tree_function_index": str(context.tree_index.resolve()) + if context.tree_index is not None + else None, + "raw_src_dir": str(_need(context.raw_dir).resolve()), + "tree_src_dir": str(context.tree_dir.resolve()) + if context.tree_dir is not None + else None, + "cluster_count": len(context.clusters), + "function_count": len(context.raw_ranges), + "data_variable_count": context.data_variable_count, + "failure_count": len(context.failures), + "requested_worker_count": context.requested_jobs + if context.requested_jobs is not None + else "auto", + "worker_count": context.worker_count, + "parallel_mode": context.render_mode, + "agents": str((root / "AGENTS.md").resolve()), + "claude": str((root / "CLAUDE.md").resolve()), + "triage": str((root / "triage.json").resolve()), + "imports": str((root / "imports.json").resolve()), + "exports": str((root / "exports.json").resolve()), + "reachable": str((root / "reachable.json").resolve()), + "cluster_graph": str((root / "cluster-graph.json").resolve()), + "variables_interesting": str( + (root / "data" / "variables_interesting.json").resolve() + ), + "failures": [ + { + "address": f"0x{item.address:x}", + "name": item.name, + "error": item.message, + } + for item in context.failures + ], + }, + ) + return path + + +def build_export_agents( + analysis: ProgramAnalysis, header_name: str, *, tree_enabled: bool = True +) -> str: + section_files = sorted( + [ + f"`data/{clean_path_component(section.name)}.bin`" + for section in analysis.segments + if max(section.vsize, section.size) > 0 and section.size > 0 + ], + key=lambda item: (item != "`data/text.bin`", item), + ) + examples = ", ".join(section_files[:8]) + if len(section_files) > 8: + examples += ", ..." + lines = [ + "# AGENTS", + "", + "You are working inside a ToCode binary export.", + "Treat the recovered source, assembly, metadata, and raw section data as evidence for reverse engineering.", + "", + "## Mission", + "", + "Reverse the binary, answer user questions, or serve as an oracle/helper to the user regarding this recovered source code.", + "Write a report only when the user asks for one; otherwise keep findings focused on the question or task at hand.", + "Do not refactor or modify the generated export unless the user explicitly asks for edits.", + "Use subagents only for narrow evidence-gathering tasks such as strings, entrypoint paths, or one cluster family.", + "", + "## Files", + "", + "- `src/raw/*.summary`: compact function summaries grouped with each clustered source file.", + "- `src/raw/*.c`: raw decompiled C-like output from the selected backend.", + "- `src/raw/*.asm`: disassembly grouped with the matching raw source clusters.", + f"- `include/{header_name}`: recovered prototypes and common typedefs.", + f"- `data/*.bin`: raw section payloads from the original binary. Example files: {examples or '`data/*.bin`'}", + "- `data/variables.json`: section manifest plus recovered strings, symbols, relocations, and data labels.", + "- `data/variables_interesting.json`: globals and pointers worth checking early.", + "- `triage.json`: first-read summary with entry clusters, sections, counts, and strings of interest.", + "- `imports.json` and `exports.json`: structured import/export tables.", + "- `reachable.json`: entrypoint/export reachability depths and unreachable count.", + "- `cluster-graph.json`: inter-cluster call graph.", + "- `functions.json`: per-function caller/callee relationships, prototypes, source ranges, and ASM ranges.", + "- `function-index.json`: exact raw source and ASM line mappings for each exported function.", + "- `sections.json`, `strings.json`, and `relocations.json`: layout and reference metadata.", + "- `project.json` and `export-manifest.json`: top-level export paths and artifact inventory.", + "- `CLAUDE.md`: Claude entrypoint that references `AGENTS.md`.", + "- `.i64` or `.idb`: exported IDA database when the IDA backend was used.", + "", + "## Working Style", + "", + "- Start with `triage.json`, `imports.json`, `strings.json`, and `src/raw/*.summary`.", + "- Use `functions.json` and `function-index.json` to open only the function line ranges you need.", + "- Read ASM when decompiled output is ambiguous, short, indirect, or contradicted by metadata.", + "- Treat recovered names and types as hints, not ground truth.", + "- Cite file paths, function names, addresses, and line numbers for every major claim.", + "- Be explicit about uncertainty and unresolved symbols.", + "", + ] + if tree_enabled: + lines.insert( + lines.index( + "- `src/raw/*.summary`: compact function summaries grouped with each clustered source file." + ), + "- `src/tree/*.c`: scanner-friendly C normalized for tree-sitter and Semgrep. Use this for automated source scanning.", + ) + lines.insert( + lines.index( + "- `sections.json`, `strings.json`, and `relocations.json`: layout and reference metadata." + ), + "- `function-index-tree.json`: exact scanner-source line mappings for each exported function.", + ) + return "\n".join(lines) + + +def _build_clusters( + analysis: ProgramAnalysis, analyzer: BinaryAnalyzer +) -> list[Cluster]: + app = analysis.app_routines() + if len(app) >= FAST_CLUSTER_FUNCTIONS: + clusters = _fast_clusters(analysis) + else: + clusters = cluster_routines( + addresses=[item.address for item in app], + roots=analysis.roots, + callees=analysis.callees, + callers=analysis.callers, + thunks=analysis.thunks, + ) + for cluster in clusters: + if cluster.root == SHARED_CLUSTER_ID: + cluster.summary = "Shared utility functions" + continue + routine = analysis.routines.get(cluster.root) + if routine is not None: + cluster.label = routine.name + cluster.summary = analyzer.cluster_description_from_imports(cluster.members) + return _normalize_clusters(analysis, clusters) + _support_clusters(analysis) + + +def _fast_clusters(analysis: ProgramAnalysis) -> list[Cluster]: + by_segment: dict[str, list[int]] = {} + for routine in analysis.app_routines(): + by_segment.setdefault( + clean_path_component(routine.segment or "misc"), [] + ).append(routine.address) + clusters: list[Cluster] = [] + for segment, members in sorted(by_segment.items()): + members.sort() + for index, chunk in enumerate(_chunks(members, MAX_FUNCTIONS_PER_FILE)): + label = ( + segment + if len(members) <= MAX_FUNCTIONS_PER_FILE + else f"{segment}_{index}" + ) + clusters.append( + Cluster( + root=chunk[0], + label=label, + summary=f"Fast export chunk from section {segment}", + members=list(chunk), + ) + ) + return clusters + + +def _support_clusters(analysis: ProgramAnalysis) -> list[Cluster]: + grouped: dict[tuple[str, str], list[int]] = {} + for routine in analysis.support_routines(): + folder = clean_path_component(routine.code_kind or "support") + segment = clean_path_component(routine.segment or "misc") + grouped.setdefault((folder, segment), []).append(routine.address) + clusters: list[Cluster] = [] + for (folder, segment), members in sorted(grouped.items()): + members.sort() + for index, chunk in enumerate(_chunks(members, MAX_FUNCTIONS_PER_FILE)): + label = f"{folder}_{segment}" + if len(members) > MAX_FUNCTIONS_PER_FILE: + label = f"{label}_{index}" + clusters.append( + Cluster( + root=chunk[0], + label=label, + summary=f"{folder} functions from section {segment}", + members=list(chunk), + folder=folder, + ) + ) + return clusters + + +def _normalize_clusters( + analysis: ProgramAnalysis, clusters: list[Cluster] +) -> list[Cluster]: + split: list[Cluster] = [] + for cluster in clusters: + if len(cluster.members) <= MAX_FUNCTIONS_PER_FILE: + split.append(cluster) + continue + for index, chunk in enumerate(_chunks(cluster.members, MAX_FUNCTIONS_PER_FILE)): + split.append( + Cluster( + root=chunk[0], + label=f"{cluster.label}_{index}", + summary=f"{cluster.summary} (part {index + 1})" + if cluster.summary + else "", + members=list(chunk), + folder=cluster.folder, + ) + ) + return _coalesce_small(analysis, split) + + +def _coalesce_small( + analysis: ProgramAnalysis, clusters: list[Cluster] +) -> list[Cluster]: + output: list[Cluster] = [] + pending: list[int] = [] + pending_root: int | None = None + pending_bytes = 0 + for cluster in clusters: + size = sum( + max(analysis.routines[address].size, 1) + for address in cluster.members + if address in analysis.routines + ) + mergeable = ( + cluster.root != SHARED_CLUSTER_ID + and len(cluster.members) <= TINY_CLUSTER_FUNCTIONS + and size <= TINY_CLUSTER_BYTES + ) + if not mergeable: + _flush_small(output, pending, pending_root) + pending = [] + pending_root = None + pending_bytes = 0 + output.append(cluster) + continue + if pending and ( + len(pending) + len(cluster.members) > MERGED_CLUSTER_FUNCTIONS + or pending_bytes + size > MERGED_CLUSTER_BYTES + ): + _flush_small(output, pending, pending_root) + pending = [] + pending_root = None + pending_bytes = 0 + if pending_root is None: + pending_root = cluster.root + pending.extend(cluster.members) + pending_bytes += size + _flush_small(output, pending, pending_root) + return output + + +def _flush_small(output: list[Cluster], members: list[int], root: int | None) -> None: + if not members: + return + cluster_root = root if root is not None else SHARED_CLUSTER_ID + output.append( + Cluster( + root=cluster_root, + label=f"cluster_{cluster_root:016x}", + summary="Merged small export clusters", + members=list(members), + ) + ) + + +def _c_preamble(cluster: Cluster, header_include: str) -> str: + lines = [ + "/* Generated by ToCode. */", + "/* Cluster: utils */" + if cluster.root == SHARED_CLUSTER_ID + else f"/* Cluster root: {cluster.label} (0x{cluster.root:x}) */", + ] + if cluster.summary: + lines.append(f"/* Description: {cluster.summary} */") + lines.extend([f'#include "{header_include}"', ""]) + return "\n".join(lines) + + +def _asm_preamble(cluster: Cluster) -> str: + lines = [ + "; Generated by ToCode.", + "; Cluster: utils" + if cluster.root == SHARED_CLUSTER_ID + else f"; Cluster root: {cluster.label} (0x{cluster.root:x})", + ] + if cluster.summary: + lines.append(f"; Description: {cluster.summary}") + lines.append("") + return "\n".join(lines) + + +def _summary_preamble(cluster: Cluster) -> str: + lines = [ + "; Generated by ToCode.", + "; Cluster: utils" + if cluster.root == SHARED_CLUSTER_ID + else f"; Cluster root: {cluster.label} (0x{cluster.root:x})", + ] + if cluster.summary: + lines.append(f"; Description: {cluster.summary}") + lines.extend(["; Summary source: backend function summaries", ""]) + return "\n".join(lines) + + +def _tree_preamble(cluster: Cluster, include_path: str) -> str: + lines = [ + "/* Generated by ToCode for C parsers and source scanners. */", + "/* Scanner source: normalized from src/raw; use raw and ASM for final evidence. */", + "/* Cluster: utils */" + if cluster.root == SHARED_CLUSTER_ID + else f"/* Cluster root: {cluster.label} (0x{cluster.root:x}) */", + ] + if cluster.summary: + lines.append(f"/* Description: {cluster.summary} */") + lines.extend([f'#include "{include_path}"', ""]) + return "\n".join(lines) + + +def _function_metadata( + *, + routine: Routine, + rendered: RenderedFunction, + c_path: Path, + asm_path: Path, + c_start: int, + c_end: int, + asm_start: int, + asm_end: int, +) -> str: + return "\n".join( + [ + "/* metadata:", + f" * address: 0x{routine.address:x}", + f" * original_name: {routine.name}", + f" * c_name: {rendered.c_name}", + f" * source_file: {display_path(c_path)}", + f" * line_start: {c_start}", + f" * line_end: {c_end}", + f" * asm_file: {display_path(asm_path)}", + f" * asm_line_start: {asm_start}", + f" * asm_line_end: {asm_end}", + " */", + "", + ] + ) + + +def _tree_function_metadata(*, routine: Routine, raw_path: Path) -> str: + return "\n".join( + [ + "/* tocode-tree:", + f" * address: 0x{routine.address:x}", + f" * original_name: {routine.name}", + f" * raw_source: {display_path(raw_path)}", + " */", + "", + ] + ) + + +def _root_dir(binary: Path, out_dir: Path | None) -> Path: + if out_dir is not None: + path = Path(out_dir).expanduser() + return ( + path.resolve() if path.is_absolute() else (binary.parent / path).resolve() + ) + default_root = os.environ.get("TOCODE_DEFAULT_OUT_ROOT", "").strip() + if default_root: + return ( + Path(default_root).expanduser().resolve() / default_output_name(binary) + ).resolve() + return (binary.parent / default_output_name(binary)).resolve() + + +def _cluster_path(root: Path, cluster: Cluster, file_name: str) -> Path: + return root / clean_path_component(cluster.folder or "app") / file_name + + +def _expected_paths(root: Path, cluster: Cluster) -> list[Path]: + return [ + _cluster_path(root, cluster, c_file_name(cluster)), + _cluster_path(root, cluster, asm_file_name(cluster)), + _cluster_path(root, cluster, summary_file_name(cluster)), + ] + + +def _remove_stale_sources(src_dir: Path, expected: list[Path]) -> None: + allowed = {path.resolve() for path in expected} + if not src_dir.exists(): + return + for path in src_dir.rglob("*"): + if ( + path.is_file() + and path.suffix in {".c", ".asm", ".summary"} + and path.resolve() not in allowed + ): + path.unlink() + _remove_empty_dirs(src_dir) + + +def _remove_empty_dirs(path: Path) -> None: + for child in path.iterdir(): + if child.is_dir(): + _remove_empty_dirs(child) + if path != path.parent and not any(path.iterdir()): + try: + path.rmdir() + except OSError: + pass + + +def _summary(context: ExportContext) -> ExportSummary: + return ExportSummary( + root_dir=_need(context.root).resolve(), + raw_src_dir=_need(context.raw_dir).resolve(), + tree_src_dir=context.tree_dir.resolve() + if context.tree_dir is not None + else None, + include_dir=_need(context.include_dir).resolve(), + header_path=_need(context.header_path).resolve(), + source_files=context.raw_sources, + tree_source_files=context.tree_sources, + asm_files=context.asm_files, + summary_files=context.summary_files, + function_count=len(context.raw_ranges), + cluster_count=len(context.clusters), + failed_functions=context.failures, + function_index_path=_need(context.function_index).resolve(), + tree_function_index_path=context.tree_index.resolve() + if context.tree_index is not None + else None, + manifest_path=_need(context.manifest).resolve(), + data_dir=_need(context.data_dir).resolve(), + ) + + +def _need(value): + if value is None: + raise RuntimeError("export context is incomplete") + return value + + +def _chunks(values: list[int], size: int): + for offset in range(0, len(values), size): + yield values[offset : offset + size] + + +def line_count(text: str) -> int: + return max(1, len(text.splitlines())) + + +def next_line(text: str) -> int: + return line_count(text) + 1 + +``` + +`src/tocode/metadata.py`: + +```py +from __future__ import annotations + +from collections import deque +import math +from pathlib import Path +import re +from typing import Any + +from .naming import SHARED_CLUSTER_ID, c_file_name, clean_path_component +from .schema import Cluster, FunctionRange, ProgramAnalysis, Segment, StringEntry + + +def display_path(path: Path) -> str: + parts = path.parts + for marker in ("src", "include", "data"): + if marker in parts: + return Path(*parts[parts.index(marker) :]).as_posix() + return path.as_posix() + + +def imports_json(analysis: ProgramAnalysis) -> dict[str, object]: + grouped: dict[str, list[dict[str, object]]] = {} + for address, item in sorted(analysis.imports.items()): + dll = item.dll or item.bind or "unknown" + grouped.setdefault(dll, []).append( + {"name": item.name, "address": f"0x{address:x}", "delay": item.delay} + ) + return { + "imports": [ + {"dll": dll, "functions": functions} + for dll, functions in sorted( + grouped.items(), key=lambda value: value[0].lower() + ) + ], + } + + +def exports_json(analysis: ProgramAnalysis) -> dict[str, object]: + return { + "exports": [ + { + "ordinal": item.ordinal, + "name": item.name, + "address": f"0x{item.address:x}", + "is_forwarder": item.forwarder + or inferred_forwarder(analysis, item) is not None, + "forwarder_target": item.forwarder_target + or inferred_forwarder(analysis, item), + } + for item in sorted( + analysis.exports, + key=lambda item: ( + item.ordinal if item.ordinal is not None else 1 << 30, + item.name, + item.address, + ), + ) + ] + } + + +def sections_json(analysis: ProgramAnalysis) -> dict[str, object]: + return { + "sections": [ + { + "name": item.name, + "vaddr": f"0x{item.vaddr:x}", + "paddr": f"0x{item.paddr:x}", + "size": item.size, + "vsize": item.vsize, + "type": item.kind, + "permissions": item.perms, + "rwx": item.readable and item.writable and item.executable, + "entropy": section_entropy(analysis, item), + } + for item in analysis.segments + ] + } + + +def relocations_json(analysis: ProgramAnalysis) -> dict[str, object]: + return { + "relocations": [ + { + "name": item.name, + "type": item.kind, + "vaddr": f"0x{item.vaddr:x}", + "paddr": f"0x{item.paddr:x}", + "is_ifunc": item.ifunc, + } + for item in analysis.relocations + ] + } + + +def strings_json( + analysis: ProgramAnalysis, ranges: list[FunctionRange] +) -> dict[str, object]: + xrefs = string_xrefs(analysis.strings, ranges) + return { + "strings": [ + { + "vaddr": f"0x{item.vaddr:x}", + "paddr": f"0x{item.paddr:x}", + "size": item.size, + "length": item.length, + "section": item.segment, + "type": item.kind, + "value": item.value, + "xrefs": xrefs.get(item.vaddr, []), + } + for item in analysis.strings + ] + } + + +def functions_json( + analysis: ProgramAnalysis, + ranges: list[FunctionRange], + prototypes: dict[int, str], + c_names: dict[int, str], + tree_ranges: list[FunctionRange] | None = None, +) -> dict[str, object]: + range_map = {item.address: item for item in ranges} + tree_map = {item.address: item for item in (tree_ranges or [])} + reachable = set(reachable_depths(analysis)) + rows: list[dict[str, object]] = [] + for address, routine in sorted(analysis.routines.items()): + if routine.imported: + continue + raw_range = range_map.get(address) + tree_range = tree_map.get(address) + callees = analysis.callees.get(address, []) + callers = analysis.callers.get(address, []) + rows.append( + { + "address": f"0x{address:x}", + "name": routine.name, + "c_name": c_names.get(address, routine.name), + "prototype": prototypes.get(address), + "size": routine.size, + "nargs": routine.args_count, + "nlocals": routine.locals_count, + "stackframe": routine.stack_size, + "callees": [f"0x{item:x}" for item in callees], + "callee_names": [ + analysis.routines[item].name + for item in callees + if item in analysis.routines + ], + "callees_imports": analysis.import_calls.get(address, []), + "callee_count": len(set(callees)), + "callers": [f"0x{item:x}" for item in callers], + "caller_count": len(set(callers)), + "dead_code": address not in reachable, + "source_file": str(raw_range.c_file) if raw_range else None, + "source_line_start": raw_range.c_line_start if raw_range else None, + "source_line_end": raw_range.c_line_end if raw_range else None, + "tree_source_file": str(tree_range.c_file) if tree_range else None, + "tree_source_line_start": tree_range.c_line_start + if tree_range + else None, + "tree_source_line_end": tree_range.c_line_end if tree_range else None, + "asm_file": str(raw_range.asm_file) if raw_range else None, + "asm_line_start": raw_range.asm_line_start if raw_range else None, + "asm_line_end": raw_range.asm_line_end if raw_range else None, + } + ) + return {"functions": rows} + + +def reachable_json(analysis: ProgramAnalysis) -> dict[str, object]: + depths = reachable_depths(analysis) + internal = [item for item in analysis.routines.values() if not item.imported] + return { + "reachable": [ + { + "address": f"0x{address:x}", + "name": analysis.routines[address].name, + "depth": depth, + } + for address, depth in sorted( + depths.items(), key=lambda item: (item[1], item[0]) + ) + ], + "unreachable_count": sum(1 for item in internal if item.address not in depths), + } + + +def reachable_depths(analysis: ProgramAnalysis) -> dict[int, int]: + seeds = [ + address for address in entry_seeds(analysis) if address in analysis.routines + ] + if not seeds: + seeds = [address for address in analysis.roots if address in analysis.routines] + depths: dict[int, int] = {} + queue: deque[tuple[int, int]] = deque((address, 0) for address in seeds) + while queue: + address, depth = queue.popleft() + old = depths.get(address) + if old is not None and old <= depth: + continue + depths[address] = depth + for callee in analysis.callees.get(address, []): + if callee in analysis.routines and not analysis.routines[callee].imported: + queue.append((callee, depth + 1)) + return depths + + +def cluster_graph_json( + analysis: ProgramAnalysis, + clusters: list[Cluster], + ranges: list[FunctionRange], +) -> dict[str, object]: + cluster_by_func: dict[int, Cluster] = {} + for cluster in clusters: + for address in cluster.members: + cluster_by_func[address] = cluster + file_by_root = {item.address: item.c_file for item in ranges} + outgoing: dict[str, set[str]] = {} + incoming: dict[str, set[str]] = {} + for address, cluster in cluster_by_func.items(): + source = cluster_id(cluster) + outgoing.setdefault(source, set()) + incoming.setdefault(source, set()) + for callee in analysis.callees.get(address, []): + target_cluster = cluster_by_func.get(callee) + if target_cluster is None or target_cluster is cluster: + continue + target = cluster_id(target_cluster) + outgoing[source].add(target) + incoming.setdefault(target, set()).add(source) + return { + "clusters": [ + { + "id": cluster_id(cluster), + "file": display_path( + file_by_root.get(cluster.root, Path(c_file_name(cluster))) + ), + "root_function": cluster.label, + "calls_clusters": sorted(outgoing.get(cluster_id(cluster), set())), + "called_by_clusters": sorted(incoming.get(cluster_id(cluster), set())), + } + for cluster in clusters + ] + } + + +def triage_json( + analysis: ProgramAnalysis, + clusters: list[Cluster], + ranges: list[FunctionRange], + reachable_doc: dict[str, object], +) -> dict[str, object]: + reachable_rows = reachable_doc.get("reachable", []) + return { + "binary_type": binary_type(analysis), + "arch": analysis.binary.arch, + "bits": analysis.binary.bits, + "compiler": guess_compiler(analysis), + "packed": guess_packed(analysis), + "entry_clusters": entry_clusters(analysis, clusters, ranges), + "sections": triage_sections(analysis), + "rwx_sections": triage_sections(analysis, rwx_only=True), + "export_count": len(analysis.exports), + "import_count": len(analysis.imports), + "strings_of_interest": interesting_strings(analysis)[:50], + "reachable_count": len(reachable_rows) + if isinstance(reachable_rows, list) + else 0, + "unreachable_count": reachable_doc.get("unreachable_count", 0), + } + + +def entry_clusters( + analysis: ProgramAnalysis, + clusters: list[Cluster], + ranges: list[FunctionRange], +) -> list[dict[str, object]]: + ranges_by_address = {item.address: item for item in ranges} + cluster_by_address = { + address: cluster for cluster in clusters for address in cluster.members + } + rows: list[dict[str, object]] = [] + for address in entry_seeds(analysis): + routine = analysis.routines.get(address) + if routine is None: + continue + row_range = ranges_by_address.get(address) + rows.append( + { + "name": routine.name, + "address": f"0x{address:x}", + "cluster": display_path(row_range.c_file) + if row_range + else ( + c_file_name(cluster_by_address[address]) + if address in cluster_by_address + else None + ), + "line": row_range.c_line_start if row_range else None, + } + ) + return rows + + +def export_variables( + analysis: ProgramAnalysis, root: Path, ranges: list[FunctionRange] +) -> int: + data_dir = root / "data" + sections: list[dict[str, object]] = [] + with analysis.binary.path.open("rb") as handle: + for segment in analysis.segments: + if max(segment.vsize, segment.size) <= 0: + continue + file_name = None + if segment.size > 0: + file_name = f"{clean_path_component(segment.name)}.bin" + handle.seek(segment.paddr) + blob = handle.read(segment.size) + (data_dir / file_name).write_bytes(blob) + if blob: + segment.entropy = round(shannon_entropy(blob), 6) + sections.append( + { + "name": segment.name, + "file": file_name, + "va": f"0x{segment.vaddr:x}", + "size": segment.vsize, + "file_size": segment.size, + "permissions": segment.perms, + "entropy": section_entropy(analysis, segment), + } + ) + variables = variables_document(analysis, ranges) + write_json( + data_dir / "variables.json", {"sections": sections, "variables": variables} + ) + write_json( + data_dir / "variables_interesting.json", + interesting_variables(analysis, variables), + ) + return len(variables) + + +def variables_document( + analysis: ProgramAnalysis, ranges: list[FunctionRange] +) -> dict[str, dict[str, object]]: + variables: dict[str, dict[str, object]] = {} + seen: set[int] = set() + data_segments = [ + segment + for segment in analysis.segments + if segment.readable + and not segment.executable + and max(segment.vsize, segment.size) > 0 + ] + + def containing(address: int) -> Segment | None: + for segment in data_segments: + end = segment.vaddr + max(segment.vsize, segment.size) + if segment.vaddr <= address < end: + return segment + return None + + def unique(base: str, address: int) -> str: + return base if base not in variables else f"{base}_{address:x}" + + for string in analysis.strings: + segment = containing(string.vaddr) + if segment is None: + continue + label = unique( + f"str_{clean_path_component(string.value[:32] or f'{string.vaddr:x}')}_{string.vaddr:x}", + string.vaddr, + ) + variables[label] = { + "section": segment.name, + "offset": string.vaddr - segment.vaddr, + "size": string.size, + "end": string.vaddr - segment.vaddr + string.size, + "va": f"0x{string.vaddr:x}", + "type": "char[]" if string.kind == "ascii" else "wchar_t[]", + "string_type": string.kind, + "value": string.value, + } + seen.add(string.vaddr) + + for symbol in analysis.symbols: + if symbol.imported or symbol.vaddr in seen or symbol.kind == "FUNC": + continue + segment = containing(symbol.vaddr) + if segment is None: + continue + label = unique( + clean_path_component(symbol.real_name or symbol.name), symbol.vaddr + ) + variables[label] = { + "section": segment.name, + "offset": symbol.vaddr - segment.vaddr, + "size": symbol.size, + "end": symbol.vaddr - segment.vaddr + symbol.size, + "va": f"0x{symbol.vaddr:x}", + "type": "uint8_t" if symbol.size == 1 else "uint8_t[]", + "symbol_type": symbol.kind, + } + seen.add(symbol.vaddr) + + for relocation in analysis.relocations: + if relocation.vaddr in seen: + continue + segment = containing(relocation.vaddr) + if segment is None: + continue + label = unique( + f"reloc_{clean_path_component(relocation.name)}", relocation.vaddr + ) + variables[label] = { + "section": segment.name, + "offset": relocation.vaddr - segment.vaddr, + "size": analysis.binary.pointer_size, + "end": relocation.vaddr - segment.vaddr + analysis.binary.pointer_size, + "va": f"0x{relocation.vaddr:x}", + "type": "void*", + "relocation_type": relocation.kind, + } + seen.add(relocation.vaddr) + + for flag in analysis.flags: + if flag.offset in seen or not flag.name.startswith( + ("obj.", "data.", "str.", "reloc.", "vtable.") + ): + continue + segment = containing(flag.offset) + if segment is None: + continue + label = unique(clean_path_component(flag.real_name or flag.name), flag.offset) + variables[label] = { + "section": segment.name, + "offset": flag.offset - segment.vaddr, + "size": flag.size, + "end": flag.offset - segment.vaddr + flag.size, + "va": f"0x{flag.offset:x}", + "type": "uint8_t[]" if flag.size != 1 else "uint8_t", + "flag": flag.name, + } + seen.add(flag.offset) + + add_variable_xrefs(variables, ranges) + return dict(sorted(variables.items())) + + +def source_lines(ranges: list[FunctionRange]) -> list[dict[str, Any]]: + cache: dict[Path, list[str]] = {} + rows: list[dict[str, Any]] = [] + for item in ranges: + if item.c_file not in cache: + try: + cache[item.c_file] = item.c_file.read_text( + encoding="utf-8", errors="replace" + ).splitlines() + except OSError: + cache[item.c_file] = [] + for line_number in range(item.c_line_start, item.c_line_end + 1): + index = line_number - 1 + if 0 <= index < len(cache[item.c_file]): + rows.append( + { + "address": item.address, + "function": item.name, + "path": str(item.c_file), + "line": line_number, + "text": cache[item.c_file][index], + } + ) + return rows + + +def string_xrefs( + strings: list[StringEntry], ranges: list[FunctionRange] +) -> dict[int, list[dict[str, object]]]: + lines = source_lines(ranges) + result: dict[int, list[dict[str, object]]] = {} + for item in strings: + needles = string_needles(item.value) + found: list[dict[str, object]] = [] + seen: set[tuple[int, int, str]] = set() + for line in lines: + if not any(needle in str(line["text"]) for needle in needles): + continue + key = (int(line["address"]), int(line["line"]), str(line["path"])) + if key in seen: + continue + seen.add(key) + found.append( + { + "function": line["function"], + "address": f"0x{int(line['address']):x}", + "cluster": display_path(Path(str(line["path"]))), + "line": line["line"], + } + ) + result[item.vaddr] = found + return result + + +def add_variable_xrefs( + variables: dict[str, dict[str, object]], ranges: list[FunctionRange] +) -> None: + lines = source_lines(ranges) + for name, variable in variables.items(): + needles = {name} + value = variable.get("value") + if isinstance(value, str) and len(value) >= 4: + needles.update(string_needles(value)) + flag = variable.get("flag") + if isinstance(flag, str): + needles.add(flag) + xrefs: list[dict[str, object]] = [] + seen: set[tuple[int, bool]] = set() + for line in lines: + text = str(line["text"]) + if not any(needle and needle in text for needle in needles): + continue + key = (int(line["address"]), looks_written(text)) + if key in seen: + continue + seen.add(key) + xrefs.append( + { + "function": line["function"], + "address": f"0x{int(line['address']):x}", + "access": "write" if key[1] else "read", + } + ) + variable["xrefs"] = xrefs + + +def string_needles(value: str) -> list[str]: + text = value.strip() + if len(text) < 4: + return [] + escaped = text.encode("unicode_escape").decode("ascii") + return [ + item + for item in {text, escaped, escaped.replace("\\\\", "\\")} + if len(item) >= 4 + ] + + +def interesting_variables( + analysis: ProgramAnalysis, variables: dict[str, dict[str, object]] +) -> dict[str, object]: + known_strings = {item.value for item in analysis.strings} + result: dict[str, dict[str, object]] = {} + for name, variable in variables.items(): + size = _int_value(variable.get("size"), 0) + type_name = str(variable.get("type", "")) + xrefs = variable.get("xrefs") + xref_rows = xrefs if isinstance(xrefs, list) else [] + writers = [ + row + for row in xref_rows + if isinstance(row, dict) and row.get("access") == "write" + ] + value = variable.get("value") + reasons: list[str] = [] + if "[]" in type_name and size > 16: + reasons.append("large_byte_array") + if isinstance(value, str) and value not in known_strings: + reasons.append("string_not_in_strings_json") + if ( + "void*" in type_name + or "code" in type_name.lower() + or "relocation_type" in variable + ): + reasons.append("function_pointer_or_relocation") + if len({row.get("address") for row in writers}) > 1: + reasons.append("multiple_writers") + if reasons: + row = dict(variable) + row["reasons"] = reasons + result[name] = row + return {"variables": result} + + +def interesting_strings(analysis: ProgramAnalysis) -> list[dict[str, object]]: + pattern = re.compile( + r"(https?://|\\\\|\\[A-Za-z0-9_. -]+\\|[A-Za-z]:\\|" + r"\b\d{1,3}(?:\.\d{1,3}){3}\b|" + r"HKEY_|SOFTWARE\\|SYSTEM\\|\.exe\b|\.dll\b|\.ini\b|%[sdx])", + re.IGNORECASE, + ) + return [ + {"value": item.value, "address": f"0x{item.vaddr:x}"} + for item in analysis.strings + if pattern.search(item.value) + ] + + +def entry_seeds(analysis: ProgramAnalysis) -> list[int]: + seeds: list[int] = [] + for address in analysis.binary.entrypoints: + if address not in seeds: + seeds.append(address) + for item in analysis.exports: + if item.address and item.address not in seeds: + seeds.append(item.address) + return seeds + + +def inferred_forwarder(analysis: ProgramAnalysis, export: object) -> str | None: + address = getattr(export, "address", 0) + if not isinstance(address, int): + return None + segment = analysis.segment_at(address) + if segment is not None and segment.executable: + return None + for item in analysis.strings: + if item.vaddr <= address < item.vaddr + max(item.size, 1) and looks_forwarded( + item.value + ): + return item.value + return None + + +def looks_forwarded(value: str) -> bool: + return bool( + re.match( + r"^[A-Za-z0-9_.-]+\.dll(?:\.[A-Za-z0-9_?$@.-]+)+$", + value.strip(), + re.IGNORECASE, + ) + ) + + +def triage_sections( + analysis: ProgramAnalysis, *, rwx_only: bool = False +) -> list[dict[str, object]]: + rows: list[dict[str, object]] = [] + for item in analysis.segments: + rwx = item.readable and item.writable and item.executable + if rwx_only and not rwx: + continue + rows.append( + { + "name": item.name, + "entropy": section_entropy(analysis, item), + "perms": item.perms, + "size": item.size, + "rwx": rwx, + } + ) + return rows + + +def _int_value(value: object, default: int = 0) -> int: + if not isinstance(value, str | bytes | bytearray | int | float): + return default + try: + return int(value) + except (TypeError, ValueError): + return default + + +def binary_type(analysis: ProgramAnalysis) -> str: + text = f"{analysis.binary.file_type} {analysis.binary.format_name} {analysis.binary.path.suffix}".lower() + if "dll" in text or analysis.binary.path.suffix.lower() == ".dll": + return "DLL" + if "pe" in text or analysis.binary.os_name.lower().startswith("windows"): + return "EXE" + return analysis.binary.file_type or "unknown" + + +def guess_compiler(analysis: ProgramAnalysis) -> str | None: + names = " ".join(item.name for item in analysis.imports.values()).lower() + strings = " ".join(item.value for item in analysis.strings[:500]).lower() + if any( + token in names or token in strings + for token in ("msvcrt", "ucrtbase", "vcruntime", "__security_check_cookie") + ): + return "MSVC" + if "libgcc" in strings or "__gxx" in names: + return "GCC/MinGW" + return None + + +def guess_packed(analysis: ProgramAnalysis) -> bool: + executable = [segment for segment in analysis.segments if segment.executable] + return bool( + [ + segment + for segment in executable + if (section_entropy(analysis, segment) or 0.0) >= 7.2 + ] + and len(analysis.imports) <= 5 + ) + + +def looks_written(line: str) -> bool: + return bool(re.search(r"(?])=(?!=)", line) or "++" in line or "--" in line) + + +def section_entropy(analysis: ProgramAnalysis, segment: Segment) -> float | None: + if segment.entropy is not None: + return segment.entropy + if segment.size <= 0: + return None + try: + with analysis.binary.path.open("rb") as handle: + handle.seek(segment.paddr) + blob = handle.read(segment.size) + except OSError: + return None + if not blob: + return None + segment.entropy = round(shannon_entropy(blob), 6) + return segment.entropy + + +def shannon_entropy(data: bytes) -> float: + if not data: + return 0.0 + counts: dict[int, int] = {} + for byte in data: + counts[byte] = counts.get(byte, 0) + 1 + total = len(data) + return -sum((count / total) * math.log2(count / total) for count in counts.values()) + + +def cluster_id(cluster: Cluster) -> str: + return ( + "utils" if cluster.root == SHARED_CLUSTER_ID else f"cluster_{cluster.root:016x}" + ) + + +def write_json(path: Path, payload: dict[str, object]) -> None: + import json + + path.write_text( + json.dumps(payload, indent=2, sort_keys=False) + "\n", encoding="utf-8" + ) + +``` + +`src/tocode/naming.py`: + +```py +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +import re + +from .schema import Cluster, ProgramAnalysis + + +_IDENT_BAD = re.compile(r"[^0-9A-Za-z_]") +_ANSI = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]") + + +def clean_path_component(value: str) -> str: + parts: list[str] = [] + just_inserted_sep = False + for ch in value: + if ch.isascii() and (ch.isalnum() or ch == "_"): + parts.append(ch) + just_inserted_sep = False + elif not just_inserted_sep: + parts.append("_") + just_inserted_sep = True + return "".join(parts).strip("_") or "unnamed" + + +def clean_c_identifier(value: str) -> str: + text = _IDENT_BAD.sub("_", value) + if not re.search(r"[0-9A-Za-z_]", text): + return "unnamed" + return f"fn_{text}" if text[0].isdigit() else text + + +def default_output_name(binary: Path) -> str: + return f"{clean_path_component(binary.stem or 'binary')}_decompiler" + + +def c_file_name(cluster: Cluster) -> str: + if cluster.root == SHARED_CLUSTER_ID: + return "utils.c" + return f"cluster_{cluster.root:016x}.c" + + +def asm_file_name(cluster: Cluster) -> str: + return c_file_name(cluster).removesuffix(".c") + ".asm" + + +def summary_file_name(cluster: Cluster) -> str: + return c_file_name(cluster).removesuffix(".c") + ".summary" + + +SHARED_CLUSTER_ID = 0xFFFFFFFFFFFFFFFF + + +class _Allocator: + def __init__(self) -> None: + self.used: set[str] = set() + + def claim(self, raw: str | None, fallback: str) -> str: + base = clean_c_identifier(raw or "") + if base == "unnamed": + base = clean_c_identifier(fallback) + name = base + if name not in self.used: + self.used.add(name) + return name + suffix = clean_c_identifier(fallback) + name = f"{base}_{suffix}" if suffix != base else f"{base}_dup" + while name in self.used: + name = f"{name}_dup" + self.used.add(name) + return name + + +@dataclass(slots=True) +class NameBook: + functions: dict[int, str] + imports: dict[int, str] + aliases: dict[str, str] + _pattern: re.Pattern[str] | None = field(default=None, init=False, repr=False) + + def __post_init__(self) -> None: + keys = sorted( + (k for k, v in self.aliases.items() if k and k != v), key=len, reverse=True + ) + if keys: + joined = "|".join(re.escape(key) for key in keys) + self._pattern = re.compile( + rf"(? str: + return self.functions.get(address, clean_c_identifier(fallback)) + + def import_name(self, address: int, fallback: str) -> str: + return self.imports.get(address, clean_c_identifier(fallback)) + + def rewrite(self, text: str) -> str: + stripped = _ANSI.sub("", text) + if self._pattern is None: + return stripped + return self._pattern.sub(lambda match: self.aliases[match.group(0)], stripped) + + +def build_name_book(analysis: ProgramAnalysis) -> NameBook: + allocator = _Allocator() + function_names: dict[int, str] = {} + import_names: dict[int, str] = {} + aliases: dict[str, str] = {} + + for address, routine in sorted(analysis.routines.items()): + if routine.imported: + continue + c_name = allocator.claim(routine.name, f"sub_{address:x}") + function_names[address] = c_name + _alias(aliases, c_name, routine.name) + + for address, imported in sorted(analysis.imports.items()): + c_name = allocator.claim(imported.name, f"imp_{address:x}") + import_names[address] = c_name + _alias( + aliases, + c_name, + imported.name, + f"sym.imp.{imported.name}", + f"loc.imp.{imported.name}", + ) + + for symbol in sorted( + analysis.symbols, key=lambda s: (s.vaddr, s.name, s.flag_name) + ): + existing = function_names.get(symbol.vaddr) or import_names.get(symbol.vaddr) + if existing is None: + c_name = allocator.claim( + symbol.flag_name or symbol.real_name or symbol.name, + f"sym_{symbol.vaddr:x}", + ) + else: + c_name = existing + _alias(aliases, c_name, symbol.name, symbol.flag_name, symbol.real_name) + + for relocation in sorted(analysis.relocations, key=lambda r: (r.vaddr, r.name)): + raw = relocation.name or f"reloc_{relocation.vaddr:x}" + c_name = allocator.claim(f"reloc_{raw}", f"reloc_{relocation.vaddr:x}") + _alias(aliases, c_name, raw, f"reloc.{raw}") + + for flag in sorted(analysis.flags, key=lambda f: (f.offset, f.name)): + existing = function_names.get(flag.offset) or import_names.get(flag.offset) + if existing is None: + c_name = allocator.claim( + flag.name or flag.real_name, f"flag_{flag.offset:x}" + ) + else: + c_name = existing + _alias(aliases, c_name, flag.name, flag.real_name) + + return NameBook(function_names, import_names, aliases) + + +def normalize_source(text: str, names: NameBook) -> str: + lines = names.rewrite(text).splitlines() + while lines and not lines[0].strip(): + lines.pop(0) + while lines and _warning(lines[0]): + lines.pop(0) + while lines and not lines[0].strip(): + lines.pop(0) + return "\n".join(lines).rstrip() + + +def _alias(target: dict[str, str], canonical: str, *raw_names: str | None) -> None: + for raw in raw_names: + if raw: + target.setdefault(raw, canonical) + + +def _warning(line: str) -> bool: + value = line.strip() + return value.startswith("//WARNING:") or value.startswith("WARNING:") + +``` + +`src/tocode/parallel.py`: + +```py +from __future__ import annotations + +import math +import os + + +FAST_ANALYSIS_SECONDS = 5.0 +MIN_FUNCTIONS_FOR_AUTO = 32 +MAX_AUTO_JOBS = 8 +MAX_AUTO_IDA_JOBS = 4 +FUNCTIONS_PER_WORKER = 32 +DEFAULT_JOB_LIMIT = 16 + + +def choose_jobs( + *, + function_count: int, + analysis_seconds: float | None, + requested: int | None, + backend: str, + cpu_count: int | None = None, + job_limit: int | None = None, +) -> int: + limit = job_limit if job_limit is not None else configured_job_limit() + if requested is not None: + return max(1, min(requested, function_count or 1, limit)) + + if function_count < MIN_FUNCTIONS_FOR_AUTO or analysis_seconds is None: + return 1 + if backend.lower() != "ida" and analysis_seconds > FAST_ANALYSIS_SECONDS: + return 1 + + cpus = cpu_count if cpu_count is not None else (os.cpu_count() or 1) + backend_limit = MAX_AUTO_IDA_JOBS if backend.lower() == "ida" else MAX_AUTO_JOBS + ceiling = min(cpus, backend_limit, limit, function_count) + target = math.ceil(function_count / FUNCTIONS_PER_WORKER) + return max(1, min(ceiling, target)) + + +def describe_jobs( + *, + function_count: int, + analysis_seconds: float | None, + requested: int | None, + selected: int, + backend: str, +) -> str: + if requested is not None: + return f"Workers: {selected} requested" + if function_count < MIN_FUNCTIONS_FOR_AUTO: + return f"Workers: 1 for {function_count} functions" + if analysis_seconds is None: + return "Workers: 1, no analysis timing" + if selected == 1: + return f"Workers: 1 after {analysis_seconds:.2f}s analysis" + if backend.lower() == "ida": + return f"Workers: {selected}, IDA cache ready" + return f"Workers: {selected} after {analysis_seconds:.2f}s analysis" + + +def configured_job_limit() -> int: + raw = os.environ.get("TOCODE_MAX_DECOMPILE_WORKERS", str(DEFAULT_JOB_LIMIT)).strip() + try: + value = int(raw) + except ValueError: + return DEFAULT_JOB_LIMIT + return max(1, value) + +``` + +`src/tocode/progress.py`: + +```py +from __future__ import annotations + +from contextlib import contextmanager +import sys +from typing import Any, Iterator, Protocol + +_tqdm_module: Any +try: # pragma: no cover - dependency presence is covered by CLI/export tests. + import tqdm as _loaded_tqdm_module +except ImportError: # pragma: no cover + _tqdm_module = None +else: + _tqdm_module = _loaded_tqdm_module + +_tqdm: Any = getattr(_tqdm_module, "tqdm", None) + + +class _NullProgress: + def update(self, _value: int = 1) -> None: + return + + def close(self) -> None: + return + + +class ProgressBar(Protocol): + def update(self, value: int = 1) -> object: ... + + def close(self) -> None: ... + + +class Progress: + def __init__(self, *, enabled: bool = True) -> None: + self.enabled = enabled + + def log(self, message: str) -> None: + if self.enabled: + print(message, file=sys.stderr) + + @contextmanager + def bar(self, *, total: int, desc: str, unit: str) -> Iterator[ProgressBar]: + tqdm_factory: Any = _tqdm + if not self.enabled or tqdm_factory is None: + yield _NullProgress() + return + bar = tqdm_factory( + total=total, desc=desc, unit=unit, leave=False, dynamic_ncols=True + ) + try: + yield bar + finally: + bar.close() + +``` + +`src/tocode/schema.py`: + +```py +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path + + +@dataclass(slots=True) +class Segment: + name: str + size: int + vsize: int + kind: str + perms: str + paddr: int + vaddr: int + entropy: float | None = None + + @property + def readable(self) -> bool: + return "r" in self.perms + + @property + def writable(self) -> bool: + return "w" in self.perms + + @property + def executable(self) -> bool: + return "x" in self.perms + + +@dataclass(slots=True) +class Routine: + address: int + name: str + size: int + signature: str | None + calltype: str | None + noreturn: bool + stack_size: int + locals_count: int + args_count: int + outdegree: int + indegree: int + imported: bool = False + library: bool = False + thunk: bool = False + code_kind: str = "app" + segment: str | None = None + + +@dataclass(slots=True) +class ImportEntry: + name: str + address: int + bind: str | None = None + kind: str | None = None + dll: str | None = None + delay: bool = False + + +@dataclass(slots=True) +class ExportEntry: + name: str + address: int + bind: str | None = None + kind: str | None = None + ordinal: int | None = None + forwarder: bool = False + forwarder_target: str | None = None + + +@dataclass(slots=True) +class SymbolEntry: + name: str + flag_name: str + real_name: str + size: int + kind: str + vaddr: int + paddr: int + imported: bool + + +@dataclass(slots=True) +class RelocationEntry: + name: str + kind: str + vaddr: int + paddr: int + ifunc: bool + + +@dataclass(slots=True) +class StringEntry: + vaddr: int + paddr: int + size: int + length: int + segment: str + kind: str + value: str + + +@dataclass(slots=True) +class FlagEntry: + name: str + offset: int + size: int + real_name: str | None = None + + +@dataclass(slots=True) +class Cluster: + root: int + label: str + summary: str + members: list[int] + folder: str = "app" + + +@dataclass(slots=True) +class FunctionFailure: + address: int + name: str + message: str + + +@dataclass(slots=True) +class FunctionRange: + address: int + name: str + c_file: Path + c_line_start: int + c_line_end: int + asm_file: Path + asm_line_start: int + asm_line_end: int + + +@dataclass(slots=True) +class RenderedFunction: + address: int + c_name: str + prototype: str + c_text: str + asm_text: str + summary_text: str + failure: FunctionFailure | None = None + + +@dataclass(slots=True) +class BinaryFacts: + path: Path + arch: str + bits: int + image_base: int + os_name: str + format_name: str + file_type: str + entrypoints: list[int] + + @property + def pointer_size(self) -> int: + if self.bits >= 64: + return 8 + if self.bits >= 32: + return 4 + if self.bits >= 16: + return 2 + return 1 + + +@dataclass(slots=True) +class ProgramAnalysis: + binary: BinaryFacts + segments: list[Segment] + routines: dict[int, Routine] + imports: dict[int, ImportEntry] + exports: list[ExportEntry] + symbols: list[SymbolEntry] + relocations: list[RelocationEntry] + strings: list[StringEntry] + flags: list[FlagEntry] + callees: dict[int, list[int]] + callers: dict[int, list[int]] + import_calls: dict[int, list[str]] + roots: list[int] + thunks: set[int] + _app_cache: list[Routine] | None = field(default=None, init=False, repr=False) + + def segment_at(self, address: int) -> Segment | None: + for segment in self.segments: + end = segment.vaddr + max(segment.vsize, segment.size) + if segment.vaddr <= address < end: + return segment + return None + + def app_routines(self) -> list[Routine]: + if self._app_cache is None: + self._app_cache = [ + routine + for routine in self.routines.values() + if not routine.imported + and routine.code_kind == "app" + and not routine.library + ] + return self._app_cache + + def support_routines(self) -> list[Routine]: + return [ + routine + for routine in self.routines.values() + if not routine.imported and routine.code_kind != "app" + ] + + +@dataclass(slots=True) +class ExportSummary: + root_dir: Path + raw_src_dir: Path + tree_src_dir: Path | None + include_dir: Path + header_path: Path + source_files: list[Path] + tree_source_files: list[Path] + asm_files: list[Path] + summary_files: list[Path] + function_count: int + cluster_count: int + failed_functions: list[FunctionFailure] = field(default_factory=list) + function_index_path: Path | None = None + tree_function_index_path: Path | None = None + manifest_path: Path | None = None + data_dir: Path | None = None + +``` + +`tests/test_algorithms.py`: + +```py +from pathlib import Path + +import pytest + +from tocode.backends.base import choose_backend, discover_idadir +from tocode.backends.r2 import R2Session +from tocode.cluster import cluster_routines +from tocode.errors import BackendError, ToCodeError +from tocode.naming import SHARED_CLUSTER_ID, clean_c_identifier, clean_path_component +from tocode.parallel import choose_jobs + + +def test_cluster_routines_groups_shared_callees() -> None: + clusters = cluster_routines( + addresses=[0x1000, 0x1100, 0x2000], + roots=[0x1000, 0x2000], + callees={0x1000: [0x1100], 0x2000: [0x1100], 0x1100: []}, + callers={0x1000: [], 0x2000: [], 0x1100: [0x1000, 0x2000]}, + thunks=set(), + ) + + shared = [cluster for cluster in clusters if cluster.root == SHARED_CLUSTER_ID] + + assert shared + assert shared[0].members == [0x1100] + assert {cluster.root for cluster in clusters} >= {0x1000, 0x2000} + + +def test_choose_jobs_caps_auto_ida_parallelism() -> None: + assert ( + choose_jobs( + function_count=300, + analysis_seconds=0.2, + requested=None, + backend="ida", + cpu_count=32, + job_limit=64, + ) + == 4 + ) + + +def test_requested_jobs_are_limited_by_function_count() -> None: + assert ( + choose_jobs( + function_count=3, + analysis_seconds=0.1, + requested=8, + backend="ida", + cpu_count=16, + job_limit=16, + ) + == 3 + ) + + +def test_name_sanitizers_are_c_and_path_safe() -> None: + assert clean_c_identifier("123 bad-name") == "fn_123_bad_name" + assert clean_path_component("../bad name!") == "bad_name" + + +def test_discover_idadir_checks_windows_program_files(tmp_path, monkeypatch) -> None: + install = tmp_path / "IDA Professional 9.2" + (install / "idalib").mkdir(parents=True) + + monkeypatch.delenv("IDADIR", raising=False) + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path / "home")) + monkeypatch.setenv("ProgramFiles", str(tmp_path)) + monkeypatch.delenv("ProgramFiles(x86)", raising=False) + + assert discover_idadir() == install.resolve() + + +def test_ida_database_input_rejects_r2_backend(tmp_path) -> None: + db_path = tmp_path / "sample.idb" + db_path.write_bytes(b"IDA") + + with pytest.raises(ToCodeError): + choose_backend("r2", input_path=db_path) + + +def test_r2_decompiler_probe_reports_missing_sleigh() -> None: + class MissingSleighSession(R2Session): + def __init__(self) -> None: + pass + + def cmd(self, command: str) -> str: + return { + "pdg?": "Usage: pdg", + "pdgL": "", + }[command] + + session = MissingSleighSession() + + with pytest.raises(BackendError, match="r2ghidra SLEIGH languages"): + session.ensure_decompiler() + +``` + +`tests/test_cli.py`: + +```py +import pytest + +from tocode.cli import build_parser, parse_jobs + + +def test_parse_jobs_accepts_auto_and_positive_ints() -> None: + assert parse_jobs("auto") is None + assert parse_jobs("3") == 3 + + +def test_parse_jobs_rejects_zero() -> None: + with pytest.raises(Exception): + parse_jobs("0") + + +def test_parser_uses_tree_as_opt_in_flag() -> None: + parser = build_parser() + + default_args = parser.parse_args(["sample.bin"]) + tree_args = parser.parse_args(["--tree", "sample.bin"]) + + assert default_args.tree is False + assert tree_args.tree is True + + +def test_parser_accepts_short_quiet_flag() -> None: + args = build_parser().parse_args(["-q", "sample.bin"]) + + assert args.quiet is True + +``` + +`tests/test_exporter.py`: + +```py +from __future__ import annotations + +import json +from pathlib import Path + +from tocode.exporter import ( + _worker_spec, + export_binary, + fallback_prototype, + render_one, + tree_safe_function, +) +from tocode.naming import NameBook +from tocode.progress import Progress +from tocode.schema import ( + BinaryFacts, + ProgramAnalysis, + Routine, + Segment, + StringEntry, +) + + +class FakeAnalyzer: + backend_name = "fake" + backend_label = "Fake Backend" + decompiler_label = "Fake Decompiler" + supports_parallel = False + analysis_seconds = 0.01 + + def __init__( + self, + analysis: ProgramAnalysis, + database_path: Path | None = None, + binary_path: Path | None = None, + ) -> None: + self.analysis = analysis + self.binary = binary_path or analysis.binary.path + self.progress = Progress(enabled=False) + self.session = FakeSession(database_path) + + def collect(self) -> ProgramAnalysis: + return self.analysis + + def disasm(self, address: int) -> str: + return f"push rbp\nmov rbp, rsp\n; addr {address:x}" + + def decompile(self, address: int) -> str: + if address == 0x1000: + return "int main(void)\n{\n helper();\n return 0;\n}" + return "int helper(void)\n{\n return 7;\n}" + + def function_summary(self, address: int) -> str: + return f"address: 0x{address:x}\ncallees: 0" + + def cluster_description_from_imports(self, _members: list[int]) -> str: + return "General functions" + + def prepare_parallel_workers(self) -> None: + return + + +class FakeSession: + def __init__(self, database_path: Path | None) -> None: + self._database_path = database_path + + def database_path(self) -> Path | None: + return self._database_path + + +class TrackingSession: + backend_name = "ida" + backend_label = "IDA Domain" + decompiler_label = "Hex-Rays" + parallel_safe = False + analysis_command = None + + def __init__(self) -> None: + self.decompile_calls = 0 + + def disasm(self, _address: int) -> str: + return "xor eax, eax\nretn" + + def function_summary(self, _address: int) -> str: + return "signature: _BOOL8()\ncallees: 0" + + def decompile(self, _address: int) -> str: + self.decompile_calls += 1 + return ( + "_BOOL8 __scrt_is_ucrt_dll_in_use()\n{\n return dword_140005070 != 0;\n}" + ) + + +def test_export_binary_writes_source_tree_and_metadata(tmp_path: Path) -> None: + binary = tmp_path / "sample.bin" + binary.write_bytes(b"\x7fELF" + b"\x00" * 256 + b"hello\x00") + analysis = ProgramAnalysis( + binary=BinaryFacts( + path=binary, + arch="x86", + bits=64, + image_base=0x1000, + os_name="linux", + format_name="elf", + file_type="EXEC", + entrypoints=[0x1000], + ), + segments=[ + Segment(".text", 128, 128, "PROGBITS", "r-x", 0, 0x1000), + Segment(".rodata", 32, 32, "PROGBITS", "r--", 256, 0x2000), + ], + routines={ + 0x1000: Routine( + 0x1000, "main", 48, "int main(void)", None, False, 0, 0, 0, 1, 0 + ), + 0x1050: Routine( + 0x1050, "helper", 32, "int helper(void)", None, False, 0, 0, 0, 0, 1 + ), + }, + imports={}, + exports=[], + symbols=[], + relocations=[], + strings=[StringEntry(0x2000, 256, 6, 5, ".rodata", "ascii", "hello")], + flags=[], + callees={0x1000: [0x1050], 0x1050: []}, + callers={0x1000: [], 0x1050: [0x1000]}, + import_calls={0x1000: [], 0x1050: []}, + roots=[0x1000], + thunks=set(), + ) + + summary = export_binary( + FakeAnalyzer(analysis), # type: ignore[arg-type] + out_dir=tmp_path / "export", + progress=Progress(enabled=False), + jobs=None, + tree=True, + ) + + assert summary.function_count == 2 + assert summary.cluster_count == 1 + assert (summary.root_dir / "AGENTS.md").is_file() + assert (summary.root_dir / "CLAUDE.md").read_text( + encoding="utf-8" + ) == "@./AGENTS.md\n" + assert (summary.root_dir / "project.json").is_file() + assert (summary.root_dir / "export-manifest.json").is_file() + assert ( + summary.root_dir / "src" / "raw" / "app" / "cluster_0000000000001000.c" + ).is_file() + assert ( + summary.root_dir / "src" / "tree" / "app" / "cluster_0000000000001000.c" + ).is_file() + assert (summary.root_dir / "function-index-tree.json").is_file() + assert (summary.root_dir / "include" / "tocode_tree.h").is_file() + assert not (summary.root_dir / "src" / ("ll" + "m")).exists() + assert (summary.root_dir / "data" / "rodata.bin").is_file() + + manifest = json.loads( + (summary.root_dir / "export-manifest.json").read_text(encoding="utf-8") + ) + assert manifest["function_count"] == 2 + assert manifest["claude"].endswith("CLAUDE.md") + assert len(manifest["tree_source_files"]) == 1 + assert manifest["tree_function_index"].endswith("function-index-tree.json") + assert ("ll" + "m_available") not in manifest + + functions = json.loads( + (summary.root_dir / "functions.json").read_text(encoding="utf-8") + ) + first_function = functions["functions"][0] + assert first_function["tree_source_file"] + assert first_function["tree_source_line_start"] + + project = json.loads( + (summary.root_dir / "project.json").read_text(encoding="utf-8") + ) + assert project["claude"].endswith("CLAUDE.md") + + agents = (summary.root_dir / "AGENTS.md").read_text(encoding="utf-8") + assert "ToCode binary export" in agents + assert "oracle/helper" in agents + + triage = json.loads((summary.root_dir / "triage.json").read_text(encoding="utf-8")) + assert "dynamic_api_resolution" not in triage + assert "has_debug_strings" not in triage + assert "embedded_pe" not in triage + assert "embedded_shellcode_hint" not in triage + assert "evasion" not in triage + + +def test_export_binary_publishes_ida_database(tmp_path: Path) -> None: + binary = tmp_path / "sample.bin" + binary.write_bytes(b"\x7fELF" + b"\x00" * 256) + database = tmp_path / "cache.i64" + database.write_bytes(b"IDA database") + analysis = ProgramAnalysis( + binary=BinaryFacts( + path=binary, + arch="x86", + bits=64, + image_base=0x1000, + os_name="linux", + format_name="elf", + file_type="EXEC", + entrypoints=[0x1000], + ), + segments=[Segment(".text", 128, 128, "PROGBITS", "r-x", 0, 0x1000)], + routines={ + 0x1000: Routine( + 0x1000, "main", 48, "int main(void)", None, False, 0, 0, 0, 0, 0 + ), + }, + imports={}, + exports=[], + symbols=[], + relocations=[], + strings=[], + flags=[], + callees={0x1000: []}, + callers={0x1000: []}, + import_calls={0x1000: []}, + roots=[0x1000], + thunks=set(), + ) + + summary = export_binary( + FakeAnalyzer(analysis, database_path=database), # type: ignore[arg-type] + out_dir=tmp_path / "export", + progress=Progress(enabled=False), + jobs=None, + ) + + exported_database = summary.root_dir / "sample.i64" + assert exported_database.read_bytes() == b"IDA database" + + manifest = json.loads( + (summary.root_dir / "export-manifest.json").read_text(encoding="utf-8") + ) + project = json.loads( + (summary.root_dir / "project.json").read_text(encoding="utf-8") + ) + assert manifest["ida_database"] == str(exported_database.resolve()) + assert project["ida_database"] == str(exported_database.resolve()) + + +def test_bool_return_signature_uses_routine_name() -> None: + routine = Routine( + 0x140001FDC, + "__scrt_is_ucrt_dll_in_use", + 12, + "_BOOL8()", + None, + False, + 0, + 0, + 0, + 0, + 3, + ) + names = NameBook( + functions={routine.address: "__scrt_is_ucrt_dll_in_use"}, + imports={}, + aliases={}, + ) + + assert fallback_prototype(routine, 8, names) == "_BOOL8 __scrt_is_ucrt_dll_in_use()" + + +def test_short_non_thunk_routine_is_decompiled() -> None: + routine = Routine( + 0x140001FDC, + "__scrt_is_ucrt_dll_in_use", + 12, + "_BOOL8()", + None, + False, + 0, + 0, + 0, + 0, + 3, + thunk=False, + ) + analysis = ProgramAnalysis( + binary=BinaryFacts( + path=Path("sample.exe"), + arch="x86", + bits=64, + image_base=0x140000000, + os_name="windows", + format_name="pe", + file_type="EXEC", + entrypoints=[routine.address], + ), + segments=[], + routines={routine.address: routine}, + imports={}, + exports=[], + symbols=[], + relocations=[], + strings=[], + flags=[], + callees={routine.address: []}, + callers={routine.address: []}, + import_calls={routine.address: []}, + roots=[routine.address], + thunks=set(), + ) + names = NameBook( + functions={routine.address: "__scrt_is_ucrt_dll_in_use"}, + imports={}, + aliases={}, + ) + session = TrackingSession() + + rendered = render_one(session, analysis, routine, names) + + assert session.decompile_calls == 1 + assert "short routine" not in rendered.c_text + assert "dword_140005070 != 0" in rendered.c_text + + +def test_worker_spec_uses_ida_database_input_for_worker_copies(tmp_path: Path) -> None: + database = tmp_path / "sample.i64" + database.write_bytes(b"IDA database") + analysis = ProgramAnalysis( + binary=BinaryFacts( + path=database, + arch="x86", + bits=64, + image_base=0x1000, + os_name="windows", + format_name="pe", + file_type="EXEC", + entrypoints=[0x1000], + ), + segments=[], + routines={}, + imports={}, + exports=[], + symbols=[], + relocations=[], + strings=[], + flags=[], + callees={}, + callers={}, + import_calls={}, + roots=[], + thunks=set(), + ) + analyzer = FakeAnalyzer(analysis, database_path=database) + analyzer.backend_name = "ida" + + spec = _worker_spec(analyzer) # type: ignore[arg-type] + + assert spec.db_path == database + + +def test_export_binary_can_skip_tree_source(tmp_path: Path) -> None: + binary = tmp_path / "sample.bin" + binary.write_bytes(b"\x7fELF" + b"\x00" * 256) + analysis = ProgramAnalysis( + binary=BinaryFacts( + path=binary, + arch="x86", + bits=64, + image_base=0x1000, + os_name="linux", + format_name="elf", + file_type="EXEC", + entrypoints=[0x1000], + ), + segments=[Segment(".text", 128, 128, "PROGBITS", "r-x", 0, 0x1000)], + routines={ + 0x1000: Routine( + 0x1000, "main", 48, "int main(void)", None, False, 0, 0, 0, 0, 0 + ), + }, + imports={}, + exports=[], + symbols=[], + relocations=[], + strings=[], + flags=[], + callees={0x1000: []}, + callers={0x1000: []}, + import_calls={0x1000: []}, + roots=[0x1000], + thunks=set(), + ) + export_root = tmp_path / "export" + (export_root / "include").mkdir(parents=True) + (export_root / "include" / "tocode_tree.h").write_text("stale", encoding="utf-8") + (export_root / "src" / "tree").mkdir(parents=True) + (export_root / "src" / "tree" / "stale.c").write_text("stale", encoding="utf-8") + (export_root / "function-index-tree.json").write_text("{}", encoding="utf-8") + + summary = export_binary( + FakeAnalyzer(analysis), # type: ignore[arg-type] + out_dir=export_root, + progress=Progress(enabled=False), + jobs=None, + ) + + assert summary.tree_src_dir is None + assert summary.tree_source_files == [] + assert summary.tree_function_index_path is None + assert not (summary.root_dir / "src" / "tree").exists() + assert not (summary.root_dir / "include" / "tocode_tree.h").exists() + assert not (summary.root_dir / "function-index-tree.json").exists() + + +def test_default_output_uses_invoked_binary_path(tmp_path: Path) -> None: + invoked_root = tmp_path / "tmp_view" + ida_root = tmp_path / "ida_view" + invoked_root.mkdir() + ida_root.mkdir() + invoked_binary = invoked_root / "sample.bin" + ida_binary = ida_root / "sample.bin" + invoked_binary.write_bytes(b"\x7fELF" + b"\x00" * 256) + ida_binary.write_bytes(invoked_binary.read_bytes()) + analysis = ProgramAnalysis( + binary=BinaryFacts( + path=ida_binary, + arch="x86", + bits=64, + image_base=0x1000, + os_name="linux", + format_name="elf", + file_type="EXEC", + entrypoints=[0x1000], + ), + segments=[Segment(".text", 128, 128, "PROGBITS", "r-x", 0, 0x1000)], + routines={ + 0x1000: Routine( + 0x1000, "main", 48, "int main(void)", None, False, 0, 0, 0, 0, 0 + ), + }, + imports={}, + exports=[], + symbols=[], + relocations=[], + strings=[], + flags=[], + callees={0x1000: []}, + callers={0x1000: []}, + import_calls={0x1000: []}, + roots=[0x1000], + thunks=set(), + ) + + summary = export_binary( + FakeAnalyzer(analysis, binary_path=invoked_binary), # type: ignore[arg-type] + progress=Progress(enabled=False), + jobs=None, + ) + + assert summary.root_dir == (invoked_root / "sample_decompiler").resolve() + + +def test_tree_safe_function_preserves_scanner_calls() -> None: + source = ( + "__int64 __fastcall sub_1000@(char *a1)\n" + "{\n" + " if ( a1 > 1 )\n" + " strcpy__GLIBC_2_17(dest, a1);\n" + " return 0i64;\n" + "}" + ) + + tree_source = tree_safe_function(source, fallback_name="sub_1000") + + assert "__fastcall" not in tree_source + assert "__int64" not in tree_source + assert "long long sub_1000_x0" in tree_source + assert "if ( a1 > 1 )" in tree_source + assert "strcpy(dest, a1);" in tree_source + assert "strcpy__GLIBC_2_17" not in tree_source + assert "return 0LL;" in tree_source + +``` + +`uv.lock`: + +```lock +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "ida-domain" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idapro" }, + { name = "packaging" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/80/4b99404b9e658e5289e00def5aa0617941565c73782a420bd572e3ffe5cc/ida_domain-0.5.0.tar.gz", hash = "sha256:bc992924f271aa1f75afe359ef1fde9b1bb9e31f445e6e1ddada143af488e394", size = 207012, upload-time = "2026-04-24T07:58:15.004Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/17/3621a7412dca4ea8dd7149c3a64977fc2ad1434a64a21fc1ed8e55acc13e/ida_domain-0.5.0-py3-none-any.whl", hash = "sha256:3963c0d300734c1558f86cd482f4b35ef4af333700371f350e89299e1eb7b634", size = 151350, upload-time = "2026-04-24T07:58:13.294Z" }, +] + +[[package]] +name = "idapro" +version = "0.0.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/c9/7529ea6f5ecc7272342e85b3c7a8087646f347a9052b2e34eab71a3667bc/idapro-0.0.9.tar.gz", hash = "sha256:8a043a89ce507533e502e8f6581a4fb58bade25e8fa6049545b79ec636792e35", size = 986315, upload-time = "2026-05-12T15:03:50.068Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/d9/a455c08b162ec8b16eb9020eb96a6404c2cc4f87ba3b74b8401647b45fe1/idapro-0.0.9-py3-none-any.whl", hash = "sha256:9518ead4d84833a92e6085e9c8204e87ab4b3ae1800267d6c3dc579822311405", size = 2042736, upload-time = "2026-05-12T15:03:48.22Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "r2pipe" +version = "1.9.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/68/1bd2e0d709f3b0d8e112f143797c92074e8b76ad6c1fb44e05420e085e4e/r2pipe-1.9.8.tar.gz", hash = "sha256:87ac3a69cd6466e330364bb47995bf49c2a87aadaf6497b9cd7fdd9dcf24c1df", size = 23828, upload-time = "2026-03-09T19:50:37.928Z" } + +[[package]] +name = "tocode-cli" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "ida-domain" }, + { name = "idapro" }, + { name = "r2pipe" }, + { name = "tqdm" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "ida-domain", specifier = "==0.5.0" }, + { name = "idapro", specifier = "==0.0.9" }, + { name = "pytest", marker = "extra == 'dev'", specifier = "==8.4.2" }, + { name = "r2pipe", specifier = "==1.9.8" }, + { name = "tqdm", specifier = "==4.67.3" }, +] +provides-extras = ["dev"] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +``` \ No newline at end of file