diff --git a/CLAUDE_SKILL.md b/CLAUDE_SKILL.md new file mode 100644 index 0000000..c61c6b3 --- /dev/null +++ b/CLAUDE_SKILL.md @@ -0,0 +1,463 @@ +# Ghidra CLI - Claude Code Skill + +This skill enables Claude Code and other AI agents to efficiently reverse engineer binaries using Ghidra through a powerful CLI interface. + +## Quick Reference + +### Most Common Commands + +```bash +# Count-first workflow (ALWAYS use this pattern) +ghidra query functions --program= --count +ghidra query functions --program= --filter="" --count +ghidra query functions --program= --filter="" --fields=name,address --format=json-compact + +# Query data types +ghidra query functions|strings|imports|exports|memory --program= [options] + +# Decompile +ghidra decompile --program= + +# Dump data +ghidra dump imports|exports|functions|strings --program= [options] +``` + +## Setup & Initialization + +```bash +# First time setup +ghidra init + +# Check installation +ghidra doctor + +# Import a binary +ghidra import --project= + +# Quick analysis (import + analyze + summary) +ghidra quick +``` + +## Universal Query Command + +The `query` command is your primary tool. It supports filtering, field selection, and multiple output formats. + +### Syntax + +```bash +ghidra query --program= [options] +``` + +### Data Types + +- `functions` - All functions +- `strings` - String literals +- `imports` - Imported functions +- `exports` - Exported functions +- `memory` - Memory regions +- `symbols` - Symbol table +- `xrefs` - Cross-references + +### Options + +```bash +--filter="" # Filter results +--fields= # Select specific fields (comma-separated) +--format= # Output format (json, json-compact, table, minimal, count) +--limit= # Max results +--offset= # Skip first n results +--sort= # Sort by field (prefix with - for descending) +--count # Just return count +``` + +## Filter Language + +### Comparison Operators + +```bash +field=value # Exact match +field!=value # Not equal +field>value # Greater than +field>=value # Greater or equal +field + +# Count functions +ghidra query functions --program= --count + +# Count named functions +ghidra query functions --program= --filter="NOT name^FUN_" --count + +# List named functions (minimal output) +ghidra query functions --program= \ + --filter="NOT name^FUN_" \ + --fields=name,address,size \ + --format=json-compact \ + --limit=50 +``` + +### 2. Finding Interesting Functions + +```bash +# Large functions +ghidra query functions --program= \ + --filter="size>1000" \ + --fields=name,address,size \ + --sort=-size \ + --limit=20 + +# Functions with specific keywords +ghidra query functions --program= \ + --filter="name~crypt OR name~encrypt OR name~password" \ + --fields=name,address \ + --format=json-compact + +# Functions that call specific APIs +ghidra query functions --program= \ + --filter="calls~WinExec OR calls~CreateProcess" \ + --format=json-compact +``` + +### 3. String Analysis + +```bash +# Count strings +ghidra query strings --program= --count + +# Find URLs +ghidra query strings --program= \ + --filter="value~http" \ + --fields=value,address \ + --format=minimal + +# Find long strings (potential paths/URLs) +ghidra query strings --program= \ + --filter="length>50" \ + --format=json-compact \ + --limit=20 + +# Find specific keywords +ghidra query strings --program= \ + --filter="value~password OR value~key OR value~token" \ + --format=json-compact +``` + +### 4. Import Analysis + +```bash +# List all imports +ghidra dump imports --program= --format=json-compact + +# Find suspicious imports +ghidra query imports --program= \ + --filter="name IN [CreateProcess,WinExec,ShellExecute,WriteFile,CreateRemoteThread]" \ + --format=json-compact + +# Find crypto imports +ghidra query imports --program= \ + --filter="name~Crypt" \ + --format=json-compact +``` + +### 5. Decompilation + +```bash +# Decompile by address +ghidra decompile 0x401000 --program= + +# Decompile by name +ghidra decompile main --program= + +# Decompile with minimal output +ghidra fn decompile 0x401000 --program= --format=compact +``` + +### 6. Cross-Reference Analysis + +```bash +# Find what calls a function +ghidra query xrefs --program= \ + --filter="to~WinExec" \ + --fields=from,from_function \ + --format=json-compact + +# Find all callers to an address +ghidra xref to 0x401000 --program= --format=json-compact +``` + +## Output Formats + +Choose the right format for your use case: + +- `count` - Just the number (best for checking result size) +- `json-compact` - Minimal JSON (best for LLMs) +- `minimal` - Just addresses/names (good for piping) +- `ids` - Just IDs (good for further queries) +- `table` - Human-readable table (good for display) +- `json` - Full JSON (when you need all data) + +## Best Practices for LLMs + +### 1. Always Count First + +```bash +# BAD: Fetching all data without knowing size +ghidra query functions --program= + +# GOOD: Count first, then filter +ghidra query functions --program= --count +ghidra query functions --program= --filter="size>1000" --count +ghidra query functions --program= --filter="size>1000" --format=json-compact +``` + +### 2. Use Aggressive Filtering + +```bash +# BAD: Fetching then filtering in code +ghidra query functions --program= --format=json + +# GOOD: Filter on Ghidra side +ghidra query functions --program= \ + --filter="size>1000 AND name~crypt" \ + --format=json-compact +``` + +### 3. Select Only Needed Fields + +```bash +# BAD: Getting all fields +ghidra query functions --program= + +# GOOD: Select only what you need +ghidra query functions --program= \ + --fields=name,address,size \ + --format=json-compact +``` + +### 4. Paginate Large Results + +```bash +# Get first page +ghidra query functions --program= --limit=50 + +# Get next page +ghidra query functions --program= --limit=50 --offset=50 +``` + +### 5. Use Appropriate Output Format + +```bash +# For analysis: json-compact +ghidra query functions --program= --format=json-compact + +# For display: table +ghidra query functions --program= --format=table + +# For piping: minimal or ids +ghidra query functions --program= --format=ids +``` + +## Common Analysis Patterns + +### Pattern 1: Find Entry Points + +```bash +# Find main or WinMain +ghidra query functions --program= \ + --filter="name~main OR name~WinMain OR name~DllMain" \ + --format=json-compact +``` + +### Pattern 2: Find Crypto Functions + +```bash +# By name +ghidra query functions --program= \ + --filter="name~crypt OR name~cipher OR name~hash OR name~aes OR name~rsa" \ + --format=json-compact + +# By imports +ghidra query imports --program= \ + --filter="name~Crypt" \ + --format=json-compact +``` + +### Pattern 3: Find Network Functions + +```bash +# By imports +ghidra query imports --program= \ + --filter="name~socket OR name~connect OR name~send OR name~recv OR name~http" \ + --format=json-compact +``` + +### Pattern 4: Find File Operations + +```bash +ghidra query imports --program= \ + --filter="name~File OR name~Read OR name~Write OR name~Create" \ + --format=json-compact +``` + +### Pattern 5: Find Suspicious Strings + +```bash +# Registry keys +ghidra query strings --program= \ + --filter="value~HKEY OR value~Software" \ + --format=json-compact + +# URLs +ghidra query strings --program= \ + --filter="value~http" \ + --format=json-compact + +# Credentials +ghidra query strings --program= \ + --filter="value~password OR value~username OR value~token" \ + --format=json-compact +``` + +## Error Handling + +### Common Errors + +1. **Program not specified**: Use `--program=` or set default with `ghidra set-default program ` +2. **Ghidra not found**: Run `ghidra init` or set `GHIDRA_INSTALL_DIR` +3. **Analysis timeout**: Increase with `set GHIDRA_TIMEOUT=600` +4. **Project not found**: Create with `ghidra project create ` + +### Troubleshooting + +```bash +# Check installation +ghidra doctor + +# List available projects +ghidra project list + +# Show current configuration +ghidra config list +``` + +## Configuration + +### Set Defaults + +```bash +# Set default program (so you don't have to pass --program each time) +ghidra set-default program + +# Set default project +ghidra set-default project +``` + +### Environment Variables + +```bash +# Windows +set GHIDRA_INSTALL_DIR=C:\ghidra\ghidra_11.0 +set GHIDRA_DEFAULT_PROGRAM=malware.exe + +# Unix +export GHIDRA_INSTALL_DIR=/opt/ghidra +export GHIDRA_DEFAULT_PROGRAM=malware.elf +``` + +## Example Workflow + +Here's a complete analysis workflow: + +```bash +# 1. Import and analyze +ghidra import suspicious.exe --project=analysis + +# 2. Get overview +ghidra summary --program=suspicious.exe + +# 3. Count functions +ghidra query functions --program=suspicious.exe --count +# Output: 1247 + +# 4. Count named functions +ghidra query functions --program=suspicious.exe --filter="NOT name^FUN_" --count +# Output: 89 + +# 5. Get named functions +ghidra query functions --program=suspicious.exe \ + --filter="NOT name^FUN_" \ + --fields=name,address,size \ + --format=json-compact + +# 6. Find suspicious imports +ghidra dump imports --program=suspicious.exe \ + --filter="name~Exec OR name~Create OR name~Write" \ + --format=json-compact + +# 7. Find interesting strings +ghidra query strings --program=suspicious.exe \ + --filter="value~http OR value~password" \ + --format=json-compact + +# 8. Decompile interesting functions +ghidra decompile 0x401000 --program=suspicious.exe +``` + +## Tips + +1. **Always count before fetching** - Prevents overwhelming your context +2. **Use filters aggressively** - Pre-filter on Ghidra side +3. **Select minimal fields** - Reduces token usage +4. **Use json-compact format** - Most efficient for LLMs +5. **Set defaults** - Avoids repeating `--program` and `--project` +6. **Paginate large results** - Use `--limit` and `--offset` +7. **Cache results** - Store commonly-used queries in variables + +## Advanced: Chaining Commands + +```bash +# Get list of function addresses, then decompile each +FUNCS=$(ghidra query functions --program= \ + --filter="name~suspicious" \ + --format=ids) + +for addr in $FUNCS; do + ghidra decompile $addr --program= --format=compact +done +``` + +This skill gives you powerful, token-efficient access to Ghidra for binary analysis! diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..498b272 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1348 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "assert_cmd" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c5bcfa8749ac45dd12cb11055aeeb6b27a3895560d60d71e3c23bf979e60514" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "clap_lex" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "comfy-table" +version = "7.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b03b7db8e0b4b2fdad6c551e634134e99ec000e5c8c3b6856c65e8bbaded7a3b" +dependencies = [ + "crossterm", + "unicode-segmentation", + "unicode-width 0.2.2", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags", + "crossterm_winapi", + "document-features", + "parking_lot", + "rustix 1.1.3", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "ghidra-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "assert_cmd", + "atty", + "clap", + "comfy-table", + "csv", + "dirs", + "dunce", + "env_logger", + "lazy_static", + "log", + "pest", + "pest_derive", + "predicates", + "regex", + "serde", + "serde_json", + "serde_yaml", + "strsim", + "tabled", + "tempfile", + "thiserror", + "walkdir", + "which", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jiff" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67e8da4c49d6d9909fe03361f9b620f58898859f5c7aded68351e85e71ecf50" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c84ee7f197eca9a86c6fd6cb771e55eb991632f15f2bc3ca6ec838929e6e78" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "papergrid" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ad43c07024ef767f9160710b3a6773976194758c7919b17e63b863db0bdf7fb" +dependencies = [ + "bytecount", + "fnv", + "unicode-width 0.1.14", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pest" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9eb05c21a464ea704b53158d358a31e6425db2f63a1a7312268b05fe2b75f7" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f9dbced329c441fa79d80472764b1a2c7e57123553b8519b36663a2fb234ed" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bb96d5051a78f44f43c8f712d8e810adb0ebf923fc9ed2655a7f66f63ba8ee5" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "pest_meta" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "portable-atomic" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "predicates" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" + +[[package]] +name = "predicates-tree" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tabled" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c998b0c8b921495196a48aabaf1901ff28be0760136e31604f7967b0792050e" +dependencies = [ + "papergrid", + "tabled_derive", + "unicode-width 0.1.14", +] + +[[package]] +name = "tabled_derive" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c138f99377e5d653a371cdad263615634cfc8467685dfe8e73e2b8e98f44b17" +dependencies = [ + "heck 0.4.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix 1.1.3", + "windows-sys 0.61.2", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "which" +version = "6.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f" +dependencies = [ + "either", + "home", + "rustix 0.38.44", + "winsafe", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "zmij" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac93432f5b761b22864c774aac244fa5c0fd877678a4c37ebf6cf42208f9c9ec" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..a751f4a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,64 @@ +[package] +name = "ghidra-cli" +version = "0.1.0" +edition = "2021" +authors = ["Alexander Kiselev"] +description = "Rust CLI to run Ghidra headless for reverse engineering with Claude Code and other agents" +license = "GPL-3.0" +repository = "http://127.0.0.1:62915/git/akiselev/ghidra-cli" + +[dependencies] +# CLI framework +clap = { version = "4.5", features = ["derive", "env", "cargo"] } + +# Serialization +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +serde_yaml = "0.9" + +# Parsing +pest = "2.7" +pest_derive = "2.7" + +# Output formatting +tabled = "0.15" +comfy-table = "7.1" + +# Error handling +anyhow = "1.0" +thiserror = "1.0" + +# Logging +env_logger = "0.11" +log = "0.4" + +# File system & paths +dirs = "5.0" +tempfile = "3.8" +walkdir = "2.4" + +# Process management +which = "6.0" + +# Regex +regex = "1.10" +lazy_static = "1.4" + +# CSV/TSV +csv = "1.3" + +# String similarity (for fuzzy matching) +strsim = "0.11" + +# Cross-platform support +dunce = "1.0" # Windows path handling +atty = "0.2" # TTY detection + +[dev-dependencies] +assert_cmd = "2.0" +predicates = "3.0" +tempfile = "3.8" + +[[bin]] +name = "ghidra" +path = "src/main.rs" diff --git a/README.md b/README.md index 0d23777..cf1d033 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,425 @@ -# ghidra-cli -Rust cli to run ghidra headless on files so that Claude Code and other agents can reverse engineer stuff +# Ghidra CLI + +A powerful Rust CLI tool for Ghidra reverse engineering, designed for Claude Code and other AI agents to efficiently analyze binaries. + +## Features + +- 🚀 **Universal Query System** - Query any Ghidra data type with a single command +- 🔍 **Advanced Filtering** - Powerful filter language for precise data extraction +- 📊 **Multiple Output Formats** - JSON, CSV, TSV, Table, and more +- 🤖 **LLM-Optimized** - Designed for minimal token usage and maximum efficiency +- 🪟 **Windows-First** - Native Windows support with cross-platform compatibility +- 📦 **Zero Configuration** - Auto-detection of Ghidra installation +- ⚡ **Fast** - Direct headless Ghidra integration + +## Installation + +### Prerequisites + +- [Ghidra](https://ghidra-sre.org/) 10.0 or later +- Rust 1.70+ (for building from source) + +### From Source + +```bash +git clone https://github.com/yourusername/ghidra-cli +cd ghidra-cli +cargo build --release +``` + +The binary will be at `target/release/ghidra.exe` (Windows) or `target/release/ghidra` (Unix). + +### Setup + +1. Run the initialization wizard: +```bash +ghidra init +``` + +2. Set your Ghidra installation (if not auto-detected): +```bash +set GHIDRA_INSTALL_DIR=C:\ghidra\ghidra_11.0 +``` + +3. Verify installation: +```bash +ghidra doctor +``` + +## Quick Start + +```bash +# Quick analysis of a binary +ghidra quick malware.exe + +# Import a binary +ghidra import suspicious.exe --project=analysis + +# Query functions +ghidra query functions --program=suspicious.exe --filter="size>1000" + +# Decompile a function +ghidra decompile 0x401000 --program=suspicious.exe + +# List suspicious imports +ghidra dump imports --program=suspicious.exe --filter="name~Crypt OR name~Process" +``` + +## Universal Query Command + +The `query` command is the primary interface for data extraction: + +```bash +ghidra query [options] +``` + +### Supported Data Types + +- `functions` - All functions in the program +- `strings` - String data +- `imports` - Import table +- `exports` - Export table +- `memory` - Memory regions +- `symbols` - Symbol table +- `xrefs` - Cross-references +- `comments` - All comments +- `types` - Data types + +### Query Options + +```bash +--filter="" # Filter results +--fields= # Select specific fields +--format= # Output format +--limit= # Max results +--offset= # Skip first n results +--sort= # Sort order +--count # Just return count +``` + +### Filter Language + +```bash +# Comparison operators +name=malloc # Exact match +size>1000 # Greater than +address>=0x401000 # Greater or equal + +# String operators +name~crypt # Contains (case-insensitive) +name^sub_ # Starts with +name$_exit # Ends with +name=~"regex" # Regex match + +# Logical operators +name~crypt AND size>500 # AND +name~main OR name~start # OR +NOT name^FUN_ # NOT + +# Special operators +calls EXISTS # Field exists +name IN [malloc,free] # One of values +size>=100 AND size<=1000 # Range +``` + +## Examples + +### Function Analysis + +```bash +# List all functions +ghidra query functions --program=malware.exe + +# Find large functions with crypto in the name +ghidra query functions --program=malware.exe \ + --filter="size>1000 AND name~crypt" \ + --format=json-compact + +# Count unnamed functions +ghidra query functions --program=malware.exe \ + --filter="name^FUN_" \ + --count + +# Get specific fields only +ghidra query functions --program=malware.exe \ + --fields=name,address,size \ + --limit=10 +``` + +### String Analysis + +```bash +# Find HTTP/HTTPS URLs +ghidra query strings --program=malware.exe \ + --filter="value~http" + +# Find long strings (potential paths, URLs) +ghidra query strings --program=malware.exe \ + --filter="length>50" \ + --format=minimal +``` + +### Import Analysis + +```bash +# Find suspicious imports +ghidra query imports --program=malware.exe \ + --filter="name IN [CreateProcess,WinExec,ShellExecute]" + +# Find crypto imports +ghidra query imports --program=malware.exe \ + --filter="name~Crypt" \ + --format=table +``` + +### Memory Analysis + +```bash +# List executable memory regions +ghidra query memory --program=malware.exe \ + --filter="permissions~x" \ + --format=table +``` + +### Decompilation + +```bash +# Decompile a specific function +ghidra decompile 0x401000 --program=malware.exe + +# Decompile by name +ghidra decompile main --program=malware.exe + +# Get compact output +ghidra fn decompile suspicious_func --program=malware.exe \ + --format=compact +``` + +## Specialized Commands + +### Function Commands + +```bash +ghidra fn list [options] # List functions +ghidra fn get [options] # Get function details +ghidra fn decompile [options] # Decompile +ghidra fn calls [options] # What it calls +ghidra fn xrefs [options] # What calls it +``` + +### String Commands + +```bash +ghidra strings [options] # List all strings +ghidra strings refs [options] # Get references +``` + +### Memory Commands + +```bash +ghidra mem map [options] # Memory map +ghidra mem read [options] # Read memory +ghidra mem search [options] # Search for pattern +``` + +### Dump Commands + +```bash +ghidra dump imports [options] # All imports +ghidra dump exports [options] # All exports +ghidra dump functions [options] # All functions +ghidra dump strings [options] # All strings +``` + +## Output Formats + +``` +full - Full human-readable (default for TTY) +compact - One-line summaries +minimal - Just addresses/names +json - Full JSON +json-compact - Minimal JSON +json-stream - NDJSON (one per line) +csv - CSV format +tsv - TSV format +table - Pretty table +ids - Just addresses/IDs +count - Just count +``` + +## Configuration + +### Environment Variables + +```bash +GHIDRA_INSTALL_DIR # Path to Ghidra installation +GHIDRA_PROJECT_DIR # Project directory +GHIDRA_DEFAULT_PROGRAM # Default program to analyze +GHIDRA_DEFAULT_PROJECT # Default project name +GHIDRA_TIMEOUT # Command timeout (seconds) +``` + +### Configuration File + +Located at: +- Windows: `%APPDATA%\ghidra-cli\config.yaml` +- Linux/Mac: `~/.config/ghidra-cli/config.yaml` + +```yaml +ghidra_install_dir: C:\ghidra\ghidra_11.0 +ghidra_project_dir: C:\Users\username\.ghidra-projects +default_program: malware.exe +default_project: analysis +default_output_format: json-compact +default_limit: 1000 +timeout: 300 +``` + +### Set Defaults + +```bash +# Set default program +ghidra set-default program malware.exe + +# Set default project +ghidra set-default project analysis +``` + +## LLM-Optimized Workflow + +For Claude Code and other agents: + +```bash +# 1. Count first (check result size) +ghidra query functions --program=malware.exe --count +# → 1,247 functions + +# 2. Refine filter and count +ghidra query functions --program=malware.exe \ + --filter="NOT name^FUN_" \ + --count +# → 89 named functions + +# 3. Get minimal data +ghidra query functions --program=malware.exe \ + --filter="NOT name^FUN_" \ + --fields=name,address \ + --format=json-compact + +# 4. Deep dive on specific items +ghidra fn decompile
--program=malware.exe \ + --format=compact +``` + +## Project Management + +```bash +# Create project +ghidra project create myproject + +# List projects +ghidra project list + +# Delete project +ghidra project delete myproject +``` + +## Scripting + +### Run Custom Scripts + +```bash +# Run a Python script +ghidra script run my_analysis.py --program=malware.exe -- arg1 arg2 + +# Execute inline Python +ghidra script python "print(currentProgram.getName())" --program=malware.exe + +# Execute inline Java +ghidra script java "println(currentProgram.getName());" --program=malware.exe +``` + +### Built-in Scripts + +The CLI includes built-in scripts for: +- Function listing +- Decompilation +- String extraction +- Import/Export tables +- Memory map +- Cross-references +- Program information + +## Windows-Specific Notes + +### Path Handling + +The CLI handles both Unix-style (`/`) and Windows-style (`\`) paths automatically. + +### Ghidra Installation Detection + +Auto-detection checks these locations: +- `C:\Program Files\Ghidra` +- `C:\Program Files (x86)\Ghidra` +- `C:\ghidra` +- Registry entries (if available) + +### Executable Detection + +Supports common Windows formats: +- `.exe` - Executables +- `.dll` - Dynamic libraries +- `.sys` - System drivers + +## Performance Tips + +1. **Use `--count` first** - Check result size before fetching data +2. **Filter aggressively** - Pre-filter on Ghidra side, not in your code +3. **Select minimal fields** - Use `--fields` to reduce data transfer +4. **Use compact formats** - `json-compact` or `minimal` for LLMs +5. **Paginate large results** - Use `--limit` and `--offset` + +## Troubleshooting + +### Ghidra Not Found + +```bash +# Check doctor +ghidra doctor + +# Set manually +set GHIDRA_INSTALL_DIR=C:\path\to\ghidra + +# Or in config +ghidra config set ghidra_install_dir C:\path\to\ghidra +``` + +### Analysis Timeout + +```bash +# Increase timeout +set GHIDRA_TIMEOUT=600 + +# Or in config +ghidra config set timeout 600 +``` + +### Project Issues + +```bash +# List projects +ghidra project list + +# Delete and recreate +ghidra project delete myproject +ghidra project create myproject +``` + +## License + +GNU General Public License v3.0 - see [LICENSE](LICENSE) for details. + +## Credits + +- [Ghidra](https://ghidra-sre.org/) - NSA's reverse engineering framework +- Built with ❤️ for Claude Code and the AI agent community diff --git a/SUBAGENT.md b/SUBAGENT.md new file mode 100644 index 0000000..a9faf28 --- /dev/null +++ b/SUBAGENT.md @@ -0,0 +1,483 @@ +# Ghidra Binary Analysis Subagent + +## Overview + +This subagent specializes in reverse engineering binaries using Ghidra CLI. It provides efficient, token-optimized access to binary analysis capabilities for Claude Code and other AI agents. + +## When to Use This Subagent + +Use this subagent when you need to: +- Analyze binary executables (PE, ELF, Mach-O) +- Reverse engineer malware or suspicious binaries +- Extract functions, strings, imports/exports from binaries +- Decompile functions to understand behavior +- Find specific patterns in binary code +- Analyze memory layout and structure +- Identify crypto functions, network operations, or file I/O +- Generate reports on binary capabilities + +## Capabilities + +### Data Extraction +- **Functions**: List, filter, and decompile functions +- **Strings**: Extract and search string literals +- **Imports/Exports**: Analyze external dependencies +- **Memory Layout**: View memory regions and permissions +- **Symbols**: Access symbol table +- **Cross-References**: Find call relationships + +### Analysis Features +- **Universal Query System**: Query any data type with powerful filters +- **Advanced Filtering**: Complex boolean expressions with field-level filtering +- **Multiple Output Formats**: JSON, CSV, Table, minimal (token-efficient) +- **Decompilation**: Convert assembly to C-like pseudocode +- **Pattern Matching**: Find specific code patterns and strings + +### LLM Optimizations +- **Count-First Workflow**: Check result sizes before fetching data +- **Field Selection**: Request only needed fields +- **Aggressive Filtering**: Pre-filter on Ghidra side +- **Compact Formats**: Minimal token usage with `json-compact` +- **Pagination**: Handle large datasets efficiently + +## Command Reference + +### Quick Start + +```bash +# Import and analyze a binary +ghidra import --project= + +# Quick analysis (all-in-one) +ghidra quick + +# Get program summary +ghidra summary --program= +``` + +### Universal Query + +```bash +# Query any data type +ghidra query --program= [options] + +# Data types: functions, strings, imports, exports, memory, symbols, xrefs + +# Essential options: + --filter="" # Filter results + --fields= # Select specific fields + --format= # Output format (json, json-compact, table, count) + --limit= # Max results + --count # Just return count +``` + +### Common Queries + +```bash +# List functions with filtering +ghidra query functions --program= \ + --filter="size>1000 AND name~crypt" \ + --fields=name,address,size \ + --format=json-compact + +# Find strings +ghidra query strings --program= \ + --filter="value~http" \ + --format=minimal + +# List imports +ghidra dump imports --program= \ + --filter="name~Crypt" \ + --format=json-compact + +# Get memory map +ghidra query memory --program= --format=table +``` + +### Decompilation + +```bash +# Decompile function by address +ghidra decompile 0x401000 --program= + +# Decompile by name +ghidra decompile main --program= + +# Compact output +ghidra fn decompile --program= --format=compact +``` + +## Filter Language + +### Operators + +``` +Comparison: =, !=, >, >=, <, <= +String: ~ (contains), ^ (starts), $ (ends), =~ (regex) +Logical: AND, OR, NOT, () +Special: EXISTS, IN [val1,val2] +``` + +### Examples + +```bash +# Exact match +name=malloc + +# Numeric comparison +size>1000 + +# String matching (case-insensitive) +name~crypt + +# Boolean logic +name~crypt AND size>500 +(name~main OR name~start) AND NOT name^FUN_ + +# IN operator +name IN [malloc,free,realloc] + +# Field existence +calls EXISTS + +# Complex expression +size>=100 AND size<=1000 AND (name~crypt OR calls~Crypt) +``` + +## Output Formats + +- `count` - Just the number (check result size) +- `json-compact` - Minimal JSON (best for LLMs) +- `minimal` - Addresses/names only (piping) +- `ids` - Just IDs (for further queries) +- `table` - Human-readable (display) +- `json` - Full JSON (complete data) + +## Best Practices + +### 1. Count-First Pattern + +Always check the result size before fetching data: + +```bash +# Step 1: Count +ghidra query functions --program= --count + +# Step 2: Refine filter if needed +ghidra query functions --program= \ + --filter="NOT name^FUN_" \ + --count + +# Step 3: Fetch minimal data +ghidra query functions --program= \ + --filter="NOT name^FUN_" \ + --fields=name,address \ + --format=json-compact \ + --limit=50 +``` + +### 2. Aggressive Filtering + +Filter on Ghidra side, not in your code: + +```bash +# GOOD: Pre-filter +ghidra query functions --program= \ + --filter="size>1000 AND name~crypt" + +# BAD: Fetch all, then filter +ghidra query functions --program= # Then filter in code +``` + +### 3. Field Selection + +Request only what you need: + +```bash +# Only name and address +ghidra query functions --program= \ + --fields=name,address \ + --format=json-compact +``` + +### 4. Use Compact Formats + +Minimize token usage: + +```bash +# For analysis: json-compact +--format=json-compact + +# For display: table +--format=table + +# For piping: minimal or ids +--format=ids +``` + +## Analysis Workflows + +### Initial Reconnaissance + +```bash +# 1. Get summary +ghidra summary --program= + +# 2. Count functions +ghidra query functions --program= --count + +# 3. Count named functions +ghidra query functions --program= \ + --filter="NOT name^FUN_" --count + +# 4. List key functions +ghidra query functions --program= \ + --filter="NOT name^FUN_" \ + --fields=name,address,size \ + --format=json-compact \ + --limit=20 +``` + +### Finding Suspicious Behavior + +```bash +# Network operations +ghidra query imports --program= \ + --filter="name~socket OR name~http OR name~inet" \ + --format=json-compact + +# File operations +ghidra query imports --program= \ + --filter="name~File OR name~Read OR name~Write" \ + --format=json-compact + +# Process operations +ghidra query imports --program= \ + --filter="name~Process OR name~Thread OR name~Exec" \ + --format=json-compact + +# Crypto operations +ghidra query imports --program= \ + --filter="name~Crypt" \ + --format=json-compact +``` + +### String Analysis + +```bash +# URLs +ghidra query strings --program= \ + --filter="value~http" \ + --format=json-compact + +# Registry keys +ghidra query strings --program= \ + --filter="value~HKEY OR value~Software" \ + --format=json-compact + +# Credentials +ghidra query strings --program= \ + --filter="value~password OR value~username OR value~key" \ + --format=json-compact + +# Long strings (paths, URLs) +ghidra query strings --program= \ + --filter="length>50" \ + --format=json-compact +``` + +### Deep Dive Analysis + +```bash +# 1. Find interesting functions +ghidra query functions --program= \ + --filter="name~crypt OR calls~Crypt" \ + --fields=name,address \ + --format=json-compact + +# 2. Decompile each +ghidra decompile
--program= + +# 3. Find cross-references +ghidra xref to
--program= \ + --format=json-compact + +# 4. Analyze callers +ghidra fn calls
--program= \ + --format=json-compact +``` + +## Common Patterns + +### Pattern: Find Entry Point + +```bash +ghidra query functions --program= \ + --filter="name~main OR name~WinMain OR name~DllMain" \ + --format=json-compact +``` + +### Pattern: Find Crypto Functions + +```bash +# By name +ghidra query functions --program= \ + --filter="name~crypt OR name~cipher OR name~aes OR name~rsa" \ + --format=json-compact + +# By imports +ghidra query imports --program= \ + --filter="name~Crypt" \ + --format=json-compact +``` + +### Pattern: Find Large/Complex Functions + +```bash +ghidra query functions --program= \ + --filter="size>2000" \ + --fields=name,address,size \ + --sort=-size \ + --limit=10 \ + --format=json-compact +``` + +### Pattern: Trace Function Calls + +```bash +# What does function call? +ghidra fn calls --program= \ + --format=json-compact + +# What calls this function? +ghidra fn xrefs --program= \ + --format=json-compact +``` + +## Configuration + +### Set Defaults (avoid repeating --program) + +```bash +# Set default program +ghidra set-default program + +# Now you can omit --program +ghidra query functions --count +``` + +### Environment Variables + +```bash +# Windows +set GHIDRA_INSTALL_DIR=C:\ghidra\ghidra_11.0 +set GHIDRA_DEFAULT_PROGRAM=malware.exe + +# Unix +export GHIDRA_INSTALL_DIR=/opt/ghidra +export GHIDRA_DEFAULT_PROGRAM=malware.elf +``` + +## Error Handling + +### Common Issues + +1. **Ghidra not found**: Run `ghidra init` or set `GHIDRA_INSTALL_DIR` +2. **Program not specified**: Use `--program=` or set default +3. **Analysis timeout**: Increase with `set GHIDRA_TIMEOUT=600` +4. **Large result set**: Use `--count` first, then filter more aggressively + +### Troubleshooting + +```bash +# Check installation +ghidra doctor + +# Show configuration +ghidra config list + +# List projects +ghidra project list +``` + +## Performance Considerations + +1. **Always count first** - Prevents context overflow +2. **Filter aggressively** - Reduce data before transfer +3. **Select minimal fields** - Less data = fewer tokens +4. **Use compact formats** - `json-compact` is most efficient +5. **Paginate results** - Use `--limit` for large datasets +6. **Cache results** - Store commonly-used data in variables + +## Example: Complete Analysis + +```bash +# Import binary +ghidra import suspicious.exe --project=analysis + +# Set as default +ghidra set-default program suspicious.exe + +# Overview +ghidra summary + +# Count functions +ghidra query functions --count +# → 1247 + +# Named functions only +ghidra query functions --filter="NOT name^FUN_" --count +# → 89 + +# Get named functions +ghidra query functions \ + --filter="NOT name^FUN_" \ + --fields=name,address,size \ + --format=json-compact + +# Find suspicious imports +ghidra dump imports \ + --filter="name~Exec OR name~Process OR name~Write" \ + --format=json-compact + +# Find URLs/IPs +ghidra query strings \ + --filter="value~http OR value=~\"[0-9]{1,3}\\.[0-9]{1,3}\"" \ + --format=json-compact + +# Decompile interesting functions +ghidra decompile 0x401000 + +# Find what calls it +ghidra xref to 0x401000 --format=json-compact +``` + +## Integration Tips + +This subagent works best when: +- You have a binary file that needs analysis +- You need to understand malware behavior +- You're investigating suspicious executables +- You need to extract specific information (strings, functions, imports) +- You want to generate a report on binary capabilities + +The subagent is optimized for: +- Token efficiency (minimal output) +- Fast queries (count-first pattern) +- Precise filtering (server-side pre-filtering) +- Flexible output (multiple formats) +- Automation-friendly (scriptable) + +## Limitations + +- Requires Ghidra to be installed +- Windows path handling is primary (but cross-platform) +- Initial analysis can be slow for large binaries +- Decompilation quality depends on Ghidra's capabilities +- Complex analysis may require multiple queries + +## Support + +- Run `ghidra doctor` to check installation +- See `README.md` for full documentation +- Check `CLAUDE_SKILL.md` for detailed examples diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..7e44f6b --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,721 @@ +use clap::{Parser, Subcommand, Args}; + +#[derive(Parser)] +#[command(name = "ghidra")] +#[command(version, about = "Rust CLI for Ghidra reverse engineering", long_about = None)] +pub struct Cli { + #[command(subcommand)] + pub command: Commands, + + /// Enable verbose output + #[arg(short, long, global = true)] + pub verbose: bool, + + /// Suppress non-essential output + #[arg(short, long, global = true)] + pub quiet: bool, +} + +#[derive(Subcommand)] +pub enum Commands { + /// Universal query command for any data type + Query(QueryArgs), + + /// Project management commands + Project(ProjectArgs), + + /// Program/binary management commands + #[command(subcommand)] + Program(ProgramCommands), + + /// Function operations + #[command(subcommand, alias = "fn")] + Function(FunctionCommands), + + /// String operations + #[command(subcommand)] + Strings(StringsCommands), + + /// Symbol operations + #[command(subcommand, alias = "sym")] + Symbol(SymbolCommands), + + /// Memory operations + #[command(subcommand, alias = "mem")] + Memory(MemoryCommands), + + /// Cross-reference operations + #[command(subcommand)] + XRef(XRefCommands), + + /// Type operations + #[command(subcommand)] + Type(TypeCommands), + + /// Comment operations + #[command(subcommand)] + Comment(CommentCommands), + + /// Search operations + #[command(subcommand)] + Find(FindCommands), + + /// Graph operations + #[command(subcommand)] + Graph(GraphCommands), + + /// Decompile function + Decompile(DecompileArgs), + + /// Disassemble code + Disasm(DisasmArgs), + + /// Diff operations + #[command(subcommand)] + Diff(DiffCommands), + + /// Dump/export data + #[command(subcommand)] + Dump(DumpCommands), + + /// Patch binary + #[command(subcommand)] + Patch(PatchCommands), + + /// Script execution + #[command(subcommand)] + Script(ScriptCommands), + + /// Batch operations + Batch(BatchArgs), + + /// Configuration management + #[command(subcommand)] + Config(ConfigCommands), + + /// Set default values + SetDefault(SetDefaultArgs), + + /// Quick analysis (import + analyze + summary) + Quick(QuickArgs), + + /// Program summary + Summary(SummaryArgs), + + /// Program statistics + Stats(StatsArgs), + + /// Show version information + Version, + + /// Check Ghidra installation + Doctor, + + /// Initialize configuration + Init, + + /// Import a binary into a project + Import(ImportArgs), + + /// Analyze a program + Analyze(AnalyzeArgs), +} + +#[derive(Args)] +pub struct QueryArgs { + /// Data type to query (functions, strings, imports, etc.) + pub data_type: String, + + /// Target program + #[arg(long, env = "GHIDRA_DEFAULT_PROGRAM")] + pub program: Option, + + /// Project name + #[arg(long, env = "GHIDRA_DEFAULT_PROJECT")] + pub project: Option, + + /// Filter expression + #[arg(short, long)] + pub filter: Option, + + /// Field selection (comma-separated) + #[arg(long)] + pub fields: Option, + + /// Output format + #[arg(long, short = 'o')] + pub format: Option, + + /// Maximum number of results + #[arg(long)] + pub limit: Option, + + /// Skip first N results + #[arg(long)] + pub offset: Option, + + /// Sort by field(s) (comma-separated, prefix with - for descending) + #[arg(long)] + pub sort: Option, + + /// Only return count + #[arg(long)] + pub count: bool, + + /// Output as JSON (shorthand for --format=json) + #[arg(long)] + pub json: bool, +} + +#[derive(Args)] +pub struct ProjectArgs { + #[command(subcommand)] + pub command: ProjectCommands, +} + +#[derive(Subcommand)] +pub enum ProjectCommands { + /// Create a new project + Create { name: String }, + /// List all projects + List, + /// Delete a project + Delete { name: String }, + /// Show project information + Info { name: Option }, +} + +#[derive(Subcommand)] +pub enum ProgramCommands { + /// Close a program + Close(ProgramTargetArgs), + /// Delete a program + Delete(ProgramTargetArgs), + /// Show program information + Info(ProgramTargetArgs), + /// Export program + Export(ExportArgs), +} + +#[derive(Args)] +pub struct ProgramTargetArgs { + #[arg(long)] + pub program: Option, + #[arg(long)] + pub project: Option, +} + +#[derive(Args)] +pub struct ExportArgs { + /// Export format (xml, json, asm, c) + pub format: String, + #[arg(long)] + pub program: Option, + #[arg(long)] + pub project: Option, + /// Output file + #[arg(short, long)] + pub output: Option, +} + +#[derive(Subcommand)] +pub enum FunctionCommands { + /// List all functions + List(QueryOptions), + /// Get function details + Get(FunctionGetArgs), + /// Decompile function + Decompile(FunctionGetArgs), + /// Disassemble function + Disasm(FunctionGetArgs), + /// Get function calls + Calls(FunctionGetArgs), + /// Get cross-references to function + XRefs(FunctionGetArgs), + /// Rename function + Rename(RenameArgs), + /// Create function + Create(CreateFunctionArgs), + /// Delete function + Delete(FunctionGetArgs), +} + +#[derive(Args)] +pub struct FunctionGetArgs { + /// Function address or name + pub target: String, + #[command(flatten)] + pub options: QueryOptions, +} + +#[derive(Args)] +pub struct RenameArgs { + pub old_name: String, + pub new_name: String, + #[arg(long)] + pub program: Option, + #[arg(long)] + pub project: Option, +} + +#[derive(Args)] +pub struct CreateFunctionArgs { + pub address: String, + pub name: Option, + #[arg(long)] + pub program: Option, + #[arg(long)] + pub project: Option, +} + +#[derive(Subcommand)] +pub enum StringsCommands { + /// List all strings + List(QueryOptions), + /// Get references to a string + Refs(StringRefsArgs), +} + +#[derive(Args)] +pub struct StringRefsArgs { + pub string: String, + #[command(flatten)] + pub options: QueryOptions, +} + +#[derive(Subcommand)] +pub enum SymbolCommands { + /// List all symbols + List(QueryOptions), + /// Get symbol details + Get(SymbolGetArgs), + /// Create symbol + Create(CreateSymbolArgs), + /// Delete symbol + Delete(SymbolGetArgs), + /// Rename symbol + Rename(RenameArgs), +} + +#[derive(Args)] +pub struct SymbolGetArgs { + pub name: String, + #[command(flatten)] + pub options: QueryOptions, +} + +#[derive(Args)] +pub struct CreateSymbolArgs { + pub address: String, + pub name: String, + #[arg(long)] + pub program: Option, + #[arg(long)] + pub project: Option, +} + +#[derive(Subcommand)] +pub enum MemoryCommands { + /// Show memory map + Map(QueryOptions), + /// Read memory + Read(MemReadArgs), + /// Write memory + Write(MemWriteArgs), + /// Search memory + Search(MemSearchArgs), +} + +#[derive(Args)] +pub struct MemReadArgs { + pub address: String, + pub size: usize, + #[command(flatten)] + pub options: QueryOptions, +} + +#[derive(Args)] +pub struct MemWriteArgs { + pub address: String, + pub bytes: String, + #[arg(long)] + pub program: Option, + #[arg(long)] + pub project: Option, +} + +#[derive(Args)] +pub struct MemSearchArgs { + pub pattern: String, + #[command(flatten)] + pub options: QueryOptions, +} + +#[derive(Subcommand)] +pub enum XRefCommands { + /// Get cross-references to address + To(XRefArgs), + /// Get cross-references from address + From(XRefArgs), + /// List all cross-references + List(XRefArgs), +} + +#[derive(Args)] +pub struct XRefArgs { + pub address: String, + #[command(flatten)] + pub options: QueryOptions, +} + +#[derive(Subcommand)] +pub enum TypeCommands { + /// List data types + List(QueryOptions), + /// Get type definition + Get(TypeGetArgs), + /// Create type + Create(CreateTypeArgs), + /// Apply type to address + Apply(ApplyTypeArgs), +} + +#[derive(Args)] +pub struct TypeGetArgs { + pub name: String, + #[command(flatten)] + pub options: QueryOptions, +} + +#[derive(Args)] +pub struct CreateTypeArgs { + pub definition: String, + #[arg(long)] + pub program: Option, + #[arg(long)] + pub project: Option, +} + +#[derive(Args)] +pub struct ApplyTypeArgs { + pub address: String, + pub type_name: String, + #[arg(long)] + pub program: Option, + #[arg(long)] + pub project: Option, +} + +#[derive(Subcommand)] +pub enum CommentCommands { + /// List all comments + List(QueryOptions), + /// Get comment at address + Get(CommentGetArgs), + /// Set comment + Set(CommentSetArgs), + /// Delete comment + Delete(CommentGetArgs), +} + +#[derive(Args)] +pub struct CommentGetArgs { + pub address: String, + #[command(flatten)] + pub options: QueryOptions, +} + +#[derive(Args)] +pub struct CommentSetArgs { + pub address: String, + pub text: String, + #[arg(long)] + pub comment_type: Option, + #[arg(long)] + pub program: Option, + #[arg(long)] + pub project: Option, +} + +#[derive(Subcommand)] +pub enum FindCommands { + /// Find strings + String(FindStringArgs), + /// Find byte patterns + Bytes(FindBytesArgs), + /// Find functions + Function(FindFunctionArgs), + /// Find calls to function + Calls(FindCallsArgs), + /// Find crypto constants + Crypto(QueryOptions), + /// Find interesting functions + Interesting(QueryOptions), +} + +#[derive(Args)] +pub struct FindStringArgs { + pub pattern: String, + #[command(flatten)] + pub options: QueryOptions, +} + +#[derive(Args)] +pub struct FindBytesArgs { + pub hex: String, + #[command(flatten)] + pub options: QueryOptions, +} + +#[derive(Args)] +pub struct FindFunctionArgs { + pub pattern: String, + #[command(flatten)] + pub options: QueryOptions, +} + +#[derive(Args)] +pub struct FindCallsArgs { + pub function: String, + #[command(flatten)] + pub options: QueryOptions, +} + +#[derive(Subcommand)] +pub enum GraphCommands { + /// Call graph + Calls(QueryOptions), + /// Get callers of function + Callers(GraphFunctionArgs), + /// Get callees of function + Callees(GraphFunctionArgs), + /// Export graph + Export(GraphExportArgs), +} + +#[derive(Args)] +pub struct GraphFunctionArgs { + pub function: String, + #[arg(long)] + pub depth: Option, + #[command(flatten)] + pub options: QueryOptions, +} + +#[derive(Args)] +pub struct GraphExportArgs { + pub format: String, + #[command(flatten)] + pub options: QueryOptions, +} + +#[derive(Args)] +pub struct DecompileArgs { + pub target: String, + #[command(flatten)] + pub options: QueryOptions, +} + +#[derive(Args)] +pub struct DisasmArgs { + pub address: String, + #[arg(long)] + pub count: Option, + #[command(flatten)] + pub options: QueryOptions, +} + +#[derive(Subcommand)] +pub enum DiffCommands { + /// Compare two programs + Programs(DiffProgramsArgs), + /// Compare functions + Functions(QueryOptions), +} + +#[derive(Args)] +pub struct DiffProgramsArgs { + pub program1: String, + pub program2: String, + #[arg(long)] + pub format: Option, +} + +#[derive(Subcommand)] +pub enum DumpCommands { + /// Dump imports + Imports(QueryOptions), + /// Dump exports + Exports(QueryOptions), + /// Dump functions + Functions(QueryOptions), + /// Dump strings + Strings(QueryOptions), +} + +#[derive(Subcommand)] +pub enum PatchCommands { + /// Patch bytes + Bytes(PatchBytesArgs), + /// NOP instructions + Nop(PatchNopArgs), + /// Export patched binary + Export(PatchExportArgs), +} + +#[derive(Args)] +pub struct PatchBytesArgs { + pub address: String, + pub hex: String, + #[arg(long)] + pub program: Option, + #[arg(long)] + pub project: Option, +} + +#[derive(Args)] +pub struct PatchNopArgs { + pub address: String, + #[arg(long)] + pub count: Option, + #[arg(long)] + pub program: Option, + #[arg(long)] + pub project: Option, +} + +#[derive(Args)] +pub struct PatchExportArgs { + #[arg(short, long)] + pub output: String, + #[arg(long)] + pub program: Option, + #[arg(long)] + pub project: Option, +} + +#[derive(Subcommand)] +pub enum ScriptCommands { + /// Run a script file + Run(ScriptRunArgs), + /// Execute inline Python code + Python(ScriptInlineArgs), + /// Execute inline Java code + Java(ScriptInlineArgs), + /// List available scripts + List, +} + +#[derive(Args)] +pub struct ScriptRunArgs { + pub script_path: String, + #[arg(long)] + pub program: Option, + #[arg(long)] + pub project: Option, + /// Script arguments (after --) + #[arg(last = true)] + pub args: Vec, +} + +#[derive(Args)] +pub struct ScriptInlineArgs { + pub code: String, + #[arg(long)] + pub program: Option, + #[arg(long)] + pub project: Option, +} + +#[derive(Args)] +pub struct BatchArgs { + pub script_file: String, +} + +#[derive(Subcommand)] +pub enum ConfigCommands { + /// List all configuration + List, + /// Get configuration value + Get { key: String }, + /// Set configuration value + Set { key: String, value: String }, + /// Reset configuration + Reset, +} + +#[derive(Args)] +pub struct SetDefaultArgs { + pub kind: String, + pub value: String, +} + +#[derive(Args)] +pub struct QuickArgs { + pub binary: String, + #[arg(long)] + pub project: Option, +} + +#[derive(Args)] +pub struct SummaryArgs { + #[command(flatten)] + pub options: QueryOptions, +} + +#[derive(Args)] +pub struct StatsArgs { + #[command(flatten)] + pub options: QueryOptions, +} + +#[derive(Args)] +pub struct ImportArgs { + pub binary: String, + #[arg(long)] + pub program: Option, + #[arg(long)] + pub project: Option, +} + +#[derive(Args)] +pub struct AnalyzeArgs { + #[arg(long)] + pub program: Option, + #[arg(long)] + pub project: Option, +} + +/// Common query options used across commands +#[derive(Args)] +pub struct QueryOptions { + #[arg(long)] + pub program: Option, + + #[arg(long)] + pub project: Option, + + #[arg(short, long)] + pub filter: Option, + + #[arg(long)] + pub fields: Option, + + #[arg(long, short = 'o')] + pub format: Option, + + #[arg(long)] + pub limit: Option, + + #[arg(long)] + pub offset: Option, + + #[arg(long)] + pub sort: Option, + + #[arg(long)] + pub count: bool, + + #[arg(long)] + pub json: bool, +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..33fa805 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,171 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::fs; +use crate::error::{GhidraError, Result}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + pub ghidra_install_dir: Option, + pub ghidra_project_dir: Option, + pub default_program: Option, + pub default_project: Option, + pub default_output_format: Option, + pub default_limit: Option, + pub timeout: Option, + pub aliases: std::collections::HashMap, +} + +impl Default for Config { + fn default() -> Self { + Self { + ghidra_install_dir: None, + ghidra_project_dir: None, + default_program: None, + default_project: None, + default_output_format: Some("auto".to_string()), + default_limit: Some(1000), + timeout: Some(300), + aliases: std::collections::HashMap::new(), + } + } +} + +impl Config { + pub fn load() -> Result { + let config_path = Self::config_path()?; + + if !config_path.exists() { + return Ok(Self::default()); + } + + let content = fs::read_to_string(&config_path)?; + let config: Config = serde_yaml::from_str(&content)?; + + Ok(config) + } + + pub fn save(&self) -> Result<()> { + let config_path = Self::config_path()?; + + if let Some(parent) = config_path.parent() { + fs::create_dir_all(parent)?; + } + + let content = serde_yaml::to_string(self)?; + fs::write(config_path, content)?; + + Ok(()) + } + + pub fn config_path() -> Result { + let config_dir = dirs::config_dir() + .ok_or_else(|| GhidraError::ConfigError("Could not determine config directory".to_string()))?; + + Ok(config_dir.join("ghidra-cli").join("config.yaml")) + } + + pub fn get_ghidra_install_dir(&self) -> Result { + // Check environment variable first + if let Ok(dir) = std::env::var("GHIDRA_INSTALL_DIR") { + return Ok(PathBuf::from(dir)); + } + + // Check config + if let Some(dir) = &self.ghidra_install_dir { + return Ok(dir.clone()); + } + + // Try to auto-detect on Windows + #[cfg(target_os = "windows")] + { + if let Some(dir) = Self::detect_ghidra_windows() { + return Ok(dir); + } + } + + Err(GhidraError::GhidraNotFound) + } + + pub fn get_project_dir(&self) -> Result { + // Check environment variable first + if let Ok(dir) = std::env::var("GHIDRA_PROJECT_DIR") { + return Ok(PathBuf::from(dir)); + } + + // Check config + if let Some(dir) = &self.ghidra_project_dir { + return Ok(dir.clone()); + } + + // Default to ~/.ghidra-projects + let home = dirs::home_dir() + .ok_or_else(|| GhidraError::ConfigError("Could not determine home directory".to_string()))?; + + Ok(home.join(".ghidra-projects")) + } + + #[cfg(target_os = "windows")] + fn detect_ghidra_windows() -> Option { + // Check common installation paths + let common_paths = vec![ + PathBuf::from("C:\\Program Files\\Ghidra"), + PathBuf::from("C:\\Program Files (x86)\\Ghidra"), + PathBuf::from("C:\\ghidra"), + ]; + + for path in common_paths { + if path.exists() { + // Look for ghidra_* directories + if let Ok(entries) = fs::read_dir(&path) { + for entry in entries.flatten() { + let entry_path = entry.path(); + if entry_path.is_dir() { + let name = entry_path.file_name()?.to_str()?; + if name.starts_with("ghidra_") { + // Check if analyzeHeadless.bat exists + let headless = entry_path.join("support").join("analyzeHeadless.bat"); + if headless.exists() { + return Some(entry_path); + } + } + } + } + } + } + } + + None + } + + pub fn get_timeout(&self) -> u64 { + std::env::var("GHIDRA_TIMEOUT") + .ok() + .and_then(|s| s.parse().ok()) + .or(self.timeout) + .unwrap_or(300) + } + + pub fn get_default_program(&self) -> Option { + std::env::var("GHIDRA_DEFAULT_PROGRAM") + .ok() + .or_else(|| self.default_program.clone()) + } + + pub fn get_default_project(&self) -> Option { + std::env::var("GHIDRA_DEFAULT_PROJECT") + .ok() + .or_else(|| self.default_project.clone()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_config() { + let config = Config::default(); + assert_eq!(config.timeout, Some(300)); + assert_eq!(config.default_limit, Some(1000)); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..6310632 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,57 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum GhidraError { + #[error("Ghidra installation not found. Set GHIDRA_INSTALL_DIR or run 'ghidra init'")] + GhidraNotFound, + + #[error("Ghidra project not found: {0}")] + ProjectNotFound(String), + + #[error("Program not found: {0}")] + ProgramNotFound(String), + + #[error("Failed to execute Ghidra: {0}")] + ExecutionFailed(String), + + #[error("Failed to parse filter: {0}")] + FilterParseError(String), + + #[error("Invalid filter expression: {0}")] + InvalidFilter(String), + + #[error("Field not found: {0}")] + FieldNotFound(String), + + #[error("Invalid format: {0}")] + InvalidFormat(String), + + #[error("Invalid data type: {0}")] + InvalidDataType(String), + + #[error("Configuration error: {0}")] + ConfigError(String), + + #[error("IO error: {0}")] + IoError(#[from] std::io::Error), + + #[error("JSON error: {0}")] + JsonError(#[from] serde_json::Error), + + #[error("YAML error: {0}")] + YamlError(#[from] serde_yaml::Error), + + #[error("Command failed: {0}")] + CommandFailed(String), + + #[error("Invalid address: {0}")] + InvalidAddress(String), + + #[error("Analysis timeout after {0} seconds")] + Timeout(u64), + + #[error("{0}")] + Other(String), +} + +pub type Result = std::result::Result; diff --git a/src/filter.pest b/src/filter.pest new file mode 100644 index 0000000..3fa2a0b --- /dev/null +++ b/src/filter.pest @@ -0,0 +1,36 @@ +WHITESPACE = _{ " " | "\t" | "\n" | "\r" } + +// Main expression +expr = { logical_expr } + +// Logical expressions +logical_expr = { logical_term ~ (logical_op ~ logical_term)* } +logical_term = { logical_not | "(" ~ logical_expr ~ ")" | comparison } +logical_not = { ("NOT" | "!") ~ logical_term } +logical_op = { "AND" | "OR" | "&&" | "||" } + +// Comparison expressions +comparison = { field ~ compare_op ~ value | field ~ string_op ~ string_value | existence_check | in_check } + +// Operators +compare_op = { ">=" | "<=" | "!=" | "=" | ">" | "<" } +string_op = { "=~" | "~" | "^" | "$" } + +// Existence checks +existence_check = { field ~ ("EXISTS" | "EMPTY" | "NULL") } + +// IN operator +in_check = { field ~ "IN" ~ "[" ~ value_list ~ "]" } +value_list = { value ~ ("," ~ value)* } + +// Field access (including nested fields and arrays) +field = @{ identifier ~ ("." ~ identifier | "[" ~ number ~ "]")* } +identifier = @{ (ASCII_ALPHA | "_") ~ (ASCII_ALPHANUMERIC | "_")* } + +// Values +value = { number | hex_number | boolean | quoted_string | identifier } +string_value = { quoted_string | identifier } +number = @{ "-"? ~ ASCII_DIGIT+ ~ ("." ~ ASCII_DIGIT+)? } +hex_number = @{ "0x" ~ ASCII_HEX_DIGIT+ } +boolean = { "true" | "false" | "TRUE" | "FALSE" } +quoted_string = @{ "\"" ~ (!"\"" ~ ANY)* ~ "\"" | "'" ~ (!"'" ~ ANY)* ~ "'" } diff --git a/src/filter/evaluator.rs b/src/filter/evaluator.rs new file mode 100644 index 0000000..7cc99fb --- /dev/null +++ b/src/filter/evaluator.rs @@ -0,0 +1,256 @@ +use regex::Regex; +use serde_json::Value as JsonValue; +use crate::error::{GhidraError, Result}; +use super::{FilterExpr, CompareOp, StringOp, LogicalOp, ExistenceCheck, Value}; + +pub fn evaluate(expr: &FilterExpr, data: &JsonValue) -> Result { + match expr { + FilterExpr::Compare { field, op, value } => { + evaluate_compare(field, *op, value, data) + } + FilterExpr::StringOp { field, op, value } => { + evaluate_string_op(field, *op, value, data) + } + FilterExpr::Logical { op, exprs } => { + evaluate_logical(*op, exprs, data) + } + FilterExpr::Not(inner) => { + Ok(!evaluate(inner, data)?) + } + FilterExpr::Exists { field, check } => { + evaluate_exists(field, *check, data) + } + FilterExpr::In { field, values } => { + evaluate_in(field, values, data) + } + } +} + +fn get_field_value<'a>(field: &str, data: &'a JsonValue) -> Option<&'a JsonValue> { + let parts: Vec<&str> = field.split('.').collect(); + let mut current = data; + + for part in parts { + // Check for array index like "field[0]" + if let Some(bracket_pos) = part.find('[') { + let field_name = &part[..bracket_pos]; + let index_str = &part[bracket_pos + 1..part.len() - 1]; + + current = current.get(field_name)?; + + if let Ok(index) = index_str.parse::() { + current = current.get(index)?; + } else { + return None; + } + } else { + current = current.get(part)?; + } + } + + Some(current) +} + +fn evaluate_compare(field: &str, op: CompareOp, value: &Value, data: &JsonValue) -> Result { + let field_value = get_field_value(field, data); + + if field_value.is_none() { + return Ok(false); + } + + let field_value = field_value.unwrap(); + + match (field_value, value) { + (JsonValue::Number(n), val) => { + let field_num = n.as_f64().unwrap(); + let compare_num = val.as_f64() + .ok_or_else(|| GhidraError::InvalidFilter(format!("Cannot compare number with {:?}", val)))?; + + Ok(match op { + CompareOp::Equal => (field_num - compare_num).abs() < f64::EPSILON, + CompareOp::NotEqual => (field_num - compare_num).abs() >= f64::EPSILON, + CompareOp::Greater => field_num > compare_num, + CompareOp::GreaterEqual => field_num >= compare_num, + CompareOp::Less => field_num < compare_num, + CompareOp::LessEqual => field_num <= compare_num, + }) + } + (JsonValue::String(s), Value::String(val)) => { + Ok(match op { + CompareOp::Equal => s == val, + CompareOp::NotEqual => s != val, + _ => return Err(GhidraError::InvalidFilter("Cannot use numeric comparison on strings".to_string())), + }) + } + (JsonValue::Bool(b), Value::Boolean(val)) => { + Ok(match op { + CompareOp::Equal => *b == *val, + CompareOp::NotEqual => *b != *val, + _ => return Err(GhidraError::InvalidFilter("Cannot use numeric comparison on booleans".to_string())), + }) + } + _ => Ok(false), + } +} + +fn evaluate_string_op(field: &str, op: StringOp, value: &str, data: &JsonValue) -> Result { + let field_value = get_field_value(field, data); + + if field_value.is_none() { + return Ok(false); + } + + let field_str = match field_value.unwrap() { + JsonValue::String(s) => s.to_lowercase(), + JsonValue::Number(n) => n.to_string(), + JsonValue::Bool(b) => b.to_string(), + _ => return Ok(false), + }; + + let value_lower = value.to_lowercase(); + + Ok(match op { + StringOp::Contains => field_str.contains(&value_lower), + StringOp::StartsWith => field_str.starts_with(&value_lower), + StringOp::EndsWith => field_str.ends_with(&value_lower), + StringOp::Regex => { + let re = Regex::new(value) + .map_err(|e| GhidraError::InvalidFilter(format!("Invalid regex: {}", e)))?; + re.is_match(&field_str) + } + }) +} + +fn evaluate_logical(op: LogicalOp, exprs: &[FilterExpr], data: &JsonValue) -> Result { + match op { + LogicalOp::And => { + for expr in exprs { + if !evaluate(expr, data)? { + return Ok(false); + } + } + Ok(true) + } + LogicalOp::Or => { + for expr in exprs { + if evaluate(expr, data)? { + return Ok(true); + } + } + Ok(false) + } + } +} + +fn evaluate_exists(field: &str, check: ExistenceCheck, data: &JsonValue) -> Result { + let field_value = get_field_value(field, data); + + Ok(match check { + ExistenceCheck::Exists => field_value.is_some(), + ExistenceCheck::Empty => { + match field_value { + None => true, + Some(JsonValue::Null) => true, + Some(JsonValue::String(s)) => s.is_empty(), + Some(JsonValue::Array(a)) => a.is_empty(), + Some(JsonValue::Object(o)) => o.is_empty(), + _ => false, + } + } + ExistenceCheck::Null => { + matches!(field_value, None | Some(JsonValue::Null)) + } + }) +} + +fn evaluate_in(field: &str, values: &[Value], data: &JsonValue) -> Result { + let field_value = get_field_value(field, data); + + if field_value.is_none() { + return Ok(false); + } + + let field_value = field_value.unwrap(); + + for val in values { + match (field_value, val) { + (JsonValue::String(s), Value::String(v)) => { + if s.eq_ignore_ascii_case(v) { + return Ok(true); + } + } + (JsonValue::Number(n), v) => { + if let Some(compare_num) = v.as_f64() { + if (n.as_f64().unwrap() - compare_num).abs() < f64::EPSILON { + return Ok(true); + } + } + } + (JsonValue::Bool(b), Value::Boolean(v)) => { + if *b == *v { + return Ok(true); + } + } + _ => {} + } + } + + Ok(false) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_evaluate_compare() { + let data = json!({ + "name": "test", + "size": 100 + }); + + let expr = FilterExpr::Compare { + field: "size".to_string(), + op: CompareOp::Greater, + value: Value::Integer(50), + }; + + assert!(evaluate(&expr, &data).unwrap()); + } + + #[test] + fn test_evaluate_string_op() { + let data = json!({ + "name": "test_function" + }); + + let expr = FilterExpr::StringOp { + field: "name".to_string(), + op: StringOp::Contains, + value: "func".to_string(), + }; + + assert!(evaluate(&expr, &data).unwrap()); + } + + #[test] + fn test_evaluate_nested_field() { + let data = json!({ + "function": { + "name": "test", + "xrefs": { + "count": 10 + } + } + }); + + let expr = FilterExpr::Compare { + field: "function.xrefs.count".to_string(), + op: CompareOp::Greater, + value: Value::Integer(5), + }; + + assert!(evaluate(&expr, &data).unwrap()); + } +} diff --git a/src/filter/mod.rs b/src/filter/mod.rs new file mode 100644 index 0000000..68a58af --- /dev/null +++ b/src/filter/mod.rs @@ -0,0 +1,137 @@ +pub mod parser; +pub mod evaluator; + +use serde::{Deserialize, Serialize}; +use crate::error::Result; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum FilterExpr { + Compare { + field: String, + op: CompareOp, + value: Value, + }, + StringOp { + field: String, + op: StringOp, + value: String, + }, + Logical { + op: LogicalOp, + exprs: Vec, + }, + Not(Box), + Exists { + field: String, + check: ExistenceCheck, + }, + In { + field: String, + values: Vec, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum CompareOp { + Equal, + NotEqual, + Greater, + GreaterEqual, + Less, + LessEqual, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum StringOp { + Contains, // ~ + StartsWith, // ^ + EndsWith, // $ + Regex, // =~ +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum LogicalOp { + And, + Or, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ExistenceCheck { + Exists, + Empty, + Null, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum Value { + String(String), + Number(f64), + Integer(i64), + Boolean(bool), + Hex(u64), +} + +impl Value { + pub fn as_str(&self) -> Option<&str> { + match self { + Value::String(s) => Some(s), + _ => None, + } + } + + pub fn as_f64(&self) -> Option { + match self { + Value::Number(n) => Some(*n), + Value::Integer(i) => Some(*i as f64), + Value::Hex(h) => Some(*h as f64), + _ => None, + } + } + + pub fn as_i64(&self) -> Option { + match self { + Value::Integer(i) => Some(*i), + Value::Number(n) => Some(*n as i64), + Value::Hex(h) => Some(*h as i64), + _ => None, + } + } + + pub fn as_bool(&self) -> Option { + match self { + Value::Boolean(b) => Some(*b), + _ => None, + } + } +} + +pub struct Filter { + pub expr: FilterExpr, +} + +impl Filter { + pub fn parse(input: &str) -> Result { + parser::parse_filter(input) + } + + pub fn evaluate(&self, data: &serde_json::Value) -> Result { + evaluator::evaluate(&self.expr, data) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_simple_filter() { + let filter = Filter::parse("name=test").unwrap(); + assert!(matches!(filter.expr, FilterExpr::Compare { .. })); + } + + #[test] + fn test_parse_logical_filter() { + let filter = Filter::parse("name=test AND size>100").unwrap(); + assert!(matches!(filter.expr, FilterExpr::Logical { .. })); + } +} diff --git a/src/filter/parser.rs b/src/filter/parser.rs new file mode 100644 index 0000000..18c428f --- /dev/null +++ b/src/filter/parser.rs @@ -0,0 +1,270 @@ +use pest::Parser; +use pest_derive::Parser; +use crate::error::{GhidraError, Result}; +use super::{Filter, FilterExpr, CompareOp, StringOp, LogicalOp, ExistenceCheck, Value}; + +#[derive(Parser)] +#[grammar = "filter.pest"] +struct FilterParser; + +pub fn parse_filter(input: &str) -> Result { + let pairs = FilterParser::parse(Rule::expr, input) + .map_err(|e| GhidraError::FilterParseError(format!("{}", e)))?; + + let mut expr = None; + for pair in pairs { + if pair.as_rule() == Rule::expr { + for inner in pair.into_inner() { + if inner.as_rule() == Rule::logical_expr { + expr = Some(parse_logical_expr(inner)?); + } + } + } + } + + expr.map(|e| Filter { expr: e }) + .ok_or_else(|| GhidraError::FilterParseError("Empty expression".to_string())) +} + +fn parse_logical_expr(pair: pest::iterators::Pair) -> Result { + let mut terms = Vec::new(); + let mut ops = Vec::new(); + + for inner in pair.into_inner() { + match inner.as_rule() { + Rule::logical_term => { + terms.push(parse_logical_term(inner)?); + } + Rule::logical_op => { + let op_str = inner.as_str(); + let op = match op_str { + "AND" | "&&" => LogicalOp::And, + "OR" | "||" => LogicalOp::Or, + _ => return Err(GhidraError::FilterParseError(format!("Unknown operator: {}", op_str))), + }; + ops.push(op); + } + _ => {} + } + } + + if terms.is_empty() { + return Err(GhidraError::FilterParseError("No terms in logical expression".to_string())); + } + + if terms.len() == 1 { + return Ok(terms.into_iter().next().unwrap()); + } + + // Build expression tree respecting precedence (AND before OR) + // For simplicity, we'll evaluate left-to-right for now + // TODO: Proper precedence handling + let mut result = terms[0].clone(); + for (i, op) in ops.iter().enumerate() { + result = FilterExpr::Logical { + op: *op, + exprs: vec![result, terms[i + 1].clone()], + }; + } + + Ok(result) +} + +fn parse_logical_term(pair: pest::iterators::Pair) -> Result { + for inner in pair.into_inner() { + match inner.as_rule() { + Rule::logical_not => { + return parse_logical_not(inner); + } + Rule::logical_expr => { + return parse_logical_expr(inner); + } + Rule::comparison => { + return parse_comparison(inner); + } + _ => {} + } + } + Err(GhidraError::FilterParseError("Invalid logical term".to_string())) +} + +fn parse_logical_not(pair: pest::iterators::Pair) -> Result { + for inner in pair.into_inner() { + if inner.as_rule() == Rule::logical_term { + let term = parse_logical_term(inner)?; + return Ok(FilterExpr::Not(Box::new(term))); + } + } + Err(GhidraError::FilterParseError("Invalid NOT expression".to_string())) +} + +fn parse_comparison(pair: pest::iterators::Pair) -> Result { + let mut field = None; + let mut op = None; + let mut value = None; + let mut string_op = None; + let mut existence = None; + let mut in_values = None; + + for inner in pair.into_inner() { + match inner.as_rule() { + Rule::field => { + field = Some(inner.as_str().to_string()); + } + Rule::compare_op => { + let op_str = inner.as_str(); + op = Some(match op_str { + "=" => CompareOp::Equal, + "!=" => CompareOp::NotEqual, + ">" => CompareOp::Greater, + ">=" => CompareOp::GreaterEqual, + "<" => CompareOp::Less, + "<=" => CompareOp::LessEqual, + _ => return Err(GhidraError::FilterParseError(format!("Unknown compare op: {}", op_str))), + }); + } + Rule::string_op => { + let op_str = inner.as_str(); + string_op = Some(match op_str { + "~" => StringOp::Contains, + "^" => StringOp::StartsWith, + "$" => StringOp::EndsWith, + "=~" => StringOp::Regex, + _ => return Err(GhidraError::FilterParseError(format!("Unknown string op: {}", op_str))), + }); + } + Rule::value => { + value = Some(parse_value(inner)?); + } + Rule::string_value => { + value = Some(parse_value(inner)?); + } + Rule::existence_check => { + let check_str = inner.as_str(); + existence = Some(match check_str { + "EXISTS" => ExistenceCheck::Exists, + "EMPTY" => ExistenceCheck::Empty, + "NULL" => ExistenceCheck::Null, + _ => return Err(GhidraError::FilterParseError(format!("Unknown existence check: {}", check_str))), + }); + } + Rule::in_check => { + // Already handled field + continue; + } + Rule::value_list => { + let mut values = Vec::new(); + for val_pair in inner.into_inner() { + if val_pair.as_rule() == Rule::value { + values.push(parse_value(val_pair)?); + } + } + in_values = Some(values); + } + _ => {} + } + } + + let field = field.ok_or_else(|| GhidraError::FilterParseError("Missing field".to_string()))?; + + if let Some(existence_check) = existence { + return Ok(FilterExpr::Exists { + field, + check: existence_check, + }); + } + + if let Some(values) = in_values { + return Ok(FilterExpr::In { field, values }); + } + + if let Some(str_op) = string_op { + let val = value.ok_or_else(|| GhidraError::FilterParseError("Missing value".to_string()))?; + let val_str = match val { + Value::String(s) => s, + _ => return Err(GhidraError::FilterParseError("String operation requires string value".to_string())), + }; + return Ok(FilterExpr::StringOp { + field, + op: str_op, + value: val_str, + }); + } + + if let Some(cmp_op) = op { + let val = value.ok_or_else(|| GhidraError::FilterParseError("Missing value".to_string()))?; + return Ok(FilterExpr::Compare { + field, + op: cmp_op, + value: val, + }); + } + + Err(GhidraError::FilterParseError("Invalid comparison".to_string())) +} + +fn parse_value(pair: pest::iterators::Pair) -> Result { + for inner in pair.into_inner() { + match inner.as_rule() { + Rule::number => { + let num_str = inner.as_str(); + if num_str.contains('.') { + let num = num_str.parse::() + .map_err(|_| GhidraError::FilterParseError(format!("Invalid number: {}", num_str)))?; + return Ok(Value::Number(num)); + } else { + let num = num_str.parse::() + .map_err(|_| GhidraError::FilterParseError(format!("Invalid integer: {}", num_str)))?; + return Ok(Value::Integer(num)); + } + } + Rule::hex_number => { + let hex_str = inner.as_str().trim_start_matches("0x"); + let num = u64::from_str_radix(hex_str, 16) + .map_err(|_| GhidraError::FilterParseError(format!("Invalid hex number: {}", inner.as_str())))?; + return Ok(Value::Hex(num)); + } + Rule::boolean => { + let bool_str = inner.as_str().to_lowercase(); + return Ok(Value::Boolean(bool_str == "true")); + } + Rule::quoted_string => { + let s = inner.as_str(); + let s = s.trim_matches(|c| c == '"' || c == '\''); + return Ok(Value::String(s.to_string())); + } + Rule::identifier => { + return Ok(Value::String(inner.as_str().to_string())); + } + _ => {} + } + } + Err(GhidraError::FilterParseError("Invalid value".to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_simple() { + let filter = parse_filter("name=test").unwrap(); + assert!(matches!(filter.expr, FilterExpr::Compare { .. })); + } + + #[test] + fn test_parse_and() { + let filter = parse_filter("name=test AND size>100").unwrap(); + assert!(matches!(filter.expr, FilterExpr::Logical { .. })); + } + + #[test] + fn test_parse_hex() { + let filter = parse_filter("address=0x401000").unwrap(); + if let FilterExpr::Compare { value, .. } = filter.expr { + assert!(matches!(value, Value::Hex(0x401000))); + } else { + panic!("Expected Compare expression"); + } + } +} diff --git a/src/format/mod.rs b/src/format/mod.rs new file mode 100644 index 0000000..4e672e9 --- /dev/null +++ b/src/format/mod.rs @@ -0,0 +1,264 @@ +use serde::Serialize; +use serde_json::Value as JsonValue; +use crate::error::{GhidraError, Result}; +use comfy_table::{Table, presets::UTF8_FULL}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OutputFormat { + Full, + Compact, + Minimal, + Json, + JsonCompact, + JsonStream, + Csv, + Tsv, + Table, + Ids, + Count, + Tree, + Hex, + Asm, + C, +} + +impl OutputFormat { + pub fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "full" => Ok(Self::Full), + "compact" => Ok(Self::Compact), + "minimal" => Ok(Self::Minimal), + "json" => Ok(Self::Json), + "json-compact" => Ok(Self::JsonCompact), + "json-stream" | "ndjson" => Ok(Self::JsonStream), + "csv" => Ok(Self::Csv), + "tsv" => Ok(Self::Tsv), + "table" => Ok(Self::Table), + "ids" => Ok(Self::Ids), + "count" => Ok(Self::Count), + "tree" => Ok(Self::Tree), + "hex" => Ok(Self::Hex), + "asm" => Ok(Self::Asm), + "c" => Ok(Self::C), + _ => Err(GhidraError::InvalidFormat(format!("Unknown format: {}", s))), + } + } + + pub fn is_human_friendly(&self) -> bool { + matches!(self, Self::Full | Self::Compact | Self::Table | Self::Tree) + } + + pub fn is_machine_friendly(&self) -> bool { + matches!(self, Self::Json | Self::JsonCompact | Self::JsonStream | Self::Csv | Self::Tsv) + } +} + +pub trait Formatter { + fn format(&self, data: &[T], format: OutputFormat) -> Result; +} + +pub struct DefaultFormatter; + +impl Formatter for DefaultFormatter { + fn format(&self, data: &[T], format: OutputFormat) -> Result { + match format { + OutputFormat::Json => { + serde_json::to_string_pretty(data).map_err(|e| e.into()) + } + OutputFormat::JsonCompact => { + serde_json::to_string(data).map_err(|e| e.into()) + } + OutputFormat::JsonStream => { + let mut result = String::new(); + for item in data { + let json = serde_json::to_string(item)?; + result.push_str(&json); + result.push('\n'); + } + Ok(result) + } + OutputFormat::Count => { + Ok(format!("{}", data.len())) + } + OutputFormat::Table => { + format_table(data) + } + OutputFormat::Csv => { + format_csv(data, ',') + } + OutputFormat::Tsv => { + format_csv(data, '\t') + } + OutputFormat::Minimal | OutputFormat::Ids => { + format_minimal(data) + } + _ => { + // For other formats, default to JSON + serde_json::to_string_pretty(data).map_err(|e| e.into()) + } + } + } +} + +fn format_table(data: &[T]) -> Result { + if data.is_empty() { + return Ok("No results".to_string()); + } + + // Convert to JSON values to inspect structure + let json_data: Vec = data.iter() + .map(|item| serde_json::to_value(item)) + .collect::, _>>()?; + + if json_data.is_empty() { + return Ok("No results".to_string()); + } + + // Get all keys from first object + let keys = if let Some(JsonValue::Object(map)) = json_data.first() { + map.keys().cloned().collect::>() + } else { + return Ok(format!("{} results", data.len())); + }; + + let mut table = Table::new(); + table.load_preset(UTF8_FULL); + + // Add header + table.set_header(&keys); + + // Add rows + for item in &json_data { + if let JsonValue::Object(map) = item { + let row: Vec = keys.iter() + .map(|k| { + map.get(k) + .map(|v| format_json_value(v)) + .unwrap_or_else(|| "".to_string()) + }) + .collect(); + table.add_row(row); + } + } + + Ok(table.to_string()) +} + +fn format_csv(data: &[T], delimiter: char) -> Result { + if data.is_empty() { + return Ok(String::new()); + } + + let json_data: Vec = data.iter() + .map(|item| serde_json::to_value(item)) + .collect::, _>>()?; + + if json_data.is_empty() { + return Ok(String::new()); + } + + let keys = if let Some(JsonValue::Object(map)) = json_data.first() { + map.keys().cloned().collect::>() + } else { + return Ok(String::new()); + }; + + let mut result = String::new(); + + // Header + result.push_str(&keys.join(&delimiter.to_string())); + result.push('\n'); + + // Rows + for item in &json_data { + if let JsonValue::Object(map) = item { + let row: Vec = keys.iter() + .map(|k| { + map.get(k) + .map(|v| format_json_value(v)) + .unwrap_or_else(|| "".to_string()) + }) + .collect(); + result.push_str(&row.join(&delimiter.to_string())); + result.push('\n'); + } + } + + Ok(result) +} + +fn format_minimal(data: &[T]) -> Result { + let json_data: Vec = data.iter() + .map(|item| serde_json::to_value(item)) + .collect::, _>>()?; + + let mut result = String::new(); + + for item in &json_data { + if let JsonValue::Object(map) = item { + // Try to get address or name or first field + let value = map.get("address") + .or_else(|| map.get("name")) + .or_else(|| map.get("id")) + .or_else(|| map.values().next()) + .map(|v| format_json_value(v)) + .unwrap_or_else(|| "".to_string()); + + result.push_str(&value); + result.push('\n'); + } else { + result.push_str(&format_json_value(item)); + result.push('\n'); + } + } + + Ok(result) +} + +fn format_json_value(value: &JsonValue) -> String { + match value { + JsonValue::Null => "null".to_string(), + JsonValue::Bool(b) => b.to_string(), + JsonValue::Number(n) => n.to_string(), + JsonValue::String(s) => s.clone(), + JsonValue::Array(arr) => { + format!("[{}]", arr.iter().map(format_json_value).collect::>().join(", ")) + } + JsonValue::Object(_) => serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string()), + } +} + +pub fn auto_detect_format(is_tty: bool) -> OutputFormat { + if is_tty { + OutputFormat::Table + } else { + OutputFormat::JsonCompact + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_format_json() { + let data = vec![ + json!({"name": "test", "value": 123}), + ]; + let formatter = DefaultFormatter; + let result = formatter.format(&data, OutputFormat::Json).unwrap(); + assert!(result.contains("test")); + } + + #[test] + fn test_format_count() { + let data = vec![ + json!({"name": "test1"}), + json!({"name": "test2"}), + ]; + let formatter = DefaultFormatter; + let result = formatter.format(&data, OutputFormat::Count).unwrap(); + assert_eq!(result, "2"); + } +} diff --git a/src/ghidra/data.rs b/src/ghidra/data.rs new file mode 100644 index 0000000..54a5d46 --- /dev/null +++ b/src/ghidra/data.rs @@ -0,0 +1,168 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Function { + pub name: String, + pub address: String, + pub size: u64, + pub signature: Option, + pub entry_point: String, + pub calling_convention: Option, + #[serde(default)] + pub parameters: Vec, + #[serde(default)] + pub local_variables: Vec, + #[serde(default)] + pub calls: Vec, + #[serde(default)] + pub called_by: Vec, + pub decompiled: Option, + pub comment: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Parameter { + pub name: String, + pub data_type: String, + pub ordinal: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocalVariable { + pub name: String, + pub data_type: String, + pub stack_offset: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StringData { + pub address: String, + pub value: String, + pub length: usize, + pub encoding: String, + #[serde(default)] + pub references: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Symbol { + pub name: String, + pub address: String, + pub symbol_type: String, + pub namespace: Option, + pub source: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Import { + pub name: String, + pub address: String, + pub library: String, + pub ordinal: Option, + pub is_external: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Export { + pub name: String, + pub address: String, + pub ordinal: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct XRef { + pub from: String, + pub to: String, + pub ref_type: String, + pub from_function: Option, + pub to_function: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemoryBlock { + pub name: String, + pub start: String, + pub end: String, + pub size: u64, + pub permissions: String, + pub is_initialized: bool, + pub is_loaded: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Section { + pub name: String, + pub address: String, + pub size: u64, + pub virtual_address: String, + pub file_offset: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Comment { + pub address: String, + pub comment_type: String, + pub text: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DataType { + pub name: String, + pub category: String, + pub size: Option, + pub description: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Instruction { + pub address: String, + pub mnemonic: String, + pub operands: String, + pub bytes: String, + pub length: u32, + pub flow_type: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BasicBlock { + pub start: String, + pub end: String, + pub size: u64, + pub instruction_count: u32, + #[serde(default)] + pub successors: Vec, + #[serde(default)] + pub predecessors: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProgramInfo { + pub name: String, + pub executable_path: String, + pub executable_format: String, + pub compiler: Option, + pub language: String, + pub creation_date: Option, + pub image_base: String, + pub min_address: String, + pub max_address: String, + pub function_count: usize, + pub instruction_count: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum GhidraData { + Function(Function), + String(StringData), + Symbol(Symbol), + Import(Import), + Export(Export), + XRef(XRef), + MemoryBlock(MemoryBlock), + Section(Section), + Comment(Comment), + DataType(DataType), + Instruction(Instruction), + BasicBlock(BasicBlock), +} diff --git a/src/ghidra/headless.rs b/src/ghidra/headless.rs new file mode 100644 index 0000000..0ccb557 --- /dev/null +++ b/src/ghidra/headless.rs @@ -0,0 +1,211 @@ +use std::path::Path; +use std::process::Command; +use serde_json::Value as JsonValue; +use crate::error::{GhidraError, Result}; +use super::GhidraClient; +use super::scripts; + +pub struct HeadlessExecutor<'a> { + client: &'a GhidraClient, +} + +impl<'a> HeadlessExecutor<'a> { + pub fn new(client: &'a GhidraClient) -> Self { + Self { client } + } + + pub fn execute_script( + &self, + project_name: &str, + program_name: &str, + script_content: &str, + script_name: &str, + args: &[String], + ) -> Result { + // Save script to disk + let scripts_dir = self.get_scripts_dir()?; + let script_path = scripts::save_script(script_name, script_content, &scripts_dir)?; + + // Execute script + let output = self.run_ghidra_script(project_name, program_name, &script_path, args)?; + + // Parse JSON output + let json: JsonValue = serde_json::from_str(&output) + .map_err(|e| GhidraError::ExecutionFailed(format!("Failed to parse script output: {}", e)))?; + + Ok(json) + } + + fn run_ghidra_script( + &self, + project_name: &str, + program_name: &str, + script_path: &Path, + args: &[String], + ) -> Result { + let project_path = self.client.get_project_path(project_name); + let headless = self.client.get_headless_script(); + + let mut cmd = Command::new(&headless); + cmd.arg(project_path.to_str().unwrap()) + .arg(project_name) + .arg("-process") + .arg(program_name) + .arg("-noanalysis") + .arg("-scriptPath") + .arg(script_path.parent().unwrap().to_str().unwrap()) + .arg("-postScript") + .arg(script_path.file_name().unwrap().to_str().unwrap()); + + for arg in args { + cmd.arg(arg); + } + + // Capture output + let output = cmd.output()?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(GhidraError::ExecutionFailed(format!("Script execution failed: {}", stderr))); + } + + // Extract JSON from output (Ghidra adds some logging we need to skip) + let stdout = String::from_utf8_lossy(&output.stdout); + let json_output = self.extract_json_from_output(&stdout)?; + + Ok(json_output) + } + + fn extract_json_from_output(&self, output: &str) -> Result { + // Find the JSON output in the Ghidra output + // Look for lines starting with { or [ + let lines: Vec<&str> = output.lines().collect(); + + let mut json_start = None; + let mut json_end = None; + let mut brace_count = 0; + + for (i, line) in lines.iter().enumerate() { + let trimmed = line.trim(); + + if json_start.is_none() && (trimmed.starts_with('{') || trimmed.starts_with('[')) { + json_start = Some(i); + brace_count = trimmed.chars().filter(|&c| c == '{' || c == '[').count() as i32; + brace_count -= trimmed.chars().filter(|&c| c == '}' || c == ']').count() as i32; + + if brace_count == 0 { + json_end = Some(i); + break; + } + } else if json_start.is_some() { + brace_count += trimmed.chars().filter(|&c| c == '{' || c == '[').count() as i32; + brace_count -= trimmed.chars().filter(|&c| c == '}' || c == ']').count() as i32; + + if brace_count == 0 { + json_end = Some(i); + break; + } + } + } + + if let (Some(start), Some(end)) = (json_start, json_end) { + let json_lines = &lines[start..=end]; + Ok(json_lines.join("\n")) + } else { + Err(GhidraError::ExecutionFailed("Could not find JSON in script output".to_string())) + } + } + + fn get_scripts_dir(&self) -> Result { + let config_dir = dirs::config_dir() + .ok_or_else(|| GhidraError::ConfigError("Could not determine config directory".to_string()))?; + + let scripts_dir = config_dir.join("ghidra-cli").join("scripts"); + + if !scripts_dir.exists() { + std::fs::create_dir_all(&scripts_dir)?; + } + + Ok(scripts_dir) + } + + pub fn list_functions(&self, project_name: &str, program_name: &str) -> Result { + self.execute_script( + project_name, + program_name, + scripts::get_list_functions_script(), + "list_functions", + &[], + ) + } + + pub fn decompile_function(&self, project_name: &str, program_name: &str, address: &str) -> Result { + self.execute_script( + project_name, + program_name, + scripts::get_decompile_function_script(), + "decompile_function", + &[address.to_string()], + ) + } + + pub fn list_strings(&self, project_name: &str, program_name: &str) -> Result { + self.execute_script( + project_name, + program_name, + scripts::get_list_strings_script(), + "list_strings", + &[], + ) + } + + pub fn list_imports(&self, project_name: &str, program_name: &str) -> Result { + self.execute_script( + project_name, + program_name, + scripts::get_list_imports_script(), + "list_imports", + &[], + ) + } + + pub fn list_exports(&self, project_name: &str, program_name: &str) -> Result { + self.execute_script( + project_name, + program_name, + scripts::get_list_exports_script(), + "list_exports", + &[], + ) + } + + pub fn get_memory_map(&self, project_name: &str, program_name: &str) -> Result { + self.execute_script( + project_name, + program_name, + scripts::get_memory_map_script(), + "memory_map", + &[], + ) + } + + pub fn get_program_info(&self, project_name: &str, program_name: &str) -> Result { + self.execute_script( + project_name, + program_name, + scripts::get_program_info_script(), + "program_info", + &[], + ) + } + + pub fn get_xrefs_to(&self, project_name: &str, program_name: &str, address: &str) -> Result { + self.execute_script( + project_name, + program_name, + scripts::get_xrefs_to_script(), + "xrefs_to", + &[address.to_string()], + ) + } +} diff --git a/src/ghidra/mod.rs b/src/ghidra/mod.rs new file mode 100644 index 0000000..cc6bd62 --- /dev/null +++ b/src/ghidra/mod.rs @@ -0,0 +1,212 @@ +pub mod headless; +pub mod data; +pub mod scripts; + +use std::path::{Path, PathBuf}; +use std::process::Command; +use crate::config::Config; +use crate::error::{GhidraError, Result}; + +pub struct GhidraClient { + config: Config, + install_dir: PathBuf, + project_dir: PathBuf, +} + +impl GhidraClient { + pub fn new(config: Config) -> Result { + let install_dir = config.get_ghidra_install_dir()?; + let project_dir = config.get_project_dir()?; + + // Create project directory if it doesn't exist + if !project_dir.exists() { + std::fs::create_dir_all(&project_dir)?; + } + + Ok(Self { + config, + install_dir, + project_dir, + }) + } + + pub fn get_headless_script(&self) -> PathBuf { + let support_dir = self.install_dir.join("support"); + + #[cfg(target_os = "windows")] + { + support_dir.join("analyzeHeadless.bat") + } + + #[cfg(not(target_os = "windows"))] + { + support_dir.join("analyzeHeadless") + } + } + + pub fn verify_installation(&self) -> Result<()> { + let headless = self.get_headless_script(); + + if !headless.exists() { + return Err(GhidraError::GhidraNotFound); + } + + Ok(()) + } + + pub fn get_project_path(&self, project_name: &str) -> PathBuf { + self.project_dir.join(project_name) + } + + pub fn project_exists(&self, project_name: &str) -> bool { + let project_path = self.get_project_path(project_name); + project_path.exists() && project_path.join(format!("{}.rep", project_name)).exists() + } + + pub fn create_project(&self, project_name: &str) -> Result<()> { + let project_path = self.get_project_path(project_name); + + if self.project_exists(project_name) { + return Ok(()); + } + + std::fs::create_dir_all(&project_path)?; + + // Run headless to create project + let headless = self.get_headless_script(); + let output = Command::new(&headless) + .arg(project_path.to_str().unwrap()) + .arg(project_name) + .output()?; + + if !output.status.success() { + return Err(GhidraError::ExecutionFailed( + String::from_utf8_lossy(&output.stderr).to_string() + )); + } + + Ok(()) + } + + pub fn import_binary(&self, project_name: &str, binary_path: &Path, program_name: Option<&str>) -> Result { + if !self.project_exists(project_name) { + self.create_project(project_name)?; + } + + let program_name = program_name.unwrap_or_else(|| { + binary_path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("program") + }); + + let project_path = self.get_project_path(project_name); + let headless = self.get_headless_script(); + + let output = Command::new(&headless) + .arg(project_path.to_str().unwrap()) + .arg(project_name) + .arg("-import") + .arg(binary_path.to_str().unwrap()) + .arg("-overwrite") + .output()?; + + if !output.status.success() { + return Err(GhidraError::ExecutionFailed( + String::from_utf8_lossy(&output.stderr).to_string() + )); + } + + Ok(program_name.to_string()) + } + + pub fn analyze_program(&self, project_name: &str, program_name: &str) -> Result<()> { + let project_path = self.get_project_path(project_name); + let headless = self.get_headless_script(); + + let output = Command::new(&headless) + .arg(project_path.to_str().unwrap()) + .arg(project_name) + .arg("-process") + .arg(program_name) + .arg("-noanalysis") // Don't auto-analyze + .arg("-scriptPath") + .arg(self.get_scripts_dir()?.to_str().unwrap()) + .output()?; + + if !output.status.success() { + return Err(GhidraError::ExecutionFailed( + String::from_utf8_lossy(&output.stderr).to_string() + )); + } + + Ok(()) + } + + pub fn run_script(&self, project_name: &str, program_name: &str, script_path: &Path, args: &[String]) -> Result { + let project_path = self.get_project_path(project_name); + let headless = self.get_headless_script(); + + let mut cmd = Command::new(&headless); + cmd.arg(project_path.to_str().unwrap()) + .arg(project_name) + .arg("-process") + .arg(program_name) + .arg("-scriptPath") + .arg(script_path.parent().unwrap().to_str().unwrap()) + .arg("-postScript") + .arg(script_path.file_name().unwrap().to_str().unwrap()); + + for arg in args { + cmd.arg(arg); + } + + let output = cmd.output()?; + + if !output.status.success() { + return Err(GhidraError::ExecutionFailed( + String::from_utf8_lossy(&output.stderr).to_string() + )); + } + + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } + + fn get_scripts_dir(&self) -> Result { + let config_dir = dirs::config_dir() + .ok_or_else(|| GhidraError::ConfigError("Could not determine config directory".to_string()))?; + + let scripts_dir = config_dir.join("ghidra-cli").join("scripts"); + + if !scripts_dir.exists() { + std::fs::create_dir_all(&scripts_dir)?; + } + + Ok(scripts_dir) + } + + pub fn get_install_dir(&self) -> &Path { + &self.install_dir + } + + pub fn get_project_dir(&self) -> &Path { + &self.project_dir + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ghidra_client_creation() { + // This will fail if GHIDRA_INSTALL_DIR is not set, which is expected + let config = Config::default(); + let result = GhidraClient::new(config); + + // We can't test this properly without a Ghidra installation + // Just verify the error is what we expect + if result.is_err() { + assert!(matches!(result.unwrap_err(), GhidraError::GhidraNotFound)); + } + } +} diff --git a/src/ghidra/scripts.rs b/src/ghidra/scripts.rs new file mode 100644 index 0000000..5a6383b --- /dev/null +++ b/src/ghidra/scripts.rs @@ -0,0 +1,310 @@ +/// Built-in Ghidra scripts for data extraction +/// These are Python scripts that will be written to disk and executed by Ghidra headless + +pub fn get_list_functions_script() -> &'static str { + r#" +# List all functions in the program +# @category Analysis + +import json + +functions = [] +function_manager = currentProgram.getFunctionManager() + +for func in function_manager.getFunctions(True): + entry = func.getEntryPoint() + body = func.getBody() + + func_data = { + "name": func.getName(), + "address": entry.toString(), + "size": body.getNumAddresses(), + "entry_point": entry.toString(), + "signature": func.getPrototypeString(False, False) if func.getSignature() else None, + "calling_convention": func.getCallingConventionName(), + "comment": func.getComment() + } + + # Get called functions + called = [] + refs = func.getBody().getAddresses(True) + for addr in refs: + for ref in currentProgram.getReferenceManager().getReferencesFrom(addr): + if ref.getReferenceType().isCall(): + to_addr = ref.getToAddress() + to_func = function_manager.getFunctionAt(to_addr) + if to_func: + called.append(to_func.getName()) + + func_data["calls"] = list(set(called)) + + # Get callers + callers = [] + refs_to = currentProgram.getReferenceManager().getReferencesTo(entry) + for ref in refs_to: + if ref.getReferenceType().isCall(): + from_addr = ref.getFromAddress() + from_func = function_manager.getFunctionContaining(from_addr) + if from_func: + callers.append(from_func.getName()) + + func_data["called_by"] = list(set(callers)) + + functions.append(func_data) + +print(json.dumps(functions, indent=2)) +"# +} + +pub fn get_decompile_function_script() -> &'static str { + r#" +# Decompile a specific function +# @category Analysis + +import json +from ghidra.app.decompiler import DecompInterface +from ghidra.util.task import ConsoleTaskMonitor + +# Get function address from args +if len(args) < 1: + print(json.dumps({"error": "No address provided"})) + exit(1) + +addr_str = args[0] +addr = currentProgram.getAddressFactory().getAddress(addr_str) + +function_manager = currentProgram.getFunctionManager() +func = function_manager.getFunctionContaining(addr) + +if not func: + print(json.dumps({"error": "No function at address " + addr_str})) + exit(1) + +# Decompile +decompiler = DecompInterface() +decompiler.openProgram(currentProgram) + +monitor = ConsoleTaskMonitor() +results = decompiler.decompileFunction(func, 30, monitor) + +if results.decompileCompleted(): + code = results.getDecompiledFunction().getC() + + result = { + "name": func.getName(), + "address": func.getEntryPoint().toString(), + "signature": func.getPrototypeString(False, False), + "code": code + } + + print(json.dumps(result, indent=2)) +else: + print(json.dumps({"error": "Decompilation failed"})) +"# +} + +pub fn get_list_strings_script() -> &'static str { + r#" +# List all strings in the program +# @category Analysis + +import json + +strings = [] +string_table = currentProgram.getListing().getDefinedData(True) + +for data in string_table: + if data.hasStringValue(): + string_data = { + "address": data.getAddress().toString(), + "value": data.getValue().toString(), + "length": len(data.getValue().toString()), + "encoding": "ascii" + } + + # Get references to this string + refs = [] + refs_to = currentProgram.getReferenceManager().getReferencesTo(data.getAddress()) + for ref in refs_to: + refs.append(ref.getFromAddress().toString()) + + string_data["references"] = refs + strings.append(string_data) + +print(json.dumps(strings, indent=2)) +"# +} + +pub fn get_list_imports_script() -> &'static str { + r#" +# List all imports in the program +# @category Analysis + +import json + +imports = [] +symbol_table = currentProgram.getSymbolTable() +external_manager = currentProgram.getExternalManager() + +for symbol in symbol_table.getExternalSymbols(): + external_location = external_manager.getExternalLocation(symbol) + + if external_location: + import_data = { + "name": symbol.getName(), + "address": symbol.getAddress().toString(), + "library": external_location.getLibraryName(), + "is_external": True + } + imports.append(import_data) + +print(json.dumps(imports, indent=2)) +"# +} + +pub fn get_list_exports_script() -> &'static str { + r#" +# List all exports in the program +# @category Analysis + +import json + +exports = [] +symbol_table = currentProgram.getSymbolTable() + +for symbol in symbol_table.getSymbolIterator(): + if symbol.isExternalEntryPoint(): + export_data = { + "name": symbol.getName(), + "address": symbol.getAddress().toString() + } + exports.append(export_data) + +print(json.dumps(exports, indent=2)) +"# +} + +pub fn get_memory_map_script() -> &'static str { + r#" +# Get memory map +# @category Analysis + +import json + +blocks = [] +memory = currentProgram.getMemory() + +for block in memory.getBlocks(): + block_data = { + "name": block.getName(), + "start": block.getStart().toString(), + "end": block.getEnd().toString(), + "size": block.getSize(), + "permissions": "", + "is_initialized": block.isInitialized(), + "is_loaded": block.isLoaded() + } + + # Build permissions string + perms = "" + if block.isRead(): + perms += "r" + if block.isWrite(): + perms += "w" + if block.isExecute(): + perms += "x" + + block_data["permissions"] = perms + blocks.append(block_data) + +print(json.dumps(blocks, indent=2)) +"# +} + +pub fn get_program_info_script() -> &'static str { + r#" +# Get program information +# @category Analysis + +import json + +info = { + "name": currentProgram.getName(), + "executable_path": currentProgram.getExecutablePath(), + "executable_format": currentProgram.getExecutableFormat(), + "compiler": currentProgram.getCompiler() if currentProgram.getCompiler() else None, + "language": currentProgram.getLanguage().toString(), + "image_base": currentProgram.getImageBase().toString(), + "min_address": currentProgram.getMinAddress().toString(), + "max_address": currentProgram.getMaxAddress().toString() +} + +# Count functions and instructions +function_manager = currentProgram.getFunctionManager() +info["function_count"] = function_manager.getFunctionCount() + +instruction_count = 0 +listing = currentProgram.getListing() +for instruction in listing.getInstructions(True): + instruction_count += 1 + +info["instruction_count"] = instruction_count + +print(json.dumps(info, indent=2)) +"# +} + +pub fn get_xrefs_to_script() -> &'static str { + r#" +# Get cross-references to an address +# @category Analysis + +import json + +if len(args) < 1: + print(json.dumps({"error": "No address provided"})) + exit(1) + +addr_str = args[0] +addr = currentProgram.getAddressFactory().getAddress(addr_str) + +xrefs = [] +refs = currentProgram.getReferenceManager().getReferencesTo(addr) +function_manager = currentProgram.getFunctionManager() + +for ref in refs: + from_addr = ref.getFromAddress() + from_func = function_manager.getFunctionContaining(from_addr) + to_func = function_manager.getFunctionContaining(addr) + + xref_data = { + "from": from_addr.toString(), + "to": addr.toString(), + "ref_type": ref.getReferenceType().toString(), + "from_function": from_func.getName() if from_func else None, + "to_function": to_func.getName() if to_func else None + } + xrefs.append(xref_data) + +print(json.dumps(xrefs, indent=2)) +"# +} + +/// Save a script to disk +pub fn save_script(name: &str, content: &str, scripts_dir: &std::path::Path) -> crate::error::Result { + let script_path = scripts_dir.join(format!("{}.py", name)); + std::fs::write(&script_path, content)?; + Ok(script_path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_scripts_not_empty() { + assert!(!get_list_functions_script().is_empty()); + assert!(!get_decompile_function_script().is_empty()); + assert!(!get_list_strings_script().is_empty()); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..f47b850 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,670 @@ +mod cli; +mod config; +mod error; +mod filter; +mod format; +mod ghidra; +mod query; + +use clap::Parser; +use cli::{Cli, Commands, QueryArgs, QueryOptions}; +use config::Config; +use error::{GhidraError, Result}; +use format::OutputFormat; +use ghidra::GhidraClient; +use query::{Query, DataType, FieldSelector, SortKey}; +use std::path::PathBuf; + +fn main() { + env_logger::init(); + + let cli = Cli::parse(); + + if let Err(e) = run(cli) { + eprintln!("Error: {}", e); + std::process::exit(1); + } +} + +fn run(cli: Cli) -> Result<()> { + match cli.command { + Commands::Query(args) => handle_query(args), + Commands::Init => handle_init(), + Commands::Doctor => handle_doctor(), + Commands::Version => handle_version(), + Commands::Import(args) => handle_import(args), + Commands::Analyze(args) => handle_analyze(args), + Commands::Summary(args) => handle_summary(args.options), + Commands::Quick(args) => handle_quick(args), + Commands::Function(cmd) => handle_function_command(cmd), + Commands::Strings(cmd) => handle_strings_command(cmd), + Commands::Memory(cmd) => handle_memory_command(cmd), + Commands::Dump(cmd) => handle_dump_command(cmd), + Commands::Decompile(args) => handle_decompile(args), + Commands::Config(cmd) => handle_config_command(cmd), + Commands::SetDefault(args) => handle_set_default(args), + Commands::Project(args) => handle_project_command(args.command), + _ => { + println!("Command not yet implemented"); + Ok(()) + } + } +} + +fn handle_query(args: QueryArgs) -> Result<()> { + let config = Config::load()?; + let client = GhidraClient::new(config.clone())?; + + // Resolve program and project + let program = resolve_program(&args.program, &config)?; + let project = resolve_project(&args.project, &config, &program)?; + + // Parse data type + let data_type = DataType::from_str(&args.data_type)?; + + // Build query + let mut query = Query::new(data_type); + + // Add filter if provided + if let Some(filter_str) = args.filter { + let filter = filter::Filter::parse(&filter_str)?; + query = query.with_filter(filter); + } + + // Add format + let format = if args.json { + OutputFormat::Json + } else if let Some(fmt) = args.format { + OutputFormat::from_str(&fmt)? + } else { + // Auto-detect based on TTY + format::auto_detect_format(atty::is(atty::Stream::Stdout)) + }; + query = query.with_format(format); + + // Add limit + if let Some(limit) = args.limit { + query = query.with_limit(limit); + } + + // Add offset + if let Some(offset) = args.offset { + query = query.with_offset(offset); + } + + // Add sort + if let Some(sort_str) = args.sort { + let sort_keys = SortKey::parse(&sort_str); + query.sort = Some(sort_keys); + } + + // Add field selection + if let Some(fields_str) = args.fields { + let selector = FieldSelector::parse(&fields_str)?; + query.fields = Some(selector); + } + + // Count only? + if args.count { + query = query.count_only(); + } + + // Execute query + let result = query.execute(&client, &project, &program)?; + println!("{}", result); + + Ok(()) +} + +fn handle_function_command(cmd: cli::FunctionCommands) -> Result<()> { + use cli::FunctionCommands; + + match cmd { + FunctionCommands::List(opts) => { + let mut args = QueryArgs { + data_type: "functions".to_string(), + program: opts.program, + project: opts.project, + filter: opts.filter, + fields: opts.fields, + format: opts.format, + limit: opts.limit, + offset: opts.offset, + sort: opts.sort, + count: opts.count, + json: opts.json, + }; + handle_query(args) + } + FunctionCommands::Decompile(args) => { + // Decompile specific function + let mut query_args = QueryArgs { + data_type: "functions".to_string(), + program: args.options.program, + project: args.options.project, + filter: Some(format!("address={} OR name={}", args.target, args.target)), + fields: args.options.fields, + format: args.options.format, + limit: Some(1), + offset: None, + sort: None, + count: false, + json: args.options.json, + }; + handle_decompile_impl(args.target, query_args) + } + _ => { + println!("Function subcommand not yet implemented"); + Ok(()) + } + } +} + +fn handle_decompile(args: cli::DecompileArgs) -> Result<()> { + let query_args = QueryArgs { + data_type: "functions".to_string(), + program: args.options.program, + project: args.options.project, + filter: None, + fields: None, + format: Some("c".to_string()), + limit: None, + offset: None, + sort: None, + count: false, + json: false, + }; + handle_decompile_impl(args.target, query_args) +} + +fn handle_decompile_impl(target: String, args: QueryArgs) -> Result<()> { + let config = Config::load()?; + let client = GhidraClient::new(config.clone())?; + + let program = resolve_program(&args.program, &config)?; + let project = resolve_project(&args.project, &config, &program)?; + + // Use headless executor to decompile + let executor = ghidra::headless::HeadlessExecutor::new(&client); + let result = executor.decompile_function(&project, &program, &target)?; + + // Format output + if let Some(code) = result.get("code") { + if let Some(code_str) = code.as_str() { + println!("{}", code_str); + return Ok(()); + } + } + + println!("{}", serde_json::to_string_pretty(&result)?); + Ok(()) +} + +fn handle_strings_command(cmd: cli::StringsCommands) -> Result<()> { + use cli::StringsCommands; + + match cmd { + StringsCommands::List(opts) => { + let args = QueryArgs { + data_type: "strings".to_string(), + program: opts.program, + project: opts.project, + filter: opts.filter, + fields: opts.fields, + format: opts.format, + limit: opts.limit, + offset: opts.offset, + sort: opts.sort, + count: opts.count, + json: opts.json, + }; + handle_query(args) + } + _ => { + println!("Strings subcommand not yet implemented"); + Ok(()) + } + } +} + +fn handle_memory_command(cmd: cli::MemoryCommands) -> Result<()> { + use cli::MemoryCommands; + + match cmd { + MemoryCommands::Map(opts) => { + let args = QueryArgs { + data_type: "memory".to_string(), + program: opts.program, + project: opts.project, + filter: opts.filter, + fields: opts.fields, + format: opts.format, + limit: opts.limit, + offset: opts.offset, + sort: opts.sort, + count: opts.count, + json: opts.json, + }; + handle_query(args) + } + _ => { + println!("Memory subcommand not yet implemented"); + Ok(()) + } + } +} + +fn handle_dump_command(cmd: cli::DumpCommands) -> Result<()> { + use cli::DumpCommands; + + match cmd { + DumpCommands::Imports(opts) => { + let args = QueryArgs { + data_type: "imports".to_string(), + program: opts.program, + project: opts.project, + filter: opts.filter, + fields: opts.fields, + format: opts.format, + limit: opts.limit, + offset: opts.offset, + sort: opts.sort, + count: opts.count, + json: opts.json, + }; + handle_query(args) + } + DumpCommands::Exports(opts) => { + let args = QueryArgs { + data_type: "exports".to_string(), + program: opts.program, + project: opts.project, + filter: opts.filter, + fields: opts.fields, + format: opts.format, + limit: opts.limit, + offset: opts.offset, + sort: opts.sort, + count: opts.count, + json: opts.json, + }; + handle_query(args) + } + DumpCommands::Functions(opts) => { + let args = QueryArgs { + data_type: "functions".to_string(), + program: opts.program, + project: opts.project, + filter: opts.filter, + fields: opts.fields, + format: opts.format, + limit: opts.limit, + offset: opts.offset, + sort: opts.sort, + count: opts.count, + json: opts.json, + }; + handle_query(args) + } + DumpCommands::Strings(opts) => { + let args = QueryArgs { + data_type: "strings".to_string(), + program: opts.program, + project: opts.project, + filter: opts.filter, + fields: opts.fields, + format: opts.format, + limit: opts.limit, + offset: opts.offset, + sort: opts.sort, + count: opts.count, + json: opts.json, + }; + handle_query(args) + } + } +} + +fn handle_init() -> Result<()> { + println!("Ghidra CLI Initialization"); + println!("========================\n"); + + let mut config = Config::default(); + + // Check if Ghidra is installed + #[cfg(target_os = "windows")] + { + if let Some(dir) = Config::detect_ghidra_windows() { + println!("Found Ghidra installation at: {}", dir.display()); + config.ghidra_install_dir = Some(dir); + } + } + + if config.ghidra_install_dir.is_none() { + println!("Ghidra installation not found automatically."); + println!("Please set GHIDRA_INSTALL_DIR environment variable or update the config file."); + println!("\nExample:"); + println!(" set GHIDRA_INSTALL_DIR=C:\\ghidra\\ghidra_11.0"); + } + + // Set default project directory + let home = dirs::home_dir() + .ok_or_else(|| GhidraError::ConfigError("Could not determine home directory".to_string()))?; + let project_dir = home.join(".ghidra-projects"); + config.ghidra_project_dir = Some(project_dir.clone()); + + println!("\nProject directory: {}", project_dir.display()); + + // Save config + config.save()?; + + println!("\nConfiguration saved to: {}", Config::config_path()?.display()); + println!("\nRun 'ghidra doctor' to verify your installation."); + + Ok(()) +} + +fn handle_doctor() -> Result<()> { + println!("Ghidra CLI Doctor"); + println!("=================\n"); + + let config = Config::load()?; + + // Check Ghidra installation + print!("Checking Ghidra installation... "); + match config.get_ghidra_install_dir() { + Ok(dir) => { + println!("✓"); + println!(" Location: {}", dir.display()); + + let client = GhidraClient::new(config.clone()); + match client { + Ok(c) => { + if c.verify_installation().is_ok() { + println!(" analyzeHeadless: ✓"); + } else { + println!(" analyzeHeadless: ✗ (not found)"); + } + } + Err(e) => { + println!(" Error: {}", e); + } + } + } + Err(e) => { + println!("✗"); + println!(" Error: {}", e); + } + } + + // Check project directory + print!("\nChecking project directory... "); + match config.get_project_dir() { + Ok(dir) => { + println!("✓"); + println!(" Location: {}", dir.display()); + println!(" Exists: {}", if dir.exists() { "yes" } else { "no (will be created)" }); + } + Err(e) => { + println!("✗"); + println!(" Error: {}", e); + } + } + + // Check config file + print!("\nConfig file... "); + match Config::config_path() { + Ok(path) => { + println!("✓"); + println!(" Location: {}", path.display()); + println!(" Exists: {}", if path.exists() { "yes" } else { "no" }); + } + Err(e) => { + println!("✗"); + println!(" Error: {}", e); + } + } + + println!("\nDone!"); + Ok(()) +} + +fn handle_version() -> Result<()> { + println!("ghidra-cli {}", env!("CARGO_PKG_VERSION")); + println!("Rust CLI for Ghidra reverse engineering"); + Ok(()) +} + +fn handle_import(args: cli::ImportArgs) -> Result<()> { + let config = Config::load()?; + let client = GhidraClient::new(config.clone())?; + + let project = resolve_project(&args.project, &config, &args.program.as_ref().unwrap_or(&"unknown".to_string()))?; + + let binary_path = PathBuf::from(&args.binary); + if !binary_path.exists() { + return Err(GhidraError::Other(format!("Binary not found: {}", args.binary))); + } + + println!("Importing {} into project {}...", args.binary, project); + + let program_name = client.import_binary(&project, &binary_path, args.program.as_deref())?; + + println!("Successfully imported as: {}", program_name); + + Ok(()) +} + +fn handle_analyze(args: cli::AnalyzeArgs) -> Result<()> { + let config = Config::load()?; + let client = GhidraClient::new(config.clone())?; + + let program = resolve_program(&args.program, &config)?; + let project = resolve_project(&args.project, &config, &program)?; + + println!("Analyzing {}...", program); + + client.analyze_program(&project, &program)?; + + println!("Analysis complete!"); + + Ok(()) +} + +fn handle_summary(opts: QueryOptions) -> Result<()> { + let config = Config::load()?; + let client = GhidraClient::new(config.clone())?; + + let program = resolve_program(&opts.program, &config)?; + let project = resolve_project(&opts.project, &config, &program)?; + + println!("Program Summary"); + println!("===============\n"); + + // Get program info + let executor = ghidra::headless::HeadlessExecutor::new(&client); + let info = executor.get_program_info(&project, &program)?; + + if let Some(name) = info.get("name") { + println!("Name: {}", name); + } + if let Some(format) = info.get("executable_format") { + println!("Format: {}", format); + } + if let Some(lang) = info.get("language") { + println!("Language: {}", lang); + } + if let Some(count) = info.get("function_count") { + println!("Functions: {}", count); + } + if let Some(count) = info.get("instruction_count") { + println!("Instructions: {}", count); + } + + Ok(()) +} + +fn handle_quick(args: cli::QuickArgs) -> Result<()> { + let config = Config::load()?; + let client = GhidraClient::new(config.clone())?; + + let project = args.project.unwrap_or_else(|| "quick-analysis".to_string()); + let binary_path = PathBuf::from(&args.binary); + + println!("Quick analysis of {}...\n", args.binary); + + // Import + println!("[1/3] Importing binary..."); + let program_name = client.import_binary(&project, &binary_path, None)?; + + // Analyze + println!("[2/3] Running analysis..."); + client.analyze_program(&project, &program_name)?; + + // Summary + println!("[3/3] Generating summary...\n"); + let opts = QueryOptions { + program: Some(program_name), + project: Some(project), + filter: None, + fields: None, + format: None, + limit: None, + offset: None, + sort: None, + count: false, + json: false, + }; + handle_summary(opts)?; + + Ok(()) +} + +fn handle_config_command(cmd: cli::ConfigCommands) -> Result<()> { + use cli::ConfigCommands; + + match cmd { + ConfigCommands::List => { + let config = Config::load()?; + println!("{}", serde_yaml::to_string(&config)?); + } + ConfigCommands::Get { key } => { + let config = Config::load()?; + let yaml = serde_yaml::to_value(&config)?; + if let Some(value) = yaml.get(&key) { + println!("{}", serde_yaml::to_string(value)?); + } else { + println!("Key not found: {}", key); + } + } + ConfigCommands::Set { key, value } => { + let mut config = Config::load()?; + // Simple key-value setting (could be expanded) + match key.as_str() { + "default_output_format" => config.default_output_format = Some(value), + "timeout" => { + let timeout: u64 = value.parse() + .map_err(|_| GhidraError::ConfigError("Invalid timeout value".to_string()))?; + config.timeout = Some(timeout); + } + _ => { + return Err(GhidraError::ConfigError(format!("Unknown config key: {}", key))); + } + } + config.save()?; + println!("Configuration updated"); + } + ConfigCommands::Reset => { + let config = Config::default(); + config.save()?; + println!("Configuration reset to defaults"); + } + } + + Ok(()) +} + +fn handle_set_default(args: cli::SetDefaultArgs) -> Result<()> { + let mut config = Config::load()?; + + match args.kind.as_str() { + "program" => { + config.default_program = Some(args.value.clone()); + config.save()?; + println!("Default program set to: {}", args.value); + } + "project" => { + config.default_project = Some(args.value.clone()); + config.save()?; + println!("Default project set to: {}", args.value); + } + _ => { + return Err(GhidraError::Other(format!("Unknown default kind: {}", args.kind))); + } + } + + Ok(()) +} + +fn handle_project_command(cmd: cli::ProjectCommands) -> Result<()> { + use cli::ProjectCommands; + + let config = Config::load()?; + let client = GhidraClient::new(config)?; + + match cmd { + ProjectCommands::Create { name } => { + client.create_project(&name)?; + println!("Project '{}' created", name); + } + ProjectCommands::List => { + let project_dir = client.get_project_dir(); + if !project_dir.exists() { + println!("No projects found"); + return Ok(()); + } + + println!("Projects:"); + for entry in std::fs::read_dir(project_dir)? { + let entry = entry?; + if entry.path().is_dir() { + if let Some(name) = entry.file_name().to_str() { + println!(" {}", name); + } + } + } + } + ProjectCommands::Delete { name } => { + let project_path = client.get_project_path(&name); + if project_path.exists() { + std::fs::remove_dir_all(&project_path)?; + println!("Project '{}' deleted", name); + } else { + println!("Project '{}' not found", name); + } + } + ProjectCommands::Info { name } => { + let project_name = name.unwrap_or_else(|| "default".to_string()); + let project_path = client.get_project_path(&project_name); + println!("Project: {}", project_name); + println!("Path: {}", project_path.display()); + println!("Exists: {}", project_path.exists()); + } + } + + Ok(()) +} + +fn resolve_program(program: &Option, config: &Config) -> Result { + program + .clone() + .or_else(|| config.get_default_program()) + .ok_or_else(|| GhidraError::Other("No program specified. Use --program or set default with 'ghidra set-default program '".to_string())) +} + +fn resolve_project(project: &Option, config: &Config, program: &str) -> Result { + Ok(project + .clone() + .or_else(|| config.get_default_project()) + .unwrap_or_else(|| format!("{}-project", program))) +} diff --git a/src/query/mod.rs b/src/query/mod.rs new file mode 100644 index 0000000..206d289 --- /dev/null +++ b/src/query/mod.rs @@ -0,0 +1,356 @@ +use serde_json::Value as JsonValue; +use crate::error::{GhidraError, Result}; +use crate::filter::{Filter, FilterExpr}; +use crate::format::{OutputFormat, Formatter, DefaultFormatter}; +use crate::ghidra::GhidraClient; +use crate::ghidra::headless::HeadlessExecutor; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DataType { + Functions, + Strings, + Symbols, + Imports, + Exports, + XRefs, + Memory, + Sections, + Comments, + Types, + Instructions, + BasicBlocks, + CallGraph, + Data, + References, +} + +impl DataType { + pub fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "functions" | "function" | "fn" => Ok(Self::Functions), + "strings" | "string" | "str" => Ok(Self::Strings), + "symbols" | "symbol" | "sym" => Ok(Self::Symbols), + "imports" | "import" => Ok(Self::Imports), + "exports" | "export" => Ok(Self::Exports), + "xrefs" | "xref" | "crossrefs" => Ok(Self::XRefs), + "memory" | "mem" => Ok(Self::Memory), + "sections" | "section" => Ok(Self::Sections), + "comments" | "comment" => Ok(Self::Comments), + "types" | "type" => Ok(Self::Types), + "instructions" | "instruction" | "insn" => Ok(Self::Instructions), + "basicblocks" | "basic-blocks" | "blocks" => Ok(Self::BasicBlocks), + "callgraph" | "call-graph" => Ok(Self::CallGraph), + "data" => Ok(Self::Data), + "references" | "refs" => Ok(Self::References), + _ => Err(GhidraError::InvalidDataType(format!("Unknown data type: {}", s))), + } + } +} + +pub struct Query { + pub data_type: DataType, + pub filter: Option, + pub fields: Option, + pub format: OutputFormat, + pub limit: Option, + pub offset: Option, + pub sort: Option>, + pub count_only: bool, +} + +impl Query { + pub fn new(data_type: DataType) -> Self { + Self { + data_type, + filter: None, + fields: None, + format: OutputFormat::Json, + limit: None, + offset: None, + sort: None, + count_only: false, + } + } + + pub fn with_filter(mut self, filter: Filter) -> Self { + self.filter = Some(filter); + self + } + + pub fn with_format(mut self, format: OutputFormat) -> Self { + self.format = format; + self + } + + pub fn with_limit(mut self, limit: usize) -> Self { + self.limit = Some(limit); + self + } + + pub fn with_offset(mut self, offset: usize) -> Self { + self.offset = Some(offset); + self + } + + pub fn count_only(mut self) -> Self { + self.count_only = true; + self + } + + pub fn execute(&self, client: &GhidraClient, project: &str, program: &str) -> Result { + let executor = HeadlessExecutor::new(client); + + // Fetch data based on type + let data = match self.data_type { + DataType::Functions => executor.list_functions(project, program)?, + DataType::Strings => executor.list_strings(project, program)?, + DataType::Imports => executor.list_imports(project, program)?, + DataType::Exports => executor.list_exports(project, program)?, + DataType::Memory => executor.get_memory_map(project, program)?, + _ => { + return Err(GhidraError::Other(format!( + "Data type {:?} not yet implemented", + self.data_type + ))); + } + }; + + // Data should be an array + let data_array = match data { + JsonValue::Array(arr) => arr, + _ => { + return Err(GhidraError::ExecutionFailed( + "Expected array from Ghidra script".to_string() + )); + } + }; + + // Apply filter + let filtered = if let Some(filter) = &self.filter { + self.apply_filter(&data_array, filter)? + } else { + data_array + }; + + // Apply field selection + let selected = if let Some(fields) = &self.fields { + self.select_fields(&filtered, fields)? + } else { + filtered + }; + + // Apply sorting + let sorted = if let Some(sort) = &self.sort { + self.apply_sort(&selected, sort)? + } else { + selected + }; + + // Apply pagination + let paginated = self.apply_pagination(&sorted); + + // Return count if requested + if self.count_only { + return Ok(paginated.len().to_string()); + } + + // Format output + let formatter = DefaultFormatter; + formatter.format(&paginated, self.format) + } + + fn apply_filter(&self, data: &[JsonValue], filter: &Filter) -> Result> { + let mut result = Vec::new(); + + for item in data { + if filter.evaluate(item)? { + result.push(item.clone()); + } + } + + Ok(result) + } + + fn select_fields(&self, data: &[JsonValue], selector: &FieldSelector) -> Result> { + let mut result = Vec::new(); + + for item in data { + if let JsonValue::Object(map) = item { + let mut new_map = serde_json::Map::new(); + + if let Some(include) = &selector.include { + for field in include { + if let Some(value) = map.get(field) { + new_map.insert(field.clone(), value.clone()); + } + } + } else if let Some(exclude) = &selector.exclude { + for (key, value) in map { + if !exclude.contains(key) { + new_map.insert(key.clone(), value.clone()); + } + } + } else { + new_map = map.clone(); + } + + result.push(JsonValue::Object(new_map)); + } else { + result.push(item.clone()); + } + } + + Ok(result) + } + + fn apply_sort(&self, data: &[JsonValue], sort_keys: &[SortKey]) -> Result> { + let mut result = data.to_vec(); + + result.sort_by(|a, b| { + for sort_key in sort_keys { + let a_val = self.get_field_for_sort(a, &sort_key.field); + let b_val = self.get_field_for_sort(b, &sort_key.field); + + let cmp = match (&a_val, &b_val) { + (Some(JsonValue::Number(a)), Some(JsonValue::Number(b))) => { + a.as_f64().partial_cmp(&b.as_f64()).unwrap_or(std::cmp::Ordering::Equal) + } + (Some(JsonValue::String(a)), Some(JsonValue::String(b))) => a.cmp(b), + _ => std::cmp::Ordering::Equal, + }; + + let final_cmp = if sort_key.descending { + cmp.reverse() + } else { + cmp + }; + + if final_cmp != std::cmp::Ordering::Equal { + return final_cmp; + } + } + + std::cmp::Ordering::Equal + }); + + Ok(result) + } + + fn get_field_for_sort(&self, value: &JsonValue, field: &str) -> Option { + if let JsonValue::Object(map) = value { + map.get(field).cloned() + } else { + None + } + } + + fn apply_pagination(&self, data: &[JsonValue]) -> Vec { + let offset = self.offset.unwrap_or(0); + let limit = self.limit.unwrap_or(usize::MAX); + + data.iter() + .skip(offset) + .take(limit) + .cloned() + .collect() + } +} + +pub struct FieldSelector { + pub include: Option>, + pub exclude: Option>, +} + +impl FieldSelector { + pub fn include(fields: Vec) -> Self { + Self { + include: Some(fields), + exclude: None, + } + } + + pub fn exclude(fields: Vec) -> Self { + Self { + include: None, + exclude: Some(fields), + } + } + + pub fn parse(input: &str) -> Result { + if input.starts_with('-') { + // Exclude fields + let fields: Vec = input + .trim_start_matches('-') + .split(',') + .map(|s| s.trim().to_string()) + .collect(); + Ok(Self::exclude(fields)) + } else { + // Include fields + let fields: Vec = input + .split(',') + .map(|s| s.trim().to_string()) + .collect(); + Ok(Self::include(fields)) + } + } +} + +pub struct SortKey { + pub field: String, + pub descending: bool, +} + +impl SortKey { + pub fn parse(input: &str) -> Vec { + input + .split(',') + .map(|s| { + let s = s.trim(); + if s.starts_with('-') { + SortKey { + field: s.trim_start_matches('-').to_string(), + descending: true, + } + } else { + SortKey { + field: s.to_string(), + descending: false, + } + } + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_data_type_parsing() { + assert_eq!(DataType::from_str("functions").unwrap(), DataType::Functions); + assert_eq!(DataType::from_str("fn").unwrap(), DataType::Functions); + assert_eq!(DataType::from_str("strings").unwrap(), DataType::Strings); + } + + #[test] + fn test_field_selector_parse() { + let selector = FieldSelector::parse("name,address,size").unwrap(); + assert!(selector.include.is_some()); + assert_eq!(selector.include.unwrap().len(), 3); + + let selector = FieldSelector::parse("-metadata,internal").unwrap(); + assert!(selector.exclude.is_some()); + } + + #[test] + fn test_sort_key_parse() { + let keys = SortKey::parse("name,-size"); + assert_eq!(keys.len(), 2); + assert_eq!(keys[0].field, "name"); + assert!(!keys[0].descending); + assert_eq!(keys[1].field, "size"); + assert!(keys[1].descending); + } +}