From 170ca32f5862146fee463475e853752a594febb8 Mon Sep 17 00:00:00 2001 From: hermes Date: Tue, 28 Jul 2026 19:26:47 +0000 Subject: [PATCH] feat: scaffold v0.1 foundation --- Cargo.lock | 438 +++++++++++++++ Cargo.toml | 42 ++ README.md | 13 +- fixtures/golden/v1/cleanup.json | 32 ++ fixtures/golden/v1/decompilation.json | 47 ++ fixtures/golden/v1/doctor.json | 21 + fixtures/golden/v1/error.json | 10 + fixtures/golden/v1/functions.json | 58 ++ fixtures/golden/v1/inspection.json | 48 ++ schemas/v1/analysis-profile.schema.json | 167 ++++++ schemas/v1/cleanup.schema.json | 417 +++++++++++++++ schemas/v1/decompilation.schema.json | 507 ++++++++++++++++++ schemas/v1/doctor.schema.json | 304 +++++++++++ schemas/v1/error.schema.json | 241 +++++++++ schemas/v1/functions.schema.json | 539 +++++++++++++++++++ schemas/v1/inspection.schema.json | 518 ++++++++++++++++++ schemas/v1/protocol-request.schema.json | 341 ++++++++++++ schemas/v1/protocol-response.schema.json | 120 +++++ src/cli.rs | 280 ++++++++++ src/domain/address.rs | 63 +++ src/domain/identifier.rs | 80 +++ src/domain/limits.rs | 81 +++ src/domain/mod.rs | 13 + src/domain/path.rs | 75 +++ src/domain/selector.rs | 14 + src/error.rs | 249 +++++++++ src/lib.rs | 12 + src/main.rs | 20 + src/operation.rs | 647 +++++++++++++++++++++++ src/output.rs | 214 ++++++++ src/protocol.rs | 305 +++++++++++ src/schema.rs | 163 ++++++ tests/cli.rs | 43 ++ tests/contracts.rs | 58 ++ 34 files changed, 6175 insertions(+), 5 deletions(-) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 fixtures/golden/v1/cleanup.json create mode 100644 fixtures/golden/v1/decompilation.json create mode 100644 fixtures/golden/v1/doctor.json create mode 100644 fixtures/golden/v1/error.json create mode 100644 fixtures/golden/v1/functions.json create mode 100644 fixtures/golden/v1/inspection.json create mode 100644 schemas/v1/analysis-profile.schema.json create mode 100644 schemas/v1/cleanup.schema.json create mode 100644 schemas/v1/decompilation.schema.json create mode 100644 schemas/v1/doctor.schema.json create mode 100644 schemas/v1/error.schema.json create mode 100644 schemas/v1/functions.schema.json create mode 100644 schemas/v1/inspection.schema.json create mode 100644 schemas/v1/protocol-request.schema.json create mode 100644 schemas/v1/protocol-response.schema.json create mode 100644 src/cli.rs create mode 100644 src/domain/address.rs create mode 100644 src/domain/identifier.rs create mode 100644 src/domain/limits.rs create mode 100644 src/domain/mod.rs create mode 100644 src/domain/path.rs create mode 100644 src/domain/selector.rs create mode 100644 src/error.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/operation.rs create mode 100644 src/output.rs create mode 100644 src/protocol.rs create mode 100644 src/schema.rs create mode 100644 tests/cli.rs create mode 100644 tests/contracts.rs diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..daa43d2 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,438 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +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", +] + +[[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", +] + +[[package]] +name = "assert_cmd" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bstr" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + +[[package]] +name = "clap" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ghidra-cli" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "base64", + "clap", + "schemars", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[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.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" + +[[package]] +name = "schemars" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33d020396d1d138dc19f1165df7545479dcd58d93810dc5d646a16e55abefa80" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[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.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..efabe06 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "ghidra-cli" +version = "0.1.0" +edition = "2024" +rust-version = "1.85" +description = "Reproducible, read-only Ghidra analysis from the command line" +license = "Apache-2.0" +publish = false + +[lib] +name = "ghidra_cli" +path = "src/lib.rs" + +[[bin]] +name = "ghidr" +path = "src/main.rs" + +[dependencies] +base64 = "0.22.1" +clap = { version = "4.5.60", features = ["derive"] } +schemars = { version = "=1.0.4", features = ["derive"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" +thiserror = "2.0.18" + +[dev-dependencies] +assert_cmd = "2.1.2" + +[lints.rust] +unsafe_code = "forbid" +missing_docs = "warn" + +[lints.clippy] +unwrap_used = "deny" +expect_used = "deny" +panic = "deny" +todo = "deny" +unimplemented = "deny" +dbg_macro = "deny" +print_stdout = "deny" +print_stderr = "deny" +large_futures = "deny" diff --git a/README.md b/README.md index 92b8043..a4092d6 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,10 @@ A small, dependable command-line interface for read-only Ghidra analysis. The repository is named `ghidra-cli`; the installed executable is `ghidr`. -The project is currently in its design phase. Its first objective is to make -common reverse-engineering questions easy to ask without exposing Ghidra -project management, JVM lifecycle, or scripting details to the user. +The project now has its version 0.1 Rust contract foundation. The typed CLI, +public envelopes and schemas, bounded Rust/Java protocol, and synchronous +execution seam are present; store, process/sandbox, and Ghidra adapter execution +remain deliberately unconnected. Proposed usage: @@ -33,8 +34,10 @@ available explicitly through `--format human`. ## Status -Documentation first. There is intentionally no Rust crate yet. Implementation -begins after the initial requirements and interface are reviewed. +Foundation only. Every accepted command currently returns a typed +`internal_not_implemented` runtime error instead of pretending Ghidra analysis +succeeded. Subsequent layers implement the library's synchronous executor +contract while preserving the committed schemas and reviewed fixtures. ## License diff --git a/fixtures/golden/v1/cleanup.json b/fixtures/golden/v1/cleanup.json new file mode 100644 index 0000000..55d31b0 --- /dev/null +++ b/fixtures/golden/v1/cleanup.json @@ -0,0 +1,32 @@ +{ + "schema_version": 1, + "kind": "cleanup", + "provenance": { + "ghidr_version": "0.1.0", + "adapter_protocol_version": 1, + "analysis_store": { + "path": { "encoding": "utf8", "value": "/tmp/ghidr-store" }, + "source": "cli" + }, + "sandbox": { "backend": "bubblewrap", "verification": "verified" } + }, + "data": { + "mode": "dry_run", + "target": { + "kind": "digest", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "matched": { + "analyses": 1, + "quarantined_analyses": 0, + "artifacts": 1, + "diagnostic_logs": 0, + "usage": { "logical_bytes": 4096, "allocated_bytes": 8192 }, + "analysis_profile_sha256": [ + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ] + }, + "removed": null + }, + "warnings": [] +} diff --git a/fixtures/golden/v1/decompilation.json b/fixtures/golden/v1/decompilation.json new file mode 100644 index 0000000..cc545d9 --- /dev/null +++ b/fixtures/golden/v1/decompilation.json @@ -0,0 +1,47 @@ +{ + "schema_version": 1, + "kind": "decompilation", + "provenance": { + "ghidr_version": "0.1.0", + "adapter_protocol_version": 1, + "ghidra_version": "12.1.2", + "java_version": "21", + "sample_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "source_path": { "encoding": "utf8", "value": "./sample" }, + "analysis_profile_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "target": { + "loader": "ElfLoader", + "format": "ELF", + "processor_language": "x86:LE:64:default", + "compiler_specification": "gcc", + "verification": "verified" + }, + "analysis_store": { + "path": { "encoding": "utf8", "value": "/tmp/ghidr-store" }, + "source": "cli" + }, + "sandbox": { "backend": "bubblewrap", "verification": "verified" }, + "limits": { + "max_heap_mib": 2048, + "max_cpu": 2, + "analysis_timeout_seconds": 600, + "decompile_timeout_seconds": 60, + "child_watchdog_seconds": 780, + "max_sample_bytes": 1073741824, + "max_inline_bytes": 65536 + } + }, + "data": { + "requested_selector": { "kind": "name", "value": "main" }, + "function": { + "name": "main", + "qualified_name": "main", + "entry": { "space": "ram", "offset": "0x0000000000401000" } + }, + "decompilation": { + "syntax": "ghidra_c", + "text": "int main(void) {\n return 0;\n}\n" + } + }, + "warnings": [] +} diff --git a/fixtures/golden/v1/doctor.json b/fixtures/golden/v1/doctor.json new file mode 100644 index 0000000..5e9fe80 --- /dev/null +++ b/fixtures/golden/v1/doctor.json @@ -0,0 +1,21 @@ +{ + "schema_version": 1, + "kind": "doctor", + "provenance": { + "ghidr_version": "0.1.0", + "adapter_protocol_version": 1, + "analysis_store": { + "path": { "encoding": "utf8", "value": "/tmp/ghidr-store" }, + "source": "cli" + }, + "sandbox": { "backend": "bubblewrap", "verification": "verified" } + }, + "data": { + "ready": true, + "components": { + "ghidra": { "status": "ready", "details": { "version": "12.1.2" } }, + "java": { "status": "ready", "details": { "version": "21" } } + } + }, + "warnings": [] +} diff --git a/fixtures/golden/v1/error.json b/fixtures/golden/v1/error.json new file mode 100644 index 0000000..d32ca0d --- /dev/null +++ b/fixtures/golden/v1/error.json @@ -0,0 +1,10 @@ +{ + "schema_version": 1, + "kind": "error", + "error": { + "code": "function_selector_ambiguous", + "message": "symbol 'parse' resolves to more than one function", + "retryable": false, + "details": { "candidates": [] } + } +} diff --git a/fixtures/golden/v1/functions.json b/fixtures/golden/v1/functions.json new file mode 100644 index 0000000..43def92 --- /dev/null +++ b/fixtures/golden/v1/functions.json @@ -0,0 +1,58 @@ +{ + "schema_version": 1, + "kind": "functions", + "provenance": { + "ghidr_version": "0.1.0", + "adapter_protocol_version": 1, + "ghidra_version": "12.1.2", + "java_version": "21", + "sample_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "source_path": { "encoding": "utf8", "value": "./sample" }, + "analysis_profile_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "target": { + "loader": "ElfLoader", + "format": "ELF", + "processor_language": "x86:LE:64:default", + "compiler_specification": "gcc", + "verification": "verified" + }, + "analysis_store": { + "path": { "encoding": "utf8", "value": "/tmp/ghidr-store" }, + "source": "cli" + }, + "sandbox": { "backend": "bubblewrap", "verification": "verified" }, + "limits": { + "max_heap_mib": 2048, + "max_cpu": 2, + "analysis_timeout_seconds": 600, + "decompile_timeout_seconds": null, + "child_watchdog_seconds": 720, + "max_sample_bytes": 1073741824, + "max_inline_bytes": 65536 + } + }, + "data": { + "page": { + "order": "location_then_entry_ascending", + "offset": 0, + "limit": 100, + "returned": 1, + "total": 1, + "has_more": false + }, + "items": [ + { + "name": "main", + "qualified_name": "main", + "entry": { "space": "ram", "offset": "0x0000000000401000" }, + "body_address_count": 32, + "location": "memory", + "is_external": false, + "is_thunk": false, + "thunk_target_entry": null, + "decompilable": true + } + ] + }, + "warnings": [] +} diff --git a/fixtures/golden/v1/inspection.json b/fixtures/golden/v1/inspection.json new file mode 100644 index 0000000..022379b --- /dev/null +++ b/fixtures/golden/v1/inspection.json @@ -0,0 +1,48 @@ +{ + "schema_version": 1, + "kind": "inspection", + "provenance": { + "ghidr_version": "0.1.0", + "adapter_protocol_version": 1, + "ghidra_version": "12.1.2", + "java_version": "21", + "sample_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "source_path": { "encoding": "utf8", "value": "./sample" }, + "analysis_profile_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "target": { + "loader": "ElfLoader", + "format": "ELF", + "processor_language": "x86:LE:64:default", + "compiler_specification": "gcc", + "verification": "verified" + }, + "analysis_store": { + "path": { "encoding": "utf8", "value": "/tmp/ghidr-store" }, + "source": "cli" + }, + "sandbox": { "backend": "bubblewrap", "verification": "verified" }, + "limits": { + "max_heap_mib": 2048, + "max_cpu": 2, + "analysis_timeout_seconds": 600, + "decompile_timeout_seconds": null, + "child_watchdog_seconds": 720, + "max_sample_bytes": 1073741824, + "max_inline_bytes": 65536 + } + }, + "data": { + "sample": { + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "size_bytes": 4096 + }, + "program": { + "image_base": { "space": "ram", "offset": "0x0000000000400000" }, + "minimum_address": { "space": "ram", "offset": "0x0000000000400000" }, + "maximum_address": { "space": "ram", "offset": "0x0000000000400fff" }, + "function_count": 1 + }, + "analysis": { "disposition": "created" } + }, + "warnings": [] +} diff --git a/schemas/v1/analysis-profile.schema.json b/schemas/v1/analysis-profile.schema.json new file mode 100644 index 0000000..ad1ef79 --- /dev/null +++ b/schemas/v1/analysis-profile.schema.json @@ -0,0 +1,167 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "AnalysisProfile", + "description": "Canonical Analysis Profile document whose exact JSON bytes are hashed.", + "type": "object", + "properties": { + "analysis_adapter_version": { + "description": "Analysis adapter implementation version.", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "analyzer_options": { + "description": "Fully resolved, bytewise-name-sorted analyzer options.", + "type": "array", + "items": { + "$ref": "#/$defs/ProfileOption" + } + }, + "ghidra_version": { + "description": "Exact Ghidra version.", + "type": "string" + }, + "java_version": { + "description": "Exact Java major version.", + "type": "string" + }, + "loader_options": { + "description": "Fully resolved, bytewise-name-sorted loader options.", + "type": "array", + "items": { + "$ref": "#/$defs/ProfileOption" + } + }, + "max_cpu": { + "description": "Maximum analysis CPU count.", + "$ref": "#/$defs/PositiveU64" + }, + "profile_version": { + "type": "integer", + "const": 1 + }, + "target": { + "description": "Analysis-affecting Target Specification.", + "$ref": "#/$defs/ProfileTarget" + } + }, + "additionalProperties": false, + "required": [ + "profile_version", + "ghidra_version", + "java_version", + "analysis_adapter_version", + "target", + "loader_options", + "analyzer_options", + "max_cpu" + ], + "$defs": { + "PositiveU64": { + "description": "A validated positive integer accepted by resource-limit flags.", + "type": "integer", + "format": "uint64", + "minimum": 1 + }, + "ProfileOption": { + "description": "Fully resolved typed Ghidra option.", + "type": "object", + "properties": { + "name": { + "description": "Canonical option name.", + "type": "string" + }, + "value": { + "description": "Typed canonical value without floating-point data.", + "$ref": "#/$defs/ProfileOptionValue" + } + }, + "additionalProperties": false, + "required": [ + "name", + "value" + ] + }, + "ProfileOptionValue": { + "description": "Values permitted in canonical profile options.", + "oneOf": [ + { + "description": "Boolean option.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "boolean" + }, + "value": { + "type": "boolean" + } + }, + "required": [ + "type", + "value" + ] + }, + { + "description": "Signed integer option.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "integer" + }, + "value": { + "type": "integer", + "format": "int64" + } + }, + "required": [ + "type", + "value" + ] + }, + { + "description": "Exact UTF-8 string option.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "type", + "value" + ] + } + ] + }, + "ProfileTarget": { + "description": "Target fields which affect Analysis identity.", + "type": "object", + "properties": { + "compiler_specification": { + "description": "Exact compiler-specification identifier.", + "type": "string" + }, + "loader": { + "description": "Exact loader identifier.", + "type": "string" + }, + "processor_language": { + "description": "Exact processor-language identifier.", + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "loader", + "processor_language", + "compiler_specification" + ] + } + } +} diff --git a/schemas/v1/cleanup.schema.json b/schemas/v1/cleanup.schema.json new file mode 100644 index 0000000..c3b5d8b --- /dev/null +++ b/schemas/v1/cleanup.schema.json @@ -0,0 +1,417 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SuccessEnvelope", + "description": "Versioned successful response.", + "type": "object", + "properties": { + "data": { + "description": "Operation-specific result.", + "$ref": "#/$defs/CleanupData" + }, + "kind": { + "type": "string", + "const": "cleanup" + }, + "provenance": { + "description": "Reproducibility and execution context.", + "$ref": "#/$defs/ToolProvenance" + }, + "schema_version": { + "type": "integer", + "const": 1 + }, + "warnings": { + "description": "Deduplicated stable warnings.", + "type": "array", + "items": { + "$ref": "#/$defs/Warning" + } + } + }, + "required": [ + "schema_version", + "kind", + "provenance", + "data", + "warnings" + ], + "$defs": { + "AnalysisStoreProvenance": { + "description": "Analysis Store provenance.", + "type": "object", + "properties": { + "path": { + "description": "Tagged absolute store path.", + "$ref": "#/$defs/TaggedPath" + }, + "source": { + "description": "Store resolution source.", + "$ref": "#/$defs/StoreSource" + } + }, + "required": [ + "path", + "source" + ] + }, + "CleanupData": { + "description": "Cleanup result data; detailed count contracts are carried as typed snapshots.", + "type": "object", + "properties": { + "matched": { + "description": "Preflight match snapshot.", + "$ref": "#/$defs/CleanupSnapshot" + }, + "mode": { + "description": "Execution or dry-run mode.", + "$ref": "#/$defs/CleanupMode" + }, + "removed": { + "description": "Exact removed snapshot, null for dry runs.", + "anyOf": [ + { + "$ref": "#/$defs/CleanupSnapshot" + }, + { + "type": "null" + } + ] + }, + "target": { + "description": "Resolved cleanup target.", + "$ref": "#/$defs/CleanupTarget" + } + }, + "required": [ + "mode", + "target", + "matched" + ] + }, + "CleanupMode": { + "description": "Cleanup mode.", + "oneOf": [ + { + "description": "Data was removed.", + "type": "string", + "const": "executed" + }, + { + "description": "No data was removed.", + "type": "string", + "const": "dry_run" + } + ] + }, + "CleanupSnapshot": { + "description": "Counts and disk usage associated with cleanup targets.", + "type": "object", + "properties": { + "analyses": { + "description": "Complete Analyses.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "analysis_profile_sha256": { + "description": "Unique bytewise-sorted Analysis Profile digests.", + "type": "array", + "items": { + "$ref": "#/$defs/Digest" + } + }, + "artifacts": { + "description": "Query Artifacts.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "diagnostic_logs": { + "description": "Diagnostic Log bundles.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "quarantined_analyses": { + "description": "Quarantined Analyses.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "usage": { + "description": "Logical and allocated usage.", + "$ref": "#/$defs/StorageUsage" + } + }, + "required": [ + "analyses", + "quarantined_analyses", + "artifacts", + "diagnostic_logs", + "usage", + "analysis_profile_sha256" + ] + }, + "CleanupTarget": { + "description": "Resolved cleanup target.", + "oneOf": [ + { + "description": "Digest resolved from a caller Sample.", + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "sample" + }, + "sha256": { + "description": "Resolved content identity.", + "$ref": "#/$defs/Digest" + }, + "source_path": { + "description": "Exact caller path.", + "$ref": "#/$defs/TaggedPath" + } + }, + "required": [ + "kind", + "source_path", + "sha256" + ] + }, + { + "description": "Caller-supplied digest.", + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "digest" + }, + "sha256": { + "description": "Selected content identity.", + "$ref": "#/$defs/Digest" + } + }, + "required": [ + "kind", + "sha256" + ] + }, + { + "description": "Entire Analysis Store.", + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "all" + } + }, + "required": [ + "kind" + ] + } + ] + }, + "Digest": { + "description": "A lowercase SHA-256 digest.", + "type": "string" + }, + "SandboxBackend": { + "description": "Sandbox backend.", + "oneOf": [ + { + "description": "Bubblewrap namespaces.", + "type": "string", + "const": "bubblewrap" + }, + { + "description": "Caller-supplied isolation.", + "type": "string", + "const": "external" + }, + { + "description": "Isolation disabled.", + "type": "string", + "const": "off" + } + ] + }, + "SandboxProvenance": { + "description": "Worker sandbox provenance.", + "type": "object", + "properties": { + "backend": { + "description": "Selected backend.", + "$ref": "#/$defs/SandboxBackend" + }, + "verification": { + "description": "Isolation verification state.", + "$ref": "#/$defs/SandboxVerification" + } + }, + "required": [ + "backend", + "verification" + ] + }, + "SandboxVerification": { + "description": "Sandbox verification state.", + "oneOf": [ + { + "description": "Verified by `ghidr`.", + "type": "string", + "const": "verified" + }, + { + "description": "Declared but not verifiable by `ghidr`.", + "type": "string", + "const": "unverified" + }, + { + "description": "Explicitly disabled.", + "type": "string", + "const": "disabled" + } + ] + }, + "StorageUsage": { + "description": "Filesystem usage represented without implying freed space.", + "type": "object", + "properties": { + "allocated_bytes": { + "description": "Sum of Linux allocated blocks including directories.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "logical_bytes": { + "description": "Sum of file lengths.", + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "logical_bytes", + "allocated_bytes" + ] + }, + "StoreSource": { + "description": "Analysis Store resolution source.", + "oneOf": [ + { + "description": "Explicit `--store` flag.", + "type": "string", + "const": "cli" + }, + { + "description": "`GHIDR_STORE` environment variable.", + "type": "string", + "const": "environment" + }, + { + "description": "Explicit `XDG_CACHE_HOME` fallback.", + "type": "string", + "const": "xdg" + }, + { + "description": "Home-directory fallback.", + "type": "string", + "const": "home_fallback" + } + ] + }, + "TaggedPath": { + "description": "A path preserving either UTF-8 text or exact Unix bytes.", + "oneOf": [ + { + "description": "An unmodified UTF-8 path.", + "type": "object", + "properties": { + "encoding": { + "type": "string", + "const": "utf8" + }, + "value": { + "type": "string" + } + }, + "required": [ + "encoding", + "value" + ] + }, + { + "description": "Standard padded Base64 over exact Unix path bytes.", + "type": "object", + "properties": { + "encoding": { + "type": "string", + "const": "unix_bytes_base64" + }, + "value": { + "type": "string" + } + }, + "required": [ + "encoding", + "value" + ] + } + ] + }, + "ToolProvenance": { + "description": "Minimal provenance available to non-Sample operations.", + "type": "object", + "properties": { + "adapter_protocol_version": { + "description": "Rust/Java protocol version.", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "analysis_store": { + "description": "Selected Analysis Store.", + "$ref": "#/$defs/AnalysisStoreProvenance" + }, + "ghidr_version": { + "description": "CLI package version.", + "type": "string" + }, + "sandbox": { + "description": "Selected sandbox policy.", + "$ref": "#/$defs/SandboxProvenance" + } + }, + "required": [ + "ghidr_version", + "adapter_protocol_version", + "analysis_store", + "sandbox" + ] + }, + "Warning": { + "description": "Stable non-fatal warning.", + "type": "object", + "properties": { + "code": { + "description": "Stable machine identifier.", + "type": "string" + }, + "details": { + "description": "Warning-specific context, always an object.", + "type": "object", + "additionalProperties": true + }, + "message": { + "description": "Concise human context.", + "type": "string" + } + }, + "required": [ + "code", + "message", + "details" + ] + } + } +} diff --git a/schemas/v1/decompilation.schema.json b/schemas/v1/decompilation.schema.json new file mode 100644 index 0000000..880e037 --- /dev/null +++ b/schemas/v1/decompilation.schema.json @@ -0,0 +1,507 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SuccessEnvelope", + "description": "Versioned successful response.", + "type": "object", + "properties": { + "data": { + "description": "Operation-specific result.", + "$ref": "#/$defs/DecompilationData" + }, + "kind": { + "type": "string", + "const": "decompilation" + }, + "provenance": { + "description": "Reproducibility and execution context.", + "$ref": "#/$defs/QueryProvenance" + }, + "schema_version": { + "type": "integer", + "const": 1 + }, + "warnings": { + "description": "Deduplicated stable warnings.", + "type": "array", + "items": { + "$ref": "#/$defs/Warning" + } + } + }, + "required": [ + "schema_version", + "kind", + "provenance", + "data", + "warnings" + ], + "$defs": { + "Address": { + "description": "A Ghidra address with an explicit, case-sensitive address space.", + "type": "object", + "properties": { + "offset": { + "description": "Canonical lowercase, `0x`-prefixed, even-width hexadecimal offset.", + "type": "string" + }, + "space": { + "description": "Exact Ghidra address-space name.", + "type": "string" + } + }, + "required": [ + "space", + "offset" + ] + }, + "AnalysisStoreProvenance": { + "description": "Analysis Store provenance.", + "type": "object", + "properties": { + "path": { + "description": "Tagged absolute store path.", + "$ref": "#/$defs/TaggedPath" + }, + "source": { + "description": "Store resolution source.", + "$ref": "#/$defs/StoreSource" + } + }, + "required": [ + "path", + "source" + ] + }, + "DecompilationData": { + "description": "Successful targeted decompilation data.", + "type": "object", + "properties": { + "decompilation": { + "description": "Complete normalized Ghidra C-like text.", + "$ref": "#/$defs/DecompiledText" + }, + "function": { + "description": "Unambiguously resolved Function.", + "$ref": "#/$defs/SelectedFunction" + }, + "requested_selector": { + "description": "Exact selector supplied by the caller.", + "$ref": "#/$defs/FunctionSelector" + } + }, + "required": [ + "requested_selector", + "function", + "decompilation" + ] + }, + "DecompilationSyntax": { + "description": "Decompiled text representation.", + "oneOf": [ + { + "description": "Ghidra's C-like representation.", + "type": "string", + "const": "ghidra_c" + } + ] + }, + "DecompiledText": { + "description": "Complete decompiler text.", + "type": "object", + "properties": { + "syntax": { + "description": "Always `ghidra_c` in version 0.1.", + "$ref": "#/$defs/DecompilationSyntax" + }, + "text": { + "description": "Complete text with only line endings normalized to LF.", + "type": "string" + } + }, + "required": [ + "syntax", + "text" + ] + }, + "Digest": { + "description": "A lowercase SHA-256 digest.", + "type": "string" + }, + "FunctionSelector": { + "description": "Exactly one explicit Function Selector.", + "oneOf": [ + { + "description": "Exact, case-sensitive Ghidra function name.", + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "name" + }, + "value": { + "type": "string" + } + }, + "required": [ + "kind", + "value" + ] + }, + { + "description": "Exact function entry address.", + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "address" + }, + "value": { + "$ref": "#/$defs/Address" + } + }, + "required": [ + "kind", + "value" + ] + } + ] + }, + "Limits": { + "description": "Resource policy selected for one invocation.", + "type": "object", + "properties": { + "analysis_timeout_seconds": { + "description": "Native analysis timeout.", + "$ref": "#/$defs/PositiveU64" + }, + "child_watchdog_seconds": { + "description": "Derived process watchdog.", + "$ref": "#/$defs/PositiveU64" + }, + "decompile_timeout_seconds": { + "description": "Optional native decompilation timeout.", + "anyOf": [ + { + "$ref": "#/$defs/PositiveU64" + }, + { + "type": "null" + } + ] + }, + "max_cpu": { + "description": "Maximum Ghidra analysis CPU count.", + "$ref": "#/$defs/PositiveU64" + }, + "max_heap_mib": { + "description": "Maximum Java heap, in MiB.", + "$ref": "#/$defs/PositiveU64" + }, + "max_inline_bytes": { + "description": "Maximum serialized successful stdout size.", + "$ref": "#/$defs/PositiveU64" + }, + "max_sample_bytes": { + "description": "Maximum accepted Sample size.", + "$ref": "#/$defs/PositiveU64" + } + }, + "required": [ + "max_heap_mib", + "max_cpu", + "analysis_timeout_seconds", + "child_watchdog_seconds", + "max_sample_bytes", + "max_inline_bytes" + ] + }, + "PositiveU64": { + "description": "A validated positive integer accepted by resource-limit flags.", + "type": "integer", + "format": "uint64", + "minimum": 1 + }, + "QueryProvenance": { + "description": "Fixed provenance for every Sample-backed Query.", + "type": "object", + "properties": { + "adapter_protocol_version": { + "description": "Rust/Java protocol version.", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "analysis_profile_sha256": { + "description": "Canonical Analysis Profile identity.", + "$ref": "#/$defs/Digest" + }, + "analysis_store": { + "description": "Selected Analysis Store.", + "$ref": "#/$defs/AnalysisStoreProvenance" + }, + "ghidr_version": { + "description": "CLI package version.", + "type": "string" + }, + "ghidra_version": { + "description": "Ghidra version.", + "type": "string" + }, + "java_version": { + "description": "Java major version.", + "type": "string" + }, + "limits": { + "description": "Selected and derived resource limits.", + "$ref": "#/$defs/Limits" + }, + "sample_sha256": { + "description": "Sample content identity.", + "$ref": "#/$defs/Digest" + }, + "sandbox": { + "description": "Selected sandbox policy.", + "$ref": "#/$defs/SandboxProvenance" + }, + "source_path": { + "description": "Exact caller-supplied path.", + "$ref": "#/$defs/TaggedPath" + }, + "target": { + "description": "Resolved Target Specification and verification.", + "$ref": "#/$defs/TargetProvenance" + } + }, + "required": [ + "ghidr_version", + "adapter_protocol_version", + "ghidra_version", + "java_version", + "sample_sha256", + "source_path", + "analysis_profile_sha256", + "target", + "analysis_store", + "sandbox", + "limits" + ] + }, + "SandboxBackend": { + "description": "Sandbox backend.", + "oneOf": [ + { + "description": "Bubblewrap namespaces.", + "type": "string", + "const": "bubblewrap" + }, + { + "description": "Caller-supplied isolation.", + "type": "string", + "const": "external" + }, + { + "description": "Isolation disabled.", + "type": "string", + "const": "off" + } + ] + }, + "SandboxProvenance": { + "description": "Worker sandbox provenance.", + "type": "object", + "properties": { + "backend": { + "description": "Selected backend.", + "$ref": "#/$defs/SandboxBackend" + }, + "verification": { + "description": "Isolation verification state.", + "$ref": "#/$defs/SandboxVerification" + } + }, + "required": [ + "backend", + "verification" + ] + }, + "SandboxVerification": { + "description": "Sandbox verification state.", + "oneOf": [ + { + "description": "Verified by `ghidr`.", + "type": "string", + "const": "verified" + }, + { + "description": "Declared but not verifiable by `ghidr`.", + "type": "string", + "const": "unverified" + }, + { + "description": "Explicitly disabled.", + "type": "string", + "const": "disabled" + } + ] + }, + "SelectedFunction": { + "description": "Identity of a selected Function.", + "type": "object", + "properties": { + "entry": { + "description": "Entry Address.", + "$ref": "#/$defs/Address" + }, + "name": { + "description": "Exact basename.", + "type": "string" + }, + "qualified_name": { + "description": "Exact fully-qualified name.", + "type": "string" + } + }, + "required": [ + "name", + "qualified_name", + "entry" + ] + }, + "StoreSource": { + "description": "Analysis Store resolution source.", + "oneOf": [ + { + "description": "Explicit `--store` flag.", + "type": "string", + "const": "cli" + }, + { + "description": "`GHIDR_STORE` environment variable.", + "type": "string", + "const": "environment" + }, + { + "description": "Explicit `XDG_CACHE_HOME` fallback.", + "type": "string", + "const": "xdg" + }, + { + "description": "Home-directory fallback.", + "type": "string", + "const": "home_fallback" + } + ] + }, + "TaggedPath": { + "description": "A path preserving either UTF-8 text or exact Unix bytes.", + "oneOf": [ + { + "description": "An unmodified UTF-8 path.", + "type": "object", + "properties": { + "encoding": { + "type": "string", + "const": "utf8" + }, + "value": { + "type": "string" + } + }, + "required": [ + "encoding", + "value" + ] + }, + { + "description": "Standard padded Base64 over exact Unix path bytes.", + "type": "object", + "properties": { + "encoding": { + "type": "string", + "const": "unix_bytes_base64" + }, + "value": { + "type": "string" + } + }, + "required": [ + "encoding", + "value" + ] + } + ] + }, + "TargetProvenance": { + "description": "Target Specification provenance.", + "type": "object", + "properties": { + "compiler_specification": { + "description": "Exact compiler-specification identifier.", + "type": "string" + }, + "format": { + "description": "Executable format name.", + "type": "string" + }, + "loader": { + "description": "Exact loader identifier.", + "type": "string" + }, + "processor_language": { + "description": "Exact processor-language identifier.", + "type": "string" + }, + "verification": { + "description": "Project integration-test coverage.", + "$ref": "#/$defs/TargetVerification" + } + }, + "required": [ + "loader", + "format", + "processor_language", + "compiler_specification", + "verification" + ] + }, + "TargetVerification": { + "description": "Project integration-test coverage for a recognized target.", + "oneOf": [ + { + "description": "Covered by version 0.1 real-Ghidra fixtures.", + "type": "string", + "const": "verified" + }, + { + "description": "Recognized by Ghidra but outside the tested matrix.", + "type": "string", + "const": "unverified" + } + ] + }, + "Warning": { + "description": "Stable non-fatal warning.", + "type": "object", + "properties": { + "code": { + "description": "Stable machine identifier.", + "type": "string" + }, + "details": { + "description": "Warning-specific context, always an object.", + "type": "object", + "additionalProperties": true + }, + "message": { + "description": "Concise human context.", + "type": "string" + } + }, + "required": [ + "code", + "message", + "details" + ] + } + } +} diff --git a/schemas/v1/doctor.schema.json b/schemas/v1/doctor.schema.json new file mode 100644 index 0000000..11297de --- /dev/null +++ b/schemas/v1/doctor.schema.json @@ -0,0 +1,304 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SuccessEnvelope", + "description": "Versioned successful response.", + "type": "object", + "properties": { + "data": { + "description": "Operation-specific result.", + "$ref": "#/$defs/DoctorData" + }, + "kind": { + "type": "string", + "const": "doctor" + }, + "provenance": { + "description": "Reproducibility and execution context.", + "$ref": "#/$defs/ToolProvenance" + }, + "schema_version": { + "type": "integer", + "const": 1 + }, + "warnings": { + "description": "Deduplicated stable warnings.", + "type": "array", + "items": { + "$ref": "#/$defs/Warning" + } + } + }, + "required": [ + "schema_version", + "kind", + "provenance", + "data", + "warnings" + ], + "$defs": { + "AnalysisStoreProvenance": { + "description": "Analysis Store provenance.", + "type": "object", + "properties": { + "path": { + "description": "Tagged absolute store path.", + "$ref": "#/$defs/TaggedPath" + }, + "source": { + "description": "Store resolution source.", + "$ref": "#/$defs/StoreSource" + } + }, + "required": [ + "path", + "source" + ] + }, + "ComponentReport": { + "description": "One diagnosed component.", + "type": "object", + "properties": { + "details": { + "description": "Component-specific facts.", + "type": "object", + "additionalProperties": true + }, + "status": { + "description": "Stable readiness classification.", + "$ref": "#/$defs/ComponentStatus" + } + }, + "required": [ + "status", + "details" + ] + }, + "ComponentStatus": { + "description": "Doctor component readiness classification.", + "oneOf": [ + { + "description": "Component is usable.", + "type": "string", + "const": "ready" + }, + { + "description": "Component is absent.", + "type": "string", + "const": "missing" + }, + { + "description": "Component version is unsupported.", + "type": "string", + "const": "incompatible" + }, + { + "description": "Component configuration is invalid.", + "type": "string", + "const": "invalid" + }, + { + "description": "Component storage cannot be written.", + "type": "string", + "const": "unwritable" + } + ] + }, + "DoctorData": { + "description": "Installation diagnosis data.", + "type": "object", + "properties": { + "components": { + "description": "Typed component reports keyed by stable component name.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/ComponentReport" + } + }, + "ready": { + "description": "True only when every required component is ready.", + "type": "boolean" + } + }, + "required": [ + "ready", + "components" + ] + }, + "SandboxBackend": { + "description": "Sandbox backend.", + "oneOf": [ + { + "description": "Bubblewrap namespaces.", + "type": "string", + "const": "bubblewrap" + }, + { + "description": "Caller-supplied isolation.", + "type": "string", + "const": "external" + }, + { + "description": "Isolation disabled.", + "type": "string", + "const": "off" + } + ] + }, + "SandboxProvenance": { + "description": "Worker sandbox provenance.", + "type": "object", + "properties": { + "backend": { + "description": "Selected backend.", + "$ref": "#/$defs/SandboxBackend" + }, + "verification": { + "description": "Isolation verification state.", + "$ref": "#/$defs/SandboxVerification" + } + }, + "required": [ + "backend", + "verification" + ] + }, + "SandboxVerification": { + "description": "Sandbox verification state.", + "oneOf": [ + { + "description": "Verified by `ghidr`.", + "type": "string", + "const": "verified" + }, + { + "description": "Declared but not verifiable by `ghidr`.", + "type": "string", + "const": "unverified" + }, + { + "description": "Explicitly disabled.", + "type": "string", + "const": "disabled" + } + ] + }, + "StoreSource": { + "description": "Analysis Store resolution source.", + "oneOf": [ + { + "description": "Explicit `--store` flag.", + "type": "string", + "const": "cli" + }, + { + "description": "`GHIDR_STORE` environment variable.", + "type": "string", + "const": "environment" + }, + { + "description": "Explicit `XDG_CACHE_HOME` fallback.", + "type": "string", + "const": "xdg" + }, + { + "description": "Home-directory fallback.", + "type": "string", + "const": "home_fallback" + } + ] + }, + "TaggedPath": { + "description": "A path preserving either UTF-8 text or exact Unix bytes.", + "oneOf": [ + { + "description": "An unmodified UTF-8 path.", + "type": "object", + "properties": { + "encoding": { + "type": "string", + "const": "utf8" + }, + "value": { + "type": "string" + } + }, + "required": [ + "encoding", + "value" + ] + }, + { + "description": "Standard padded Base64 over exact Unix path bytes.", + "type": "object", + "properties": { + "encoding": { + "type": "string", + "const": "unix_bytes_base64" + }, + "value": { + "type": "string" + } + }, + "required": [ + "encoding", + "value" + ] + } + ] + }, + "ToolProvenance": { + "description": "Minimal provenance available to non-Sample operations.", + "type": "object", + "properties": { + "adapter_protocol_version": { + "description": "Rust/Java protocol version.", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "analysis_store": { + "description": "Selected Analysis Store.", + "$ref": "#/$defs/AnalysisStoreProvenance" + }, + "ghidr_version": { + "description": "CLI package version.", + "type": "string" + }, + "sandbox": { + "description": "Selected sandbox policy.", + "$ref": "#/$defs/SandboxProvenance" + } + }, + "required": [ + "ghidr_version", + "adapter_protocol_version", + "analysis_store", + "sandbox" + ] + }, + "Warning": { + "description": "Stable non-fatal warning.", + "type": "object", + "properties": { + "code": { + "description": "Stable machine identifier.", + "type": "string" + }, + "details": { + "description": "Warning-specific context, always an object.", + "type": "object", + "additionalProperties": true + }, + "message": { + "description": "Concise human context.", + "type": "string" + } + }, + "required": [ + "code", + "message", + "details" + ] + } + } +} diff --git a/schemas/v1/error.schema.json b/schemas/v1/error.schema.json new file mode 100644 index 0000000..30d1f0f --- /dev/null +++ b/schemas/v1/error.schema.json @@ -0,0 +1,241 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ErrorEnvelope", + "description": "Versioned failure document.", + "type": "object", + "properties": { + "error": { + "description": "Typed failure body.", + "$ref": "#/$defs/ErrorBody" + }, + "kind": { + "description": "Always `error`.", + "$ref": "#/$defs/ErrorKind" + }, + "schema_version": { + "type": "integer", + "const": 1 + } + }, + "required": [ + "schema_version", + "kind", + "error" + ], + "$defs": { + "ErrorBody": { + "description": "Stable public error body.", + "type": "object", + "properties": { + "code": { + "description": "Stable machine identifier.", + "$ref": "#/$defs/ErrorCode" + }, + "details": { + "description": "Code-specific structured context, always an object.", + "type": "object", + "additionalProperties": true + }, + "message": { + "description": "Concise context that is not a control-flow interface.", + "type": "string" + }, + "retryable": { + "description": "Whether the same logical request might later succeed unchanged.", + "type": "boolean" + } + }, + "required": [ + "code", + "message", + "retryable", + "details" + ] + }, + "ErrorCode": { + "description": "Stable machine-readable error taxonomy available in the foundation.", + "oneOf": [ + { + "description": "Command-line syntax or validation failed.", + "type": "string", + "const": "invalid_arguments" + }, + { + "description": "The caller's Sample path does not exist.", + "type": "string", + "const": "sample_not_found" + }, + { + "description": "The caller's Sample cannot be read.", + "type": "string", + "const": "sample_unreadable" + }, + { + "description": "The opened Sample is not a regular file.", + "type": "string", + "const": "invalid_sample_type" + }, + { + "description": "The Sample exceeded the selected byte ceiling.", + "type": "string", + "const": "sample_too_large" + }, + { + "description": "Source metadata changed while snapshotting.", + "type": "string", + "const": "sample_changed" + }, + { + "description": "The Analysis Store lacks the required free-space reserve.", + "type": "string", + "const": "insufficient_store_space" + }, + { + "description": "Ghidra could not recognize a Target Specification.", + "type": "string", + "const": "unsupported_target" + }, + { + "description": "Ghidra found multiple Target Specification candidates.", + "type": "string", + "const": "ambiguous_target" + }, + { + "description": "Ghidra is not installed.", + "type": "string", + "const": "ghidra_missing" + }, + { + "description": "The installed Ghidra version is incompatible.", + "type": "string", + "const": "ghidra_incompatible" + }, + { + "description": "Java is not installed.", + "type": "string", + "const": "java_missing" + }, + { + "description": "The installed Java version is incompatible.", + "type": "string", + "const": "java_incompatible" + }, + { + "description": "A later integration layer has not connected execution yet.", + "type": "string", + "const": "internal_not_implemented" + }, + { + "description": "Rust/Java request or response validation failed.", + "type": "string", + "const": "protocol_violation" + }, + { + "description": "The child exceeded a native or watchdog timeout.", + "type": "string", + "const": "timeout" + }, + { + "description": "Native auto-analysis timed out.", + "type": "string", + "const": "analysis_timeout" + }, + { + "description": "Ghidra auto-analysis failed.", + "type": "string", + "const": "analysis_failed" + }, + { + "description": "Native Function decompilation timed out.", + "type": "string", + "const": "decompile_timeout" + }, + { + "description": "Ghidra Function decompilation failed.", + "type": "string", + "const": "decompilation_failed" + }, + { + "description": "The caller interrupted the operation.", + "type": "string", + "const": "interrupted" + }, + { + "description": "The adapter could not serialize a bounded response.", + "type": "string", + "const": "result_too_large" + }, + { + "description": "The success descriptor cannot fit the requested inline budget.", + "type": "string", + "const": "inline_budget_too_small" + }, + { + "description": "Store-wide cleanup lacks explicit confirmation.", + "type": "string", + "const": "confirmation_required" + }, + { + "description": "Another process holds the selected Analysis lock.", + "type": "string", + "const": "analysis_busy" + }, + { + "description": "Stored Analysis validation failed and recovery did not succeed.", + "type": "string", + "const": "corrupt_analysis" + }, + { + "description": "A cleanup transaction requires a recoverable retry.", + "type": "string", + "const": "cleanup_incomplete" + }, + { + "description": "The resolved Analysis Store path is invalid.", + "type": "string", + "const": "invalid_store_path" + }, + { + "description": "A Function Selector resolved to no Function.", + "type": "string", + "const": "function_not_found" + }, + { + "description": "A Function Selector resolved to multiple Functions.", + "type": "string", + "const": "function_selector_ambiguous" + }, + { + "description": "An Address is not a Function entry.", + "type": "string", + "const": "function_entry_required" + }, + { + "description": "Ghidra does not contain the requested address space.", + "type": "string", + "const": "address_space_not_found" + }, + { + "description": "Installation diagnosis found an unusable component.", + "type": "string", + "const": "doctor_failed" + }, + { + "description": "A general runtime or integration failure.", + "type": "string", + "const": "internal" + } + ] + }, + "ErrorKind": { + "description": "Error envelope discriminator.", + "oneOf": [ + { + "description": "A failed invocation.", + "type": "string", + "const": "error" + } + ] + } + } +} diff --git a/schemas/v1/functions.schema.json b/schemas/v1/functions.schema.json new file mode 100644 index 0000000..873f9df --- /dev/null +++ b/schemas/v1/functions.schema.json @@ -0,0 +1,539 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SuccessEnvelope", + "description": "Versioned successful response.", + "type": "object", + "properties": { + "data": { + "description": "Operation-specific result.", + "$ref": "#/$defs/FunctionsData" + }, + "kind": { + "type": "string", + "const": "functions" + }, + "provenance": { + "description": "Reproducibility and execution context.", + "$ref": "#/$defs/QueryProvenance" + }, + "schema_version": { + "type": "integer", + "const": 1 + }, + "warnings": { + "description": "Deduplicated stable warnings.", + "type": "array", + "items": { + "$ref": "#/$defs/Warning" + } + } + }, + "required": [ + "schema_version", + "kind", + "provenance", + "data", + "warnings" + ], + "$defs": { + "Address": { + "description": "A Ghidra address with an explicit, case-sensitive address space.", + "type": "object", + "properties": { + "offset": { + "description": "Canonical lowercase, `0x`-prefixed, even-width hexadecimal offset.", + "type": "string" + }, + "space": { + "description": "Exact Ghidra address-space name.", + "type": "string" + } + }, + "required": [ + "space", + "offset" + ] + }, + "AnalysisStoreProvenance": { + "description": "Analysis Store provenance.", + "type": "object", + "properties": { + "path": { + "description": "Tagged absolute store path.", + "$ref": "#/$defs/TaggedPath" + }, + "source": { + "description": "Store resolution source.", + "$ref": "#/$defs/StoreSource" + } + }, + "required": [ + "path", + "source" + ] + }, + "Digest": { + "description": "A lowercase SHA-256 digest.", + "type": "string" + }, + "FunctionItem": { + "description": "Minimal version 0.1 Function record.", + "type": "object", + "properties": { + "body_address_count": { + "description": "Number of Addresses in the body.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "decompilable": { + "description": "Whether the Function is a valid decompilation target.", + "type": "boolean" + }, + "entry": { + "description": "Function entry Address.", + "$ref": "#/$defs/Address" + }, + "is_external": { + "description": "Whether Ghidra marks the Function external.", + "type": "boolean" + }, + "is_thunk": { + "description": "Whether Ghidra marks the Function a thunk.", + "type": "boolean" + }, + "location": { + "description": "Memory or external location.", + "$ref": "#/$defs/FunctionLocation" + }, + "name": { + "description": "Exact basename.", + "type": "string" + }, + "qualified_name": { + "description": "Exact fully-qualified name.", + "type": "string" + }, + "thunk_target_entry": { + "description": "Resolved thunk target entry when available.", + "anyOf": [ + { + "$ref": "#/$defs/Address" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "name", + "qualified_name", + "entry", + "body_address_count", + "location", + "is_external", + "is_thunk", + "decompilable" + ] + }, + "FunctionLocation": { + "description": "Function location class used by stable ordering.", + "oneOf": [ + { + "description": "Function has a memory Address.", + "type": "string", + "const": "memory" + }, + { + "description": "External/imported Function.", + "type": "string", + "const": "external" + } + ] + }, + "FunctionsData": { + "description": "Function collection data.", + "type": "object", + "properties": { + "items": { + "description": "Function records in documented order.", + "type": "array", + "items": { + "$ref": "#/$defs/FunctionItem" + } + }, + "page": { + "description": "Stable page facts.", + "$ref": "#/$defs/PageMetadata" + } + }, + "required": [ + "page", + "items" + ] + }, + "Limits": { + "description": "Resource policy selected for one invocation.", + "type": "object", + "properties": { + "analysis_timeout_seconds": { + "description": "Native analysis timeout.", + "$ref": "#/$defs/PositiveU64" + }, + "child_watchdog_seconds": { + "description": "Derived process watchdog.", + "$ref": "#/$defs/PositiveU64" + }, + "decompile_timeout_seconds": { + "description": "Optional native decompilation timeout.", + "anyOf": [ + { + "$ref": "#/$defs/PositiveU64" + }, + { + "type": "null" + } + ] + }, + "max_cpu": { + "description": "Maximum Ghidra analysis CPU count.", + "$ref": "#/$defs/PositiveU64" + }, + "max_heap_mib": { + "description": "Maximum Java heap, in MiB.", + "$ref": "#/$defs/PositiveU64" + }, + "max_inline_bytes": { + "description": "Maximum serialized successful stdout size.", + "$ref": "#/$defs/PositiveU64" + }, + "max_sample_bytes": { + "description": "Maximum accepted Sample size.", + "$ref": "#/$defs/PositiveU64" + } + }, + "required": [ + "max_heap_mib", + "max_cpu", + "analysis_timeout_seconds", + "child_watchdog_seconds", + "max_sample_bytes", + "max_inline_bytes" + ] + }, + "PageMetadata": { + "description": "Stable page metadata.", + "type": "object", + "properties": { + "has_more": { + "description": "Whether a subsequent page exists.", + "type": "boolean" + }, + "limit": { + "description": "Applied item bound, null only for explicit `--all`.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "offset": { + "description": "Number of items skipped.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "order": { + "description": "Stable order identifier.", + "type": "string" + }, + "returned": { + "description": "Number of returned items.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "total": { + "description": "Total items in the immutable Analysis.", + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "order", + "offset", + "returned", + "total", + "has_more" + ] + }, + "PositiveU64": { + "description": "A validated positive integer accepted by resource-limit flags.", + "type": "integer", + "format": "uint64", + "minimum": 1 + }, + "QueryProvenance": { + "description": "Fixed provenance for every Sample-backed Query.", + "type": "object", + "properties": { + "adapter_protocol_version": { + "description": "Rust/Java protocol version.", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "analysis_profile_sha256": { + "description": "Canonical Analysis Profile identity.", + "$ref": "#/$defs/Digest" + }, + "analysis_store": { + "description": "Selected Analysis Store.", + "$ref": "#/$defs/AnalysisStoreProvenance" + }, + "ghidr_version": { + "description": "CLI package version.", + "type": "string" + }, + "ghidra_version": { + "description": "Ghidra version.", + "type": "string" + }, + "java_version": { + "description": "Java major version.", + "type": "string" + }, + "limits": { + "description": "Selected and derived resource limits.", + "$ref": "#/$defs/Limits" + }, + "sample_sha256": { + "description": "Sample content identity.", + "$ref": "#/$defs/Digest" + }, + "sandbox": { + "description": "Selected sandbox policy.", + "$ref": "#/$defs/SandboxProvenance" + }, + "source_path": { + "description": "Exact caller-supplied path.", + "$ref": "#/$defs/TaggedPath" + }, + "target": { + "description": "Resolved Target Specification and verification.", + "$ref": "#/$defs/TargetProvenance" + } + }, + "required": [ + "ghidr_version", + "adapter_protocol_version", + "ghidra_version", + "java_version", + "sample_sha256", + "source_path", + "analysis_profile_sha256", + "target", + "analysis_store", + "sandbox", + "limits" + ] + }, + "SandboxBackend": { + "description": "Sandbox backend.", + "oneOf": [ + { + "description": "Bubblewrap namespaces.", + "type": "string", + "const": "bubblewrap" + }, + { + "description": "Caller-supplied isolation.", + "type": "string", + "const": "external" + }, + { + "description": "Isolation disabled.", + "type": "string", + "const": "off" + } + ] + }, + "SandboxProvenance": { + "description": "Worker sandbox provenance.", + "type": "object", + "properties": { + "backend": { + "description": "Selected backend.", + "$ref": "#/$defs/SandboxBackend" + }, + "verification": { + "description": "Isolation verification state.", + "$ref": "#/$defs/SandboxVerification" + } + }, + "required": [ + "backend", + "verification" + ] + }, + "SandboxVerification": { + "description": "Sandbox verification state.", + "oneOf": [ + { + "description": "Verified by `ghidr`.", + "type": "string", + "const": "verified" + }, + { + "description": "Declared but not verifiable by `ghidr`.", + "type": "string", + "const": "unverified" + }, + { + "description": "Explicitly disabled.", + "type": "string", + "const": "disabled" + } + ] + }, + "StoreSource": { + "description": "Analysis Store resolution source.", + "oneOf": [ + { + "description": "Explicit `--store` flag.", + "type": "string", + "const": "cli" + }, + { + "description": "`GHIDR_STORE` environment variable.", + "type": "string", + "const": "environment" + }, + { + "description": "Explicit `XDG_CACHE_HOME` fallback.", + "type": "string", + "const": "xdg" + }, + { + "description": "Home-directory fallback.", + "type": "string", + "const": "home_fallback" + } + ] + }, + "TaggedPath": { + "description": "A path preserving either UTF-8 text or exact Unix bytes.", + "oneOf": [ + { + "description": "An unmodified UTF-8 path.", + "type": "object", + "properties": { + "encoding": { + "type": "string", + "const": "utf8" + }, + "value": { + "type": "string" + } + }, + "required": [ + "encoding", + "value" + ] + }, + { + "description": "Standard padded Base64 over exact Unix path bytes.", + "type": "object", + "properties": { + "encoding": { + "type": "string", + "const": "unix_bytes_base64" + }, + "value": { + "type": "string" + } + }, + "required": [ + "encoding", + "value" + ] + } + ] + }, + "TargetProvenance": { + "description": "Target Specification provenance.", + "type": "object", + "properties": { + "compiler_specification": { + "description": "Exact compiler-specification identifier.", + "type": "string" + }, + "format": { + "description": "Executable format name.", + "type": "string" + }, + "loader": { + "description": "Exact loader identifier.", + "type": "string" + }, + "processor_language": { + "description": "Exact processor-language identifier.", + "type": "string" + }, + "verification": { + "description": "Project integration-test coverage.", + "$ref": "#/$defs/TargetVerification" + } + }, + "required": [ + "loader", + "format", + "processor_language", + "compiler_specification", + "verification" + ] + }, + "TargetVerification": { + "description": "Project integration-test coverage for a recognized target.", + "oneOf": [ + { + "description": "Covered by version 0.1 real-Ghidra fixtures.", + "type": "string", + "const": "verified" + }, + { + "description": "Recognized by Ghidra but outside the tested matrix.", + "type": "string", + "const": "unverified" + } + ] + }, + "Warning": { + "description": "Stable non-fatal warning.", + "type": "object", + "properties": { + "code": { + "description": "Stable machine identifier.", + "type": "string" + }, + "details": { + "description": "Warning-specific context, always an object.", + "type": "object", + "additionalProperties": true + }, + "message": { + "description": "Concise human context.", + "type": "string" + } + }, + "required": [ + "code", + "message", + "details" + ] + } + } +} diff --git a/schemas/v1/inspection.schema.json b/schemas/v1/inspection.schema.json new file mode 100644 index 0000000..6c8f046 --- /dev/null +++ b/schemas/v1/inspection.schema.json @@ -0,0 +1,518 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SuccessEnvelope", + "description": "Versioned successful response.", + "type": "object", + "properties": { + "data": { + "description": "Operation-specific result.", + "$ref": "#/$defs/InspectionData" + }, + "kind": { + "type": "string", + "const": "inspection" + }, + "provenance": { + "description": "Reproducibility and execution context.", + "$ref": "#/$defs/QueryProvenance" + }, + "schema_version": { + "type": "integer", + "const": 1 + }, + "warnings": { + "description": "Deduplicated stable warnings.", + "type": "array", + "items": { + "$ref": "#/$defs/Warning" + } + } + }, + "required": [ + "schema_version", + "kind", + "provenance", + "data", + "warnings" + ], + "$defs": { + "Address": { + "description": "A Ghidra address with an explicit, case-sensitive address space.", + "type": "object", + "properties": { + "offset": { + "description": "Canonical lowercase, `0x`-prefixed, even-width hexadecimal offset.", + "type": "string" + }, + "space": { + "description": "Exact Ghidra address-space name.", + "type": "string" + } + }, + "required": [ + "space", + "offset" + ] + }, + "AnalysisDisposition": { + "description": "Analysis selection disposition.", + "oneOf": [ + { + "description": "A new Analysis was created.", + "type": "string", + "const": "created" + }, + { + "description": "A compatible Analysis was reused.", + "type": "string", + "const": "reused" + }, + { + "description": "Corrupt stored data was quarantined and rebuilt once.", + "type": "string", + "const": "rebuilt" + } + ] + }, + "AnalysisStoreProvenance": { + "description": "Analysis Store provenance.", + "type": "object", + "properties": { + "path": { + "description": "Tagged absolute store path.", + "$ref": "#/$defs/TaggedPath" + }, + "source": { + "description": "Store resolution source.", + "$ref": "#/$defs/StoreSource" + } + }, + "required": [ + "path", + "source" + ] + }, + "AnalysisSummary": { + "description": "Analysis selection summary.", + "type": "object", + "properties": { + "disposition": { + "description": "Whether compatible analysis was built or reused.", + "$ref": "#/$defs/AnalysisDisposition" + } + }, + "required": [ + "disposition" + ] + }, + "Digest": { + "description": "A lowercase SHA-256 digest.", + "type": "string" + }, + "InspectionData": { + "description": "Sample inspection data.", + "type": "object", + "properties": { + "analysis": { + "description": "Reuse disposition.", + "$ref": "#/$defs/AnalysisSummary" + }, + "program": { + "description": "Program address and Function summary.", + "$ref": "#/$defs/ProgramSummary" + }, + "sample": { + "description": "Sample identity and size.", + "$ref": "#/$defs/SampleSummary" + } + }, + "required": [ + "sample", + "program", + "analysis" + ] + }, + "Limits": { + "description": "Resource policy selected for one invocation.", + "type": "object", + "properties": { + "analysis_timeout_seconds": { + "description": "Native analysis timeout.", + "$ref": "#/$defs/PositiveU64" + }, + "child_watchdog_seconds": { + "description": "Derived process watchdog.", + "$ref": "#/$defs/PositiveU64" + }, + "decompile_timeout_seconds": { + "description": "Optional native decompilation timeout.", + "anyOf": [ + { + "$ref": "#/$defs/PositiveU64" + }, + { + "type": "null" + } + ] + }, + "max_cpu": { + "description": "Maximum Ghidra analysis CPU count.", + "$ref": "#/$defs/PositiveU64" + }, + "max_heap_mib": { + "description": "Maximum Java heap, in MiB.", + "$ref": "#/$defs/PositiveU64" + }, + "max_inline_bytes": { + "description": "Maximum serialized successful stdout size.", + "$ref": "#/$defs/PositiveU64" + }, + "max_sample_bytes": { + "description": "Maximum accepted Sample size.", + "$ref": "#/$defs/PositiveU64" + } + }, + "required": [ + "max_heap_mib", + "max_cpu", + "analysis_timeout_seconds", + "child_watchdog_seconds", + "max_sample_bytes", + "max_inline_bytes" + ] + }, + "PositiveU64": { + "description": "A validated positive integer accepted by resource-limit flags.", + "type": "integer", + "format": "uint64", + "minimum": 1 + }, + "ProgramSummary": { + "description": "Program summary.", + "type": "object", + "properties": { + "function_count": { + "description": "Count of memory, external, and thunk Functions.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "image_base": { + "description": "Image base when applicable.", + "anyOf": [ + { + "$ref": "#/$defs/Address" + }, + { + "type": "null" + } + ] + }, + "maximum_address": { + "description": "Maximum Address when applicable.", + "anyOf": [ + { + "$ref": "#/$defs/Address" + }, + { + "type": "null" + } + ] + }, + "minimum_address": { + "description": "Minimum Address when applicable.", + "anyOf": [ + { + "$ref": "#/$defs/Address" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "function_count" + ] + }, + "QueryProvenance": { + "description": "Fixed provenance for every Sample-backed Query.", + "type": "object", + "properties": { + "adapter_protocol_version": { + "description": "Rust/Java protocol version.", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "analysis_profile_sha256": { + "description": "Canonical Analysis Profile identity.", + "$ref": "#/$defs/Digest" + }, + "analysis_store": { + "description": "Selected Analysis Store.", + "$ref": "#/$defs/AnalysisStoreProvenance" + }, + "ghidr_version": { + "description": "CLI package version.", + "type": "string" + }, + "ghidra_version": { + "description": "Ghidra version.", + "type": "string" + }, + "java_version": { + "description": "Java major version.", + "type": "string" + }, + "limits": { + "description": "Selected and derived resource limits.", + "$ref": "#/$defs/Limits" + }, + "sample_sha256": { + "description": "Sample content identity.", + "$ref": "#/$defs/Digest" + }, + "sandbox": { + "description": "Selected sandbox policy.", + "$ref": "#/$defs/SandboxProvenance" + }, + "source_path": { + "description": "Exact caller-supplied path.", + "$ref": "#/$defs/TaggedPath" + }, + "target": { + "description": "Resolved Target Specification and verification.", + "$ref": "#/$defs/TargetProvenance" + } + }, + "required": [ + "ghidr_version", + "adapter_protocol_version", + "ghidra_version", + "java_version", + "sample_sha256", + "source_path", + "analysis_profile_sha256", + "target", + "analysis_store", + "sandbox", + "limits" + ] + }, + "SampleSummary": { + "description": "Sample identity summary.", + "type": "object", + "properties": { + "sha256": { + "description": "SHA-256 content identity.", + "$ref": "#/$defs/Digest" + }, + "size_bytes": { + "description": "Exact byte length.", + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "sha256", + "size_bytes" + ] + }, + "SandboxBackend": { + "description": "Sandbox backend.", + "oneOf": [ + { + "description": "Bubblewrap namespaces.", + "type": "string", + "const": "bubblewrap" + }, + { + "description": "Caller-supplied isolation.", + "type": "string", + "const": "external" + }, + { + "description": "Isolation disabled.", + "type": "string", + "const": "off" + } + ] + }, + "SandboxProvenance": { + "description": "Worker sandbox provenance.", + "type": "object", + "properties": { + "backend": { + "description": "Selected backend.", + "$ref": "#/$defs/SandboxBackend" + }, + "verification": { + "description": "Isolation verification state.", + "$ref": "#/$defs/SandboxVerification" + } + }, + "required": [ + "backend", + "verification" + ] + }, + "SandboxVerification": { + "description": "Sandbox verification state.", + "oneOf": [ + { + "description": "Verified by `ghidr`.", + "type": "string", + "const": "verified" + }, + { + "description": "Declared but not verifiable by `ghidr`.", + "type": "string", + "const": "unverified" + }, + { + "description": "Explicitly disabled.", + "type": "string", + "const": "disabled" + } + ] + }, + "StoreSource": { + "description": "Analysis Store resolution source.", + "oneOf": [ + { + "description": "Explicit `--store` flag.", + "type": "string", + "const": "cli" + }, + { + "description": "`GHIDR_STORE` environment variable.", + "type": "string", + "const": "environment" + }, + { + "description": "Explicit `XDG_CACHE_HOME` fallback.", + "type": "string", + "const": "xdg" + }, + { + "description": "Home-directory fallback.", + "type": "string", + "const": "home_fallback" + } + ] + }, + "TaggedPath": { + "description": "A path preserving either UTF-8 text or exact Unix bytes.", + "oneOf": [ + { + "description": "An unmodified UTF-8 path.", + "type": "object", + "properties": { + "encoding": { + "type": "string", + "const": "utf8" + }, + "value": { + "type": "string" + } + }, + "required": [ + "encoding", + "value" + ] + }, + { + "description": "Standard padded Base64 over exact Unix path bytes.", + "type": "object", + "properties": { + "encoding": { + "type": "string", + "const": "unix_bytes_base64" + }, + "value": { + "type": "string" + } + }, + "required": [ + "encoding", + "value" + ] + } + ] + }, + "TargetProvenance": { + "description": "Target Specification provenance.", + "type": "object", + "properties": { + "compiler_specification": { + "description": "Exact compiler-specification identifier.", + "type": "string" + }, + "format": { + "description": "Executable format name.", + "type": "string" + }, + "loader": { + "description": "Exact loader identifier.", + "type": "string" + }, + "processor_language": { + "description": "Exact processor-language identifier.", + "type": "string" + }, + "verification": { + "description": "Project integration-test coverage.", + "$ref": "#/$defs/TargetVerification" + } + }, + "required": [ + "loader", + "format", + "processor_language", + "compiler_specification", + "verification" + ] + }, + "TargetVerification": { + "description": "Project integration-test coverage for a recognized target.", + "oneOf": [ + { + "description": "Covered by version 0.1 real-Ghidra fixtures.", + "type": "string", + "const": "verified" + }, + { + "description": "Recognized by Ghidra but outside the tested matrix.", + "type": "string", + "const": "unverified" + } + ] + }, + "Warning": { + "description": "Stable non-fatal warning.", + "type": "object", + "properties": { + "code": { + "description": "Stable machine identifier.", + "type": "string" + }, + "details": { + "description": "Warning-specific context, always an object.", + "type": "object", + "additionalProperties": true + }, + "message": { + "description": "Concise human context.", + "type": "string" + } + }, + "required": [ + "code", + "message", + "details" + ] + } + } +} diff --git a/schemas/v1/protocol-request.schema.json b/schemas/v1/protocol-request.schema.json new file mode 100644 index 0000000..d07305f --- /dev/null +++ b/schemas/v1/protocol-request.schema.json @@ -0,0 +1,341 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "AdapterRequest", + "description": "Strict adapter request.", + "type": "object", + "properties": { + "analysis_path": { + "description": "Private Ghidra project or staging directory when required.", + "anyOf": [ + { + "$ref": "#/$defs/WorkerPath" + }, + { + "type": "null" + } + ] + }, + "arguments": { + "description": "Operation-specific validated arguments.", + "$ref": "#/$defs/RequestArguments" + }, + "invocation_id": { + "description": "Random 128-bit identifier echoed by the response.", + "$ref": "#/$defs/InvocationId" + }, + "limits": { + "description": "Selected limits.", + "$ref": "#/$defs/Limits" + }, + "operation": { + "description": "Operation echoed by the response.", + "$ref": "#/$defs/Operation" + }, + "protocol_version": { + "type": "integer", + "const": 1 + }, + "staged_sample": { + "description": "Private staged Sample path, absent for non-Sample operations.", + "anyOf": [ + { + "$ref": "#/$defs/WorkerPath" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false, + "required": [ + "protocol_version", + "invocation_id", + "operation", + "limits", + "arguments" + ], + "$defs": { + "Address": { + "description": "A Ghidra address with an explicit, case-sensitive address space.", + "type": "object", + "properties": { + "offset": { + "description": "Canonical lowercase, `0x`-prefixed, even-width hexadecimal offset.", + "type": "string" + }, + "space": { + "description": "Exact Ghidra address-space name.", + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "space", + "offset" + ] + }, + "CollectionLimit": { + "description": "An optional item bound, explicit when unbounded.", + "oneOf": [ + { + "description": "At most the enclosed number of items.", + "type": "object", + "properties": { + "bounded": { + "$ref": "#/$defs/PositiveU64" + } + }, + "additionalProperties": false, + "required": [ + "bounded" + ] + }, + { + "description": "Every item, still subject to the serialized-byte budget.", + "type": "string", + "const": "all" + } + ] + }, + "FunctionSelector": { + "description": "Exactly one explicit Function Selector.", + "oneOf": [ + { + "description": "Exact, case-sensitive Ghidra function name.", + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "name" + }, + "value": { + "type": "string" + } + }, + "required": [ + "kind", + "value" + ] + }, + { + "description": "Exact function entry address.", + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "address" + }, + "value": { + "$ref": "#/$defs/Address" + } + }, + "required": [ + "kind", + "value" + ] + } + ] + }, + "InvocationId": { + "description": "A random 128-bit invocation identifier encoded as 32 lowercase hex digits.", + "type": "string" + }, + "Limits": { + "description": "Resource policy selected for one invocation.", + "type": "object", + "properties": { + "analysis_timeout_seconds": { + "description": "Native analysis timeout.", + "$ref": "#/$defs/PositiveU64" + }, + "child_watchdog_seconds": { + "description": "Derived process watchdog.", + "$ref": "#/$defs/PositiveU64" + }, + "decompile_timeout_seconds": { + "description": "Optional native decompilation timeout.", + "anyOf": [ + { + "$ref": "#/$defs/PositiveU64" + }, + { + "type": "null" + } + ] + }, + "max_cpu": { + "description": "Maximum Ghidra analysis CPU count.", + "$ref": "#/$defs/PositiveU64" + }, + "max_heap_mib": { + "description": "Maximum Java heap, in MiB.", + "$ref": "#/$defs/PositiveU64" + }, + "max_inline_bytes": { + "description": "Maximum serialized successful stdout size.", + "$ref": "#/$defs/PositiveU64" + }, + "max_sample_bytes": { + "description": "Maximum accepted Sample size.", + "$ref": "#/$defs/PositiveU64" + } + }, + "additionalProperties": false, + "required": [ + "max_heap_mib", + "max_cpu", + "analysis_timeout_seconds", + "child_watchdog_seconds", + "max_sample_bytes", + "max_inline_bytes" + ] + }, + "Operation": { + "description": "Version 0.1 operation identifier shared with Java.", + "oneOf": [ + { + "description": "Installation diagnosis.", + "type": "string", + "const": "doctor" + }, + { + "description": "Sample metadata inspection.", + "type": "string", + "const": "inspect" + }, + { + "description": "Stable Function collection.", + "type": "string", + "const": "functions" + }, + { + "description": "Targeted Function decompilation.", + "type": "string", + "const": "decompile" + }, + { + "description": "Explicit Analysis Store cleanup.", + "type": "string", + "const": "clean" + } + ] + }, + "PageRequest": { + "description": "A normalized collection page request.", + "type": "object", + "properties": { + "limit": { + "description": "Selected item bound.", + "$ref": "#/$defs/CollectionLimit" + }, + "offset": { + "description": "Number of items skipped in stable order.", + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "additionalProperties": false, + "required": [ + "offset", + "limit" + ] + }, + "PositiveU64": { + "description": "A validated positive integer accepted by resource-limit flags.", + "type": "integer", + "format": "uint64", + "minimum": 1 + }, + "RequestArguments": { + "description": "Operation-specific request arguments.", + "oneOf": [ + { + "description": "Installation probe.", + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "doctor" + } + }, + "additionalProperties": false, + "required": [ + "kind" + ] + }, + { + "description": "Program metadata extraction.", + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "inspect" + } + }, + "additionalProperties": false, + "required": [ + "kind" + ] + }, + { + "description": "Stable Function page extraction.", + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "functions" + }, + "page": { + "description": "Explicit page bound.", + "$ref": "#/$defs/PageRequest" + } + }, + "additionalProperties": false, + "required": [ + "kind", + "page" + ] + }, + { + "description": "Targeted decompilation.", + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "decompile" + }, + "selector": { + "description": "Explicit Function Selector.", + "$ref": "#/$defs/FunctionSelector" + } + }, + "additionalProperties": false, + "required": [ + "kind", + "selector" + ] + }, + { + "description": "Store cleanup does not cross into Ghidra; reserved for parity.", + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "clean" + } + }, + "additionalProperties": false, + "required": [ + "kind" + ] + } + ] + }, + "WorkerPath": { + "description": "A validated UTF-8 absolute path owned by the trusted harness.", + "type": "string" + } + } +} diff --git a/schemas/v1/protocol-response.schema.json b/schemas/v1/protocol-response.schema.json new file mode 100644 index 0000000..90149cc --- /dev/null +++ b/schemas/v1/protocol-response.schema.json @@ -0,0 +1,120 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "AdapterResponse", + "description": "Strict adapter response envelope.", + "type": "object", + "properties": { + "invocation_id": { + "description": "Echoed invocation identifier.", + "$ref": "#/$defs/InvocationId" + }, + "operation": { + "description": "Echoed operation.", + "$ref": "#/$defs/Operation" + }, + "protocol_version": { + "type": "integer", + "const": 1 + }, + "result": { + "description": "Exactly one typed success or error result.", + "$ref": "#/$defs/AdapterResult" + } + }, + "additionalProperties": false, + "required": [ + "protocol_version", + "invocation_id", + "operation", + "result" + ], + "$defs": { + "AdapterResult": { + "description": "Adapter result, with operation data validated by the harness afterward.", + "oneOf": [ + { + "description": "Complete operation-specific result.", + "type": "object", + "properties": { + "data": { + "description": "Strictly schema-validated by the operation dispatcher." + }, + "status": { + "type": "string", + "const": "success" + } + }, + "additionalProperties": false, + "required": [ + "status", + "data" + ] + }, + { + "description": "Typed adapter failure with no partial Query data.", + "type": "object", + "properties": { + "code": { + "description": "Stable adapter error code.", + "type": "string" + }, + "details": { + "description": "Operation-specific error facts.", + "type": "object", + "additionalProperties": true + }, + "message": { + "description": "Concise diagnostic context.", + "type": "string" + }, + "status": { + "type": "string", + "const": "error" + } + }, + "additionalProperties": false, + "required": [ + "status", + "code", + "message", + "details" + ] + } + ] + }, + "InvocationId": { + "description": "A random 128-bit invocation identifier encoded as 32 lowercase hex digits.", + "type": "string" + }, + "Operation": { + "description": "Version 0.1 operation identifier shared with Java.", + "oneOf": [ + { + "description": "Installation diagnosis.", + "type": "string", + "const": "doctor" + }, + { + "description": "Sample metadata inspection.", + "type": "string", + "const": "inspect" + }, + { + "description": "Stable Function collection.", + "type": "string", + "const": "functions" + }, + { + "description": "Targeted Function decompilation.", + "type": "string", + "const": "decompile" + }, + { + "description": "Explicit Analysis Store cleanup.", + "type": "string", + "const": "clean" + } + ] + } + } +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..a343bd5 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,280 @@ +//! Typed command-line surface for every version 0.1 operation. + +use std::path::PathBuf; + +use clap::{ArgGroup, Args, Parser, Subcommand, ValueEnum}; + +use crate::domain::{Address, CollectionLimit, Digest, FunctionSelector, PageRequest, PositiveU64}; + +/// Parsed `ghidr` command line. +#[derive(Clone, Debug, Parser)] +#[command(name = "ghidr", version, about, disable_help_subcommand = true)] +pub struct Cli { + /// Structured JSON or explicit human-readable presentation. + #[arg(long, global = true, value_enum, default_value_t = OutputFormat::Json)] + pub format: OutputFormat, + + /// Absolute Analysis Store path. + #[arg(long, global = true, value_name = "ABSOLUTE_PATH", value_parser = parse_store_path)] + pub store: Option, + + /// Worker sandbox policy; never auto-detected. + #[arg(long, global = true, value_enum, default_value_t = SandboxMode::Bubblewrap)] + pub sandbox: SandboxMode, + + /// Maximum native analysis duration. + #[arg(long, global = true, default_value = "600")] + pub analysis_timeout_seconds: PositiveU64, + + /// Maximum native decompilation duration. + #[arg(long, global = true, default_value = "60")] + pub decompile_timeout_seconds: PositiveU64, + + /// Java maximum heap in MiB, not total process memory. + #[arg(long, global = true, default_value = "2048")] + pub max_heap_mib: PositiveU64, + + /// Maximum Ghidra analysis CPU count. + #[arg(long, global = true, default_value = "2")] + pub max_cpu: PositiveU64, + + /// Maximum accepted Sample bytes. + #[arg(long, global = true, default_value = "1073741824")] + pub max_sample_bytes: PositiveU64, + + /// Maximum serialized successful stdout bytes. + #[arg(long, global = true, default_value = "65536")] + pub max_inline_bytes: PositiveU64, + + /// The requested operation. + #[command(subcommand)] + pub command: Command, +} + +/// Public output presentation. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)] +#[value(rename_all = "snake_case")] +pub enum OutputFormat { + /// One versioned JSON document on the result stream. + #[default] + Json, + /// Concise terminal-oriented presentation. + Human, +} + +/// Explicit worker isolation selection. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)] +#[value(rename_all = "snake_case")] +pub enum SandboxMode { + /// Linux namespace isolation through Bubblewrap. + #[default] + Bubblewrap, + /// Isolation supplied by the caller and not verified by `ghidr`. + External, + /// Explicitly disable worker isolation for debugging. + Off, +} + +/// Version 0.1 operations. +#[derive(Clone, Debug, Subcommand)] +pub enum Command { + /// Diagnose the Ghidra, JDK, store, and sandbox installation. + Doctor, + /// Inspect Sample and Analysis metadata. + Inspect(SampleArgs), + /// List every discovered Function in stable order. + Functions(FunctionsArgs), + /// Decompile one explicitly selected Function. + Decompile(DecompileArgs), + /// Explicitly remove tool-owned stored data. + Clean(CleanArgs), +} + +/// A command operating on one caller-supplied Sample path. +#[derive(Clone, Debug, Args)] +pub struct SampleArgs { + /// Sample to snapshot and analyze read-only. + #[arg(value_name = "SAMPLE")] + pub sample: PathBuf, +} + +/// Function collection arguments. +#[derive(Clone, Debug, Args)] +pub struct FunctionsArgs { + /// Sample to snapshot and analyze read-only. + #[arg(value_name = "SAMPLE")] + pub sample: PathBuf, + + /// Maximum returned Functions; defaults to 100. + #[arg(long, conflicts_with = "all")] + pub limit: Option, + + /// Number of Functions skipped in stable order. + #[arg(long, default_value_t = 0, conflicts_with = "all")] + pub offset: u64, + + /// Request every Function, retaining the serialized-byte bound. + #[arg(long)] + pub all: bool, +} + +impl FunctionsArgs { + /// Converts clap flags to the protocol's explicit page form. + #[must_use] + pub fn page(&self) -> PageRequest { + PageRequest { + offset: self.offset, + limit: if self.all { + CollectionLimit::All + } else { + CollectionLimit::Bounded(self.limit.unwrap_or_else(default_collection_limit)) + }, + } + } +} + +fn default_collection_limit() -> PositiveU64 { + "100".parse().unwrap_or(PositiveU64::ONE) +} + +fn parse_store_path(value: &str) -> Result { + let path = PathBuf::from(value); + if !path.is_absolute() || path.to_str().is_none() { + return Err("store path must be absolute UTF-8".to_owned()); + } + Ok(path) +} + +/// Targeted decompilation arguments. +#[derive(Clone, Debug, Args)] +#[command(group(ArgGroup::new("selector").required(true).multiple(false).args(["name", "address"])))] +pub struct DecompileArgs { + /// Sample to snapshot and analyze read-only. + #[arg(value_name = "SAMPLE")] + pub sample: PathBuf, + + /// Exact, case-sensitive Ghidra Function name. + #[arg(long, value_parser = parse_function_name)] + pub name: Option, + + /// Exact entry Address in canonical `SPACE:0x...` form. + #[arg(long)] + pub address: Option
, +} + +fn parse_function_name(value: &str) -> Result { + if value.is_empty() { + return Err("Function name must not be empty".to_owned()); + } + Ok(value.to_owned()) +} + +impl DecompileArgs { + /// Returns the selector guaranteed by clap's required group. + pub fn selector(&self) -> Result { + match (&self.name, &self.address) { + (Some(name), None) => Ok(FunctionSelector::Name(name.clone())), + (None, Some(address)) => Ok(FunctionSelector::Address(address.clone())), + _ => Err("exactly one Function Selector is required"), + } + } +} + +/// Explicit cleanup arguments. +#[derive(Clone, Debug, Args)] +#[command(group(ArgGroup::new("target").required(true).multiple(false).args(["sample", "digest", "all"])))] +pub struct CleanArgs { + /// Sample whose digest selects stored data. + #[arg(value_name = "SAMPLE")] + pub sample: Option, + + /// Sample digest used when the original Sample is unavailable. + #[arg(long)] + pub digest: Option, + + /// Select all tool-owned stored data. + #[arg(long)] + pub all: bool, + + /// Report exact matches without removing them. + #[arg(long)] + pub dry_run: bool, + + /// Confirm store-wide cleanup without an interactive prompt. + #[arg(long, requires = "all")] + pub yes: bool, +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::panic)] +mod tests { + use clap::Parser as _; + + use super::{Cli, Command, OutputFormat}; + use crate::domain::{CollectionLimit, FunctionSelector}; + + #[test] + fn json_and_defaults_are_unconditional() { + let cli = Cli::try_parse_from(["ghidr", "functions", "sample"]).expect("valid CLI"); + assert_eq!(cli.format, OutputFormat::Json); + let Command::Functions(args) = cli.command else { + panic!("expected functions"); + }; + assert_eq!(args.page().offset, 0); + assert!(matches!(args.page().limit, CollectionLimit::Bounded(value) if value.get() == 100)); + } + + #[test] + fn global_flags_work_after_subcommand() { + let cli = Cli::try_parse_from([ + "ghidr", + "inspect", + "sample", + "--format", + "human", + "--max-cpu", + "4", + ]) + .expect("valid CLI"); + assert_eq!(cli.format, OutputFormat::Human); + assert_eq!(cli.max_cpu.get(), 4); + } + + #[test] + fn rejects_zero_and_conflicting_collection_bounds() { + assert!(Cli::try_parse_from(["ghidr", "functions", "sample", "--limit", "0"]).is_err()); + assert!( + Cli::try_parse_from(["ghidr", "functions", "sample", "--all", "--offset", "1"]) + .is_err() + ); + } + + #[test] + fn selector_is_explicit_and_canonical() { + let cli = Cli::try_parse_from([ + "ghidr", + "decompile", + "sample", + "--address", + "ram:0x0000000000401120", + ]) + .expect("valid CLI"); + let Command::Decompile(args) = cli.command else { + panic!("expected decompile"); + }; + assert!(matches!(args.selector(), Ok(FunctionSelector::Address(_)))); + assert!( + Cli::try_parse_from(["ghidr", "decompile", "sample", "--address", "ram:0xABC"]) + .is_err() + ); + } + + #[test] + fn clean_requires_exactly_one_target() { + assert!(Cli::try_parse_from(["ghidr", "clean"]).is_err()); + assert!( + Cli::try_parse_from(["ghidr", "clean", "sample", "--digest", &"a".repeat(64),]) + .is_err() + ); + } +} diff --git a/src/domain/address.rs b/src/domain/address.rs new file mode 100644 index 0000000..19e0f3a --- /dev/null +++ b/src/domain/address.rs @@ -0,0 +1,63 @@ +use std::{fmt, str::FromStr}; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// A Ghidra address with an explicit, case-sensitive address space. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Address { + /// Exact Ghidra address-space name. + pub space: String, + /// Canonical lowercase, `0x`-prefixed, even-width hexadecimal offset. + pub offset: String, +} + +impl FromStr for Address { + type Err = AddressParseError; + + fn from_str(value: &str) -> Result { + let (space, offset) = value.split_once(':').ok_or(AddressParseError)?; + let digits = offset.strip_prefix("0x").ok_or(AddressParseError)?; + if space.is_empty() + || digits.is_empty() + || digits.len() % 2 != 0 + || !digits + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(AddressParseError); + } + Ok(Self { + space: space.to_owned(), + offset: offset.to_owned(), + }) + } +} + +impl fmt::Display for Address { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}:{}", self.space, self.offset) + } +} + +/// The address selector was not in canonical `SPACE:0x...` form. +#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)] +#[error("address must be SPACE:0x followed by an even number of lowercase hexadecimal digits")] +pub struct AddressParseError; + +#[cfg(test)] +mod tests { + use std::str::FromStr as _; + + use super::Address; + + #[test] + fn only_canonical_explicit_addresses_parse() { + assert!(Address::from_str("ram:0x0000000000401120").is_ok()); + assert!(Address::from_str("0x00401120").is_err()); + assert!(Address::from_str("ram:0xABC0").is_err()); + assert!(Address::from_str("ram:0xabc").is_err()); + } +} diff --git a/src/domain/identifier.rs b/src/domain/identifier.rs new file mode 100644 index 0000000..b3d0b16 --- /dev/null +++ b/src/domain/identifier.rs @@ -0,0 +1,80 @@ +use std::{fmt, str::FromStr}; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// A lowercase SHA-256 digest. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize, JsonSchema)] +#[serde(transparent)] +pub struct Digest(String); + +impl Digest { + /// Returns the canonical hexadecimal spelling. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl FromStr for Digest { + type Err = IdentifierError; + + fn from_str(value: &str) -> Result { + if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(IdentifierError::Digest); + } + Ok(Self(value.to_ascii_lowercase())) + } +} + +impl fmt::Display for Digest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +/// A random 128-bit invocation identifier encoded as 32 lowercase hex digits. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(transparent)] +pub struct InvocationId(String); + +impl InvocationId { + /// Parses a canonical invocation identifier. + pub fn parse(value: &str) -> Result { + if value.len() != 32 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(IdentifierError::Invocation); + } + Ok(Self(value.to_owned())) + } +} + +/// Identifier validation failure. +#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)] +pub enum IdentifierError { + /// SHA-256 spelling is invalid. + #[error("digest must contain exactly 64 hexadecimal characters")] + Digest, + /// Invocation identifier spelling is invalid. + #[error("invocation ID must contain exactly 32 lowercase hexadecimal characters")] + Invocation, +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod tests { + use std::str::FromStr as _; + + use super::Digest; + + #[test] + fn digest_accepts_hex_and_canonicalizes_case() { + let digest = Digest::from_str(&"A".repeat(64)).expect("valid digest"); + assert_eq!(digest.as_str(), "a".repeat(64)); + assert!(Digest::from_str(&"a".repeat(63)).is_err()); + } +} diff --git a/src/domain/limits.rs b/src/domain/limits.rs new file mode 100644 index 0000000..0e51be7 --- /dev/null +++ b/src/domain/limits.rs @@ -0,0 +1,81 @@ +use std::{num::NonZeroU64, str::FromStr}; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// A validated positive integer accepted by resource-limit flags. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(transparent)] +pub struct PositiveU64(NonZeroU64); + +impl PositiveU64 { + /// The smallest valid positive value. + pub const ONE: Self = Self(NonZeroU64::MIN); + + /// Returns the positive integer. + #[must_use] + pub const fn get(self) -> u64 { + self.0.get() + } +} + +impl FromStr for PositiveU64 { + type Err = &'static str; + + fn from_str(value: &str) -> Result { + value + .parse::() + .map(Self) + .map_err(|_| "value must be a positive integer without a unit suffix") + } +} + +impl TryFrom for PositiveU64 { + type Error = &'static str; + + fn try_from(value: u64) -> Result { + NonZeroU64::new(value) + .map(Self) + .ok_or("value must be a positive integer") + } +} + +/// An optional item bound, explicit when unbounded. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum CollectionLimit { + /// At most the enclosed number of items. + Bounded(PositiveU64), + /// Every item, still subject to the serialized-byte budget. + All, +} + +/// A normalized collection page request. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct PageRequest { + /// Number of items skipped in stable order. + pub offset: u64, + /// Selected item bound. + pub limit: CollectionLimit, +} + +/// Resource policy selected for one invocation. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Limits { + /// Maximum Java heap, in MiB. + pub max_heap_mib: PositiveU64, + /// Maximum Ghidra analysis CPU count. + pub max_cpu: PositiveU64, + /// Native analysis timeout. + pub analysis_timeout_seconds: PositiveU64, + /// Optional native decompilation timeout. + pub decompile_timeout_seconds: Option, + /// Derived process watchdog. + pub child_watchdog_seconds: PositiveU64, + /// Maximum accepted Sample size. + pub max_sample_bytes: PositiveU64, + /// Maximum serialized successful stdout size. + pub max_inline_bytes: PositiveU64, +} diff --git a/src/domain/mod.rs b/src/domain/mod.rs new file mode 100644 index 0000000..7c36749 --- /dev/null +++ b/src/domain/mod.rs @@ -0,0 +1,13 @@ +//! Validated domain values shared by the CLI, store, and adapter protocol. + +mod address; +mod identifier; +mod limits; +mod path; +mod selector; + +pub use address::{Address, AddressParseError}; +pub use identifier::{Digest, IdentifierError, InvocationId}; +pub use limits::{CollectionLimit, Limits, PageRequest, PositiveU64}; +pub use path::TaggedPath; +pub use selector::FunctionSelector; diff --git a/src/domain/path.rs b/src/domain/path.rs new file mode 100644 index 0000000..6e62431 --- /dev/null +++ b/src/domain/path.rs @@ -0,0 +1,75 @@ +use std::path::{Path, PathBuf}; + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// A path preserving either UTF-8 text or exact Unix bytes. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "encoding", content = "value", rename_all = "snake_case")] +pub enum TaggedPath { + /// An unmodified UTF-8 path. + Utf8(String), + /// Standard padded Base64 over exact Unix path bytes. + UnixBytesBase64(String), +} + +impl TaggedPath { + /// Captures a path without lossy conversion. + #[must_use] + pub fn from_path(path: &Path) -> Self { + path.to_str().map_or_else( + || Self::UnixBytesBase64(STANDARD.encode(unix_path_bytes(path))), + |value| Self::Utf8(value.to_owned()), + ) + } + + /// Recovers the represented path when the encoding is valid. + pub fn to_path_buf(&self) -> Result { + match self { + Self::Utf8(value) => Ok(PathBuf::from(value)), + Self::UnixBytesBase64(value) => { + let bytes = STANDARD.decode(value)?; + Ok(path_from_unix_bytes(bytes)) + } + } + } +} + +#[cfg(unix)] +fn unix_path_bytes(path: &Path) -> &[u8] { + use std::os::unix::ffi::OsStrExt as _; + path.as_os_str().as_bytes() +} + +#[cfg(not(unix))] +fn unix_path_bytes(path: &Path) -> &[u8] { + path.as_os_str().as_encoded_bytes() +} + +#[cfg(unix)] +fn path_from_unix_bytes(bytes: Vec) -> PathBuf { + use std::os::unix::ffi::OsStringExt as _; + PathBuf::from(std::ffi::OsString::from_vec(bytes)) +} + +#[cfg(all(test, unix))] +#[allow(clippy::expect_used)] +mod tests { + use std::{ffi::OsString, os::unix::ffi::OsStringExt as _, path::PathBuf}; + + use super::TaggedPath; + + #[test] + fn non_utf8_path_round_trips_exact_unix_bytes() { + let original = PathBuf::from(OsString::from_vec(vec![b'/', b't', b'm', b'p', b'/', 0xff])); + let tagged = TaggedPath::from_path(&original); + assert!(matches!(&tagged, TaggedPath::UnixBytesBase64(value) if !value.is_empty())); + assert_eq!(tagged.to_path_buf().expect("valid Base64"), original); + } +} + +#[cfg(not(unix))] +fn path_from_unix_bytes(bytes: Vec) -> PathBuf { + PathBuf::from(String::from_utf8_lossy(&bytes).into_owned()) +} diff --git a/src/domain/selector.rs b/src/domain/selector.rs new file mode 100644 index 0000000..4c79911 --- /dev/null +++ b/src/domain/selector.rs @@ -0,0 +1,14 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::Address; + +/// Exactly one explicit Function Selector. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "kind", content = "value", rename_all = "snake_case")] +pub enum FunctionSelector { + /// Exact, case-sensitive Ghidra function name. + Name(String), + /// Exact function entry address. + Address(Address), +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..a68fcfb --- /dev/null +++ b/src/error.rs @@ -0,0 +1,249 @@ +//! Typed failures and coarse process status allocation. + +use std::collections::BTreeMap; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; + +/// Stable machine-readable error taxonomy available in the foundation. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ErrorCode { + /// Command-line syntax or validation failed. + InvalidArguments, + /// The caller's Sample path does not exist. + SampleNotFound, + /// The caller's Sample cannot be read. + SampleUnreadable, + /// The opened Sample is not a regular file. + InvalidSampleType, + /// The Sample exceeded the selected byte ceiling. + SampleTooLarge, + /// Source metadata changed while snapshotting. + SampleChanged, + /// The Analysis Store lacks the required free-space reserve. + InsufficientStoreSpace, + /// Ghidra could not recognize a Target Specification. + UnsupportedTarget, + /// Ghidra found multiple Target Specification candidates. + AmbiguousTarget, + /// Ghidra is not installed. + GhidraMissing, + /// The installed Ghidra version is incompatible. + GhidraIncompatible, + /// Java is not installed. + JavaMissing, + /// The installed Java version is incompatible. + JavaIncompatible, + /// A later integration layer has not connected execution yet. + InternalNotImplemented, + /// Rust/Java request or response validation failed. + ProtocolViolation, + /// The child exceeded a native or watchdog timeout. + Timeout, + /// Native auto-analysis timed out. + AnalysisTimeout, + /// Ghidra auto-analysis failed. + AnalysisFailed, + /// Native Function decompilation timed out. + DecompileTimeout, + /// Ghidra Function decompilation failed. + DecompilationFailed, + /// The caller interrupted the operation. + Interrupted, + /// The adapter could not serialize a bounded response. + ResultTooLarge, + /// The success descriptor cannot fit the requested inline budget. + InlineBudgetTooSmall, + /// Store-wide cleanup lacks explicit confirmation. + ConfirmationRequired, + /// Another process holds the selected Analysis lock. + AnalysisBusy, + /// Stored Analysis validation failed and recovery did not succeed. + CorruptAnalysis, + /// A cleanup transaction requires a recoverable retry. + CleanupIncomplete, + /// The resolved Analysis Store path is invalid. + InvalidStorePath, + /// A Function Selector resolved to no Function. + FunctionNotFound, + /// A Function Selector resolved to multiple Functions. + FunctionSelectorAmbiguous, + /// An Address is not a Function entry. + FunctionEntryRequired, + /// Ghidra does not contain the requested address space. + AddressSpaceNotFound, + /// Installation diagnosis found an unusable component. + DoctorFailed, + /// A general runtime or integration failure. + Internal, +} + +impl ErrorCode { + /// Authoritative coarse status mapping from ADR 0002. + #[must_use] + pub const fn exit_status(self) -> ExitStatus { + match self { + Self::InvalidArguments => ExitStatus::InvalidInvocation, + Self::Timeout | Self::AnalysisTimeout | Self::DecompileTimeout => ExitStatus::Timeout, + Self::Interrupted => ExitStatus::Interrupted, + _ => ExitStatus::RuntimeFailure, + } + } +} + +/// Stable public error body. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct ErrorBody { + /// Stable machine identifier. + pub code: ErrorCode, + /// Concise context that is not a control-flow interface. + pub message: String, + /// Whether the same logical request might later succeed unchanged. + pub retryable: bool, + /// Code-specific structured context, always an object. + pub details: BTreeMap, +} + +/// Versioned failure document. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct ErrorEnvelope { + /// Public schema major version. + pub schema_version: u32, + /// Always `error`. + pub kind: ErrorKind, + /// Typed failure body. + pub error: ErrorBody, +} + +/// Error envelope discriminator. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ErrorKind { + /// A failed invocation. + Error, +} + +/// Internal error paired with its public document and status class. +#[derive(Clone, Debug, Error)] +#[error("{body_message}")] +pub struct AppError { + body_message: String, + envelope: ErrorEnvelope, + status: ExitStatus, +} + +impl AppError { + /// Constructs a typed failure with empty structured details. + #[must_use] + pub fn new(code: ErrorCode, message: impl Into, retryable: bool) -> Self { + let message = message.into(); + let status = code.exit_status(); + Self { + body_message: message.clone(), + envelope: ErrorEnvelope { + schema_version: 1, + kind: ErrorKind::Error, + error: ErrorBody { + code, + message, + retryable, + details: BTreeMap::new(), + }, + }, + status, + } + } + + /// Clear failure used until the store/process/Java execution layer is attached. + #[must_use] + pub fn execution_not_implemented(operation: &str) -> Self { + let mut error = Self::new( + ErrorCode::InternalNotImplemented, + format!("operation '{operation}' is not connected to the Ghidra execution layer"), + false, + ); + error + .envelope + .error + .details + .insert("operation".to_owned(), Value::String(operation.to_owned())); + error + } + + /// Constructs an invalid-invocation failure from clap context. + #[must_use] + pub fn invalid_arguments(message: impl Into) -> Self { + Self::new(ErrorCode::InvalidArguments, message, false) + } + + /// Adds one typed detail field. + #[must_use] + pub fn with_detail(mut self, key: impl Into, value: Value) -> Self { + self.envelope.error.details.insert(key.into(), value); + self + } + + /// Returns the public error document. + #[must_use] + pub const fn envelope(&self) -> &ErrorEnvelope { + &self.envelope + } + + /// Returns the coarse process status. + #[must_use] + pub const fn status(&self) -> ExitStatus { + self.status + } +} + +/// Accepted coarse process statuses from ADR 0002. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(i32)] +pub enum ExitStatus { + /// Successful operation. + Success = 0, + /// Runtime failure. + RuntimeFailure = 1, + /// Invalid invocation or arguments. + InvalidInvocation = 2, + /// Native phase or child watchdog timeout. + Timeout = 124, + /// User interruption. + Interrupted = 130, +} + +impl ExitStatus { + /// Integer process exit code. + #[must_use] + pub const fn code(self) -> i32 { + self as i32 + } +} + +#[cfg(test)] +mod tests { + use super::{ErrorCode, ExitStatus}; + + #[test] + fn detailed_errors_map_to_only_accepted_coarse_statuses() { + assert_eq!( + ErrorCode::InvalidArguments.exit_status(), + ExitStatus::InvalidInvocation + ); + assert_eq!( + ErrorCode::AnalysisTimeout.exit_status(), + ExitStatus::Timeout + ); + assert_eq!( + ErrorCode::Interrupted.exit_status(), + ExitStatus::Interrupted + ); + assert_eq!( + ErrorCode::ProtocolViolation.exit_status(), + ExitStatus::RuntimeFailure + ); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..858c23b --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,12 @@ +#![forbid(unsafe_code)] +#![doc = "Public contracts and synchronous orchestration seams for `ghidr`."] + +pub mod cli; +pub mod domain; +pub mod error; +pub mod operation; +pub mod output; +pub mod protocol; +pub mod schema; + +pub use error::{AppError, ErrorCode, ExitStatus}; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..57658a0 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,20 @@ +#![forbid(unsafe_code)] +#![doc = "`ghidr` synchronous command-line entry point."] + +use std::{io, process::ExitCode}; + +use ghidra_cli::output::{UnimplementedExecutor, run_from}; + +fn main() -> ExitCode { + let mut stdout = io::stdout().lock(); + let mut stderr = io::stderr().lock(); + match run_from( + std::env::args_os(), + &UnimplementedExecutor, + &mut stdout, + &mut stderr, + ) { + Ok(status) => ExitCode::from(u8::try_from(status.code()).unwrap_or(1)), + Err(_) => ExitCode::FAILURE, + } +} diff --git a/src/operation.rs b/src/operation.rs new file mode 100644 index 0000000..090814d --- /dev/null +++ b/src/operation.rs @@ -0,0 +1,647 @@ +//! Public result models and the authoritative compile-time operation registry. + +use std::collections::BTreeMap; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::domain::{Address, Digest, FunctionSelector, Limits, PositiveU64, TaggedPath}; + +/// Public schema version emitted by version 0.1. +pub const SCHEMA_VERSION: u32 = 1; +/// Rust/Java adapter protocol version shipped with version 0.1. +pub const ADAPTER_PROTOCOL_VERSION: u32 = 1; + +/// Version 0.1 operation identifier shared with Java. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum Operation { + /// Installation diagnosis. + Doctor, + /// Sample metadata inspection. + Inspect, + /// Stable Function collection. + Functions, + /// Targeted Function decompilation. + Decompile, + /// Explicit Analysis Store cleanup. + Clean, +} + +impl Operation { + /// Stable snake-case spelling. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Doctor => "doctor", + Self::Inspect => "inspect", + Self::Functions => "functions", + Self::Decompile => "decompile", + Self::Clean => "clean", + } + } +} + +/// Static operation metadata used by conformance tests and future adapters. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct OperationDescriptor { + /// Protocol operation enum member. + pub operation: Operation, + /// Public response `kind` value. + pub public_kind: &'static str, + /// Published success schema filename. + pub schema: &'static str, + /// Reviewed representative fixture filename. + pub golden: &'static str, + /// Stable capability category. + pub category: &'static str, + /// One-sentence agent-facing description. + pub description: &'static str, +} + +/// The single authoritative version 0.1 operation set. +pub const OPERATIONS: &[OperationDescriptor] = &[ + OperationDescriptor { + operation: Operation::Doctor, + public_kind: "doctor", + schema: "doctor.schema.json", + golden: "doctor.json", + category: "diagnostics", + description: "Diagnose the local Ghidra, JDK, store, and sandbox installation.", + }, + OperationDescriptor { + operation: Operation::Inspect, + public_kind: "inspection", + schema: "inspection.schema.json", + golden: "inspection.json", + category: "analysis", + description: "Inspect reproducible Sample, program, and Analysis metadata.", + }, + OperationDescriptor { + operation: Operation::Functions, + public_kind: "functions", + schema: "functions.schema.json", + golden: "functions.json", + category: "query", + description: "List Ghidra-discovered Functions in stable bounded order.", + }, + OperationDescriptor { + operation: Operation::Decompile, + public_kind: "decompilation", + schema: "decompilation.schema.json", + golden: "decompilation.json", + category: "query", + description: "Decompile exactly one unambiguously selected Function.", + }, + OperationDescriptor { + operation: Operation::Clean, + public_kind: "cleanup", + schema: "cleanup.schema.json", + golden: "cleanup.json", + category: "store", + description: "Explicitly inspect or remove tool-owned stored data.", + }, +]; + +/// Versioned successful response. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct SuccessEnvelope { + /// Public schema major version. + pub schema_version: u32, + /// Operation-specific public discriminator. + pub kind: String, + /// Reproducibility and execution context. + pub provenance: P, + /// Operation-specific result. + pub data: D, + /// Deduplicated stable warnings. + pub warnings: Vec, +} + +impl SuccessEnvelope { + /// Builds a version 1 success while enforcing stable warning ordering. + #[must_use] + pub fn new(kind: impl Into, provenance: P, data: D, warnings: Vec) -> Self { + Self { + schema_version: SCHEMA_VERSION, + kind: kind.into(), + provenance, + data, + warnings: normalize_warnings(warnings), + } + } +} + +/// Stable non-fatal warning. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct Warning { + /// Stable machine identifier. + pub code: String, + /// Concise human context. + pub message: String, + /// Warning-specific context, always an object. + pub details: BTreeMap, +} + +/// Deterministically sorts and deduplicates warnings by code and canonical details. +#[must_use] +pub fn normalize_warnings(mut warnings: Vec) -> Vec { + warnings.sort_by_key(warning_key); + warnings.dedup_by(|left, right| { + left.code == right.code + && canonical_details(&left.details) == canonical_details(&right.details) + }); + warnings +} + +fn warning_key(warning: &Warning) -> (String, String, String) { + ( + warning.code.clone(), + canonical_details(&warning.details), + warning.message.clone(), + ) +} + +fn canonical_details(details: &BTreeMap) -> String { + serde_json::to_string(details).unwrap_or_default() +} + +/// Minimal provenance available to non-Sample operations. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct ToolProvenance { + /// CLI package version. + pub ghidr_version: String, + /// Rust/Java protocol version. + pub adapter_protocol_version: u32, + /// Selected Analysis Store. + pub analysis_store: AnalysisStoreProvenance, + /// Selected sandbox policy. + pub sandbox: SandboxProvenance, +} + +/// Fixed provenance for every Sample-backed Query. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct QueryProvenance { + /// CLI package version. + pub ghidr_version: String, + /// Rust/Java protocol version. + pub adapter_protocol_version: u32, + /// Ghidra version. + pub ghidra_version: String, + /// Java major version. + pub java_version: String, + /// Sample content identity. + pub sample_sha256: Digest, + /// Exact caller-supplied path. + pub source_path: TaggedPath, + /// Canonical Analysis Profile identity. + pub analysis_profile_sha256: Digest, + /// Resolved Target Specification and verification. + pub target: TargetProvenance, + /// Selected Analysis Store. + pub analysis_store: AnalysisStoreProvenance, + /// Selected sandbox policy. + pub sandbox: SandboxProvenance, + /// Selected and derived resource limits. + pub limits: Limits, +} + +/// Target Specification provenance. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct TargetProvenance { + /// Exact loader identifier. + pub loader: String, + /// Executable format name. + pub format: String, + /// Exact processor-language identifier. + pub processor_language: String, + /// Exact compiler-specification identifier. + pub compiler_specification: String, + /// Project integration-test coverage. + pub verification: TargetVerification, +} + +/// Project integration-test coverage for a recognized target. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum TargetVerification { + /// Covered by version 0.1 real-Ghidra fixtures. + Verified, + /// Recognized by Ghidra but outside the tested matrix. + Unverified, +} + +/// Analysis Store provenance. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct AnalysisStoreProvenance { + /// Tagged absolute store path. + pub path: TaggedPath, + /// Store resolution source. + pub source: StoreSource, +} + +/// Analysis Store resolution source. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum StoreSource { + /// Explicit `--store` flag. + Cli, + /// `GHIDR_STORE` environment variable. + Environment, + /// Explicit `XDG_CACHE_HOME` fallback. + Xdg, + /// Home-directory fallback. + HomeFallback, +} + +/// Worker sandbox provenance. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct SandboxProvenance { + /// Selected backend. + pub backend: SandboxBackend, + /// Isolation verification state. + pub verification: SandboxVerification, +} + +/// Sandbox backend. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SandboxBackend { + /// Bubblewrap namespaces. + Bubblewrap, + /// Caller-supplied isolation. + External, + /// Isolation disabled. + Off, +} + +/// Sandbox verification state. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SandboxVerification { + /// Verified by `ghidr`. + Verified, + /// Declared but not verifiable by `ghidr`. + Unverified, + /// Explicitly disabled. + Disabled, +} + +/// Installation diagnosis data. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct DoctorData { + /// True only when every required component is ready. + pub ready: bool, + /// Typed component reports keyed by stable component name. + pub components: BTreeMap, +} + +/// One diagnosed component. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct ComponentReport { + /// Stable readiness classification. + pub status: ComponentStatus, + /// Component-specific facts. + pub details: BTreeMap, +} + +/// Doctor component readiness classification. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ComponentStatus { + /// Component is usable. + Ready, + /// Component is absent. + Missing, + /// Component version is unsupported. + Incompatible, + /// Component configuration is invalid. + Invalid, + /// Component storage cannot be written. + Unwritable, +} + +/// Sample inspection data. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct InspectionData { + /// Sample identity and size. + pub sample: SampleSummary, + /// Program address and Function summary. + pub program: ProgramSummary, + /// Reuse disposition. + pub analysis: AnalysisSummary, +} + +/// Sample identity summary. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct SampleSummary { + /// SHA-256 content identity. + pub sha256: Digest, + /// Exact byte length. + pub size_bytes: u64, +} + +/// Program summary. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct ProgramSummary { + /// Image base when applicable. + pub image_base: Option
, + /// Minimum Address when applicable. + pub minimum_address: Option
, + /// Maximum Address when applicable. + pub maximum_address: Option
, + /// Count of memory, external, and thunk Functions. + pub function_count: u64, +} + +/// Analysis selection summary. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct AnalysisSummary { + /// Whether compatible analysis was built or reused. + pub disposition: AnalysisDisposition, +} + +/// Analysis selection disposition. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum AnalysisDisposition { + /// A new Analysis was created. + Created, + /// A compatible Analysis was reused. + Reused, + /// Corrupt stored data was quarantined and rebuilt once. + Rebuilt, +} + +/// Stable page metadata. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct PageMetadata { + /// Stable order identifier. + pub order: String, + /// Number of items skipped. + pub offset: u64, + /// Applied item bound, null only for explicit `--all`. + pub limit: Option, + /// Number of returned items. + pub returned: u64, + /// Total items in the immutable Analysis. + pub total: u64, + /// Whether a subsequent page exists. + pub has_more: bool, +} + +/// Function collection data. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct FunctionsData { + /// Stable page facts. + pub page: PageMetadata, + /// Function records in documented order. + pub items: Vec, +} + +/// Minimal version 0.1 Function record. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct FunctionItem { + /// Exact basename. + pub name: String, + /// Exact fully-qualified name. + pub qualified_name: String, + /// Function entry Address. + pub entry: Address, + /// Number of Addresses in the body. + pub body_address_count: u64, + /// Memory or external location. + pub location: FunctionLocation, + /// Whether Ghidra marks the Function external. + pub is_external: bool, + /// Whether Ghidra marks the Function a thunk. + pub is_thunk: bool, + /// Resolved thunk target entry when available. + pub thunk_target_entry: Option
, + /// Whether the Function is a valid decompilation target. + pub decompilable: bool, +} + +/// Function location class used by stable ordering. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum FunctionLocation { + /// Function has a memory Address. + Memory, + /// External/imported Function. + External, +} + +/// Successful targeted decompilation data. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct DecompilationData { + /// Exact selector supplied by the caller. + pub requested_selector: FunctionSelector, + /// Unambiguously resolved Function. + pub function: SelectedFunction, + /// Complete normalized Ghidra C-like text. + pub decompilation: DecompiledText, +} + +/// Identity of a selected Function. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct SelectedFunction { + /// Exact basename. + pub name: String, + /// Exact fully-qualified name. + pub qualified_name: String, + /// Entry Address. + pub entry: Address, +} + +/// Complete decompiler text. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct DecompiledText { + /// Always `ghidra_c` in version 0.1. + pub syntax: DecompilationSyntax, + /// Complete text with only line endings normalized to LF. + pub text: String, +} + +/// Decompiled text representation. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum DecompilationSyntax { + /// Ghidra's C-like representation. + GhidraC, +} + +/// Cleanup result data; detailed count contracts are carried as typed snapshots. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct CleanupData { + /// Execution or dry-run mode. + pub mode: CleanupMode, + /// Resolved cleanup target. + pub target: CleanupTarget, + /// Preflight match snapshot. + pub matched: CleanupSnapshot, + /// Exact removed snapshot, null for dry runs. + pub removed: Option, +} + +/// Cleanup mode. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum CleanupMode { + /// Data was removed. + Executed, + /// No data was removed. + DryRun, +} + +/// Resolved cleanup target. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum CleanupTarget { + /// Digest resolved from a caller Sample. + Sample { + /// Exact caller path. + source_path: TaggedPath, + /// Resolved content identity. + sha256: Digest, + }, + /// Caller-supplied digest. + Digest { + /// Selected content identity. + sha256: Digest, + }, + /// Entire Analysis Store. + All, +} + +/// Counts and disk usage associated with cleanup targets. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct CleanupSnapshot { + /// Complete Analyses. + pub analyses: u64, + /// Quarantined Analyses. + pub quarantined_analyses: u64, + /// Query Artifacts. + pub artifacts: u64, + /// Diagnostic Log bundles. + pub diagnostic_logs: u64, + /// Logical and allocated usage. + pub usage: StorageUsage, + /// Unique bytewise-sorted Analysis Profile digests. + pub analysis_profile_sha256: Vec, +} + +/// Filesystem usage represented without implying freed space. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct StorageUsage { + /// Sum of file lengths. + pub logical_bytes: u64, + /// Sum of Linux allocated blocks including directories. + pub allocated_bytes: u64, +} + +/// Artifact descriptor returned instead of oversized inline data. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct ArtifactDescriptor { + /// Tagged absolute Artifact path. + pub path: TaggedPath, + /// Exact file bytes including trailing LF. + pub bytes: u64, + /// SHA-256 of exact file bytes. + pub sha256: Digest, + /// Always `application/json`. + pub media_type: String, + /// Always `complete_success_response`. + pub contains: String, +} + +/// Canonical Analysis Profile document whose exact JSON bytes are hashed. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct AnalysisProfile { + /// Canonical profile format version. + pub profile_version: u32, + /// Exact Ghidra version. + pub ghidra_version: String, + /// Exact Java major version. + pub java_version: String, + /// Analysis adapter implementation version. + pub analysis_adapter_version: u32, + /// Analysis-affecting Target Specification. + pub target: ProfileTarget, + /// Fully resolved, bytewise-name-sorted loader options. + pub loader_options: Vec, + /// Fully resolved, bytewise-name-sorted analyzer options. + pub analyzer_options: Vec, + /// Maximum analysis CPU count. + pub max_cpu: PositiveU64, +} + +/// Target fields which affect Analysis identity. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ProfileTarget { + /// Exact loader identifier. + pub loader: String, + /// Exact processor-language identifier. + pub processor_language: String, + /// Exact compiler-specification identifier. + pub compiler_specification: String, +} + +/// Fully resolved typed Ghidra option. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ProfileOption { + /// Canonical option name. + pub name: String, + /// Typed canonical value without floating-point data. + pub value: ProfileOptionValue, +} + +/// Values permitted in canonical profile options. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "type", content = "value", rename_all = "snake_case")] +pub enum ProfileOptionValue { + /// Boolean option. + Boolean(bool), + /// Signed integer option. + Integer(i64), + /// Exact UTF-8 string option. + String(String), +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use serde_json::json; + + use super::{Warning, normalize_warnings}; + + #[test] + fn warnings_sort_and_deduplicate_by_code_and_details() { + let details = BTreeMap::from([("target".to_owned(), json!("elf"))]); + let warnings = normalize_warnings(vec![ + Warning { + code: "zeta".to_owned(), + message: "z".to_owned(), + details: BTreeMap::new(), + }, + Warning { + code: "alpha".to_owned(), + message: "second".to_owned(), + details: details.clone(), + }, + Warning { + code: "alpha".to_owned(), + message: "first".to_owned(), + details, + }, + ]); + assert_eq!(warnings.len(), 2); + assert_eq!(warnings[0].code, "alpha"); + assert_eq!(warnings[0].message, "first"); + } +} diff --git a/src/output.rs b/src/output.rs new file mode 100644 index 0000000..cfd1093 --- /dev/null +++ b/src/output.rs @@ -0,0 +1,214 @@ +//! Strict stdout/stderr framing and replaceable synchronous execution seam. + +use std::{ + ffi::OsString, + io::{self, Write}, +}; + +use clap::Parser as _; +use serde_json::Value; + +use crate::{ + AppError, ErrorCode, ExitStatus, + cli::{Cli, Command, OutputFormat}, + protocol::requests_human_format, +}; + +/// Fixed serialized safety cap for an error document. +pub const MAX_ERROR_BYTES: usize = 65_536; + +/// A fully computed success ready for strict framing. +#[derive(Clone, Debug)] +pub struct RenderedSuccess { + /// Complete public JSON document. + pub json: Value, + /// Semantically equivalent concise human presentation. + pub human: String, +} + +/// Synchronous boundary implemented by later store/process/Java layers. +pub trait Executor { + /// Executes one already parsed and validated invocation. + fn execute(&self, cli: &Cli) -> Result; +} + +/// Foundation executor which truthfully rejects every unconnected path. +#[derive(Clone, Copy, Debug, Default)] +pub struct UnimplementedExecutor; + +impl Executor for UnimplementedExecutor { + fn execute(&self, cli: &Cli) -> Result { + Err(AppError::execution_not_implemented(operation_name( + &cli.command, + ))) + } +} + +/// Parses, executes, and writes exactly the stream framing selected by the caller. +pub fn run_from( + arguments: I, + executor: &impl Executor, + stdout: &mut impl Write, + stderr: &mut impl Write, +) -> io::Result +where + I: IntoIterator, + T: Into + Clone, +{ + let arguments: Vec = arguments.into_iter().map(Into::into).collect(); + match Cli::try_parse_from(arguments.clone()) { + Ok(cli) => run_parsed(&cli, executor, stdout, stderr), + Err(error) => { + if requests_human_format(&arguments) { + stderr.write_all(error.to_string().as_bytes())?; + } else { + write_error_json(&AppError::invalid_arguments(error.to_string()), stderr)?; + } + Ok(ExitStatus::InvalidInvocation) + } + } +} + +/// Executes an already parsed command; useful for embedding and integration tests. +pub fn run_parsed( + cli: &Cli, + executor: &impl Executor, + stdout: &mut impl Write, + stderr: &mut impl Write, +) -> io::Result { + match executor.execute(cli) { + Ok(success) => { + let bytes = success_bytes(cli.format, &success)?; + let limit = usize::try_from(cli.max_inline_bytes.get()).unwrap_or(usize::MAX); + if bytes.len() > limit { + let error = AppError::new( + ErrorCode::ProtocolViolation, + "executor returned a success larger than the selected inline budget", + false, + ) + .with_detail("configured_bytes", Value::from(cli.max_inline_bytes.get())) + .with_detail("observed_bytes", Value::from(bytes.len())); + write_error_json(&error, stderr)?; + return Ok(error.status()); + } + stdout.write_all(&bytes)?; + Ok(ExitStatus::Success) + } + Err(error) => { + match cli.format { + OutputFormat::Json => write_error_json(&error, stderr)?, + OutputFormat::Human => { + stderr.write_all(error.to_string().as_bytes())?; + stderr.write_all(b"\n")?; + } + } + Ok(error.status()) + } + } +} + +fn success_bytes(format: OutputFormat, success: &RenderedSuccess) -> io::Result> { + let mut bytes = match format { + OutputFormat::Json => serde_json::to_vec(&success.json).map_err(io::Error::other)?, + OutputFormat::Human => success.human.as_bytes().to_vec(), + }; + if !bytes.ends_with(b"\n") { + bytes.push(b'\n'); + } + Ok(bytes) +} + +fn write_error_json(error: &AppError, writer: &mut impl Write) -> io::Result<()> { + let mut bytes = serde_json::to_vec(error.envelope()).map_err(io::Error::other)?; + bytes.push(b'\n'); + if bytes.len() > MAX_ERROR_BYTES { + let fallback = AppError::new( + ErrorCode::Internal, + "error document exceeded the fixed safety bound", + false, + ); + bytes = serde_json::to_vec(fallback.envelope()).map_err(io::Error::other)?; + bytes.push(b'\n'); + } + writer.write_all(&bytes) +} + +fn operation_name(command: &Command) -> &'static str { + match command { + Command::Doctor => "doctor", + Command::Inspect(_) => "inspect", + Command::Functions(_) => "functions", + Command::Decompile(_) => "decompile", + Command::Clean(_) => "clean", + } +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod tests { + use serde_json::json; + + use super::{Executor, RenderedSuccess, run_from}; + use crate::{AppError, ExitStatus, cli::Cli}; + + struct SuccessExecutor; + + impl Executor for SuccessExecutor { + fn execute(&self, _cli: &Cli) -> Result { + Ok(RenderedSuccess { + json: json!({"schema_version": 1, "kind": "doctor"}), + human: "ready".to_owned(), + }) + } + } + + #[test] + fn json_success_uses_only_stdout() { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let status = run_from( + ["ghidr", "doctor"], + &SuccessExecutor, + &mut stdout, + &mut stderr, + ) + .expect("write succeeds"); + assert_eq!(status, ExitStatus::Success); + assert_eq!(stdout, b"{\"kind\":\"doctor\",\"schema_version\":1}\n"); + assert!(stderr.is_empty()); + } + + #[test] + fn invalid_json_invocation_uses_only_stderr() { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let status = run_from( + ["ghidr", "decompile", "sample"], + &SuccessExecutor, + &mut stdout, + &mut stderr, + ) + .expect("write succeeds"); + assert_eq!(status, ExitStatus::InvalidInvocation); + assert!(stdout.is_empty()); + let document: serde_json::Value = serde_json::from_slice(&stderr).expect("valid JSON"); + assert_eq!(document["error"]["code"], "invalid_arguments"); + } + + #[test] + fn oversized_executor_success_cannot_escape_inline_bound() { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let status = run_from( + ["ghidr", "doctor", "--max-inline-bytes", "1"], + &SuccessExecutor, + &mut stdout, + &mut stderr, + ) + .expect("write succeeds"); + assert_eq!(status, ExitStatus::RuntimeFailure); + assert!(stdout.is_empty()); + let document: serde_json::Value = serde_json::from_slice(&stderr).expect("valid JSON"); + assert_eq!(document["error"]["code"], "protocol_violation"); + } +} diff --git a/src/protocol.rs b/src/protocol.rs new file mode 100644 index 0000000..1c5dfbc --- /dev/null +++ b/src/protocol.rs @@ -0,0 +1,305 @@ +//! Strict, bounded Rust/Java file protocol. + +use std::{ + ffi::OsStr, + io::{self, Read}, + path::{Path, PathBuf}, +}; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use thiserror::Error; + +use crate::{ + domain::{FunctionSelector, InvocationId, Limits, PageRequest}, + operation::{ADAPTER_PROTOCOL_VERSION, Operation}, +}; + +/// Maximum exact serialized `request.json` size (1 MiB). +pub const MAX_REQUEST_BYTES: usize = 1_048_576; +/// Maximum exact serialized `response.json` size (256 MiB). +pub const MAX_RESPONSE_BYTES: usize = 268_435_456; + +/// A validated UTF-8 absolute path owned by the trusted harness. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(transparent)] +pub struct WorkerPath(PathBuf); + +impl WorkerPath { + /// Validates a path before it crosses into the worker sandbox. + pub fn new(path: PathBuf) -> Result { + if !path.is_absolute() || path.to_str().is_none() { + return Err(ProtocolCodecError::InvalidWorkerPath); + } + Ok(Self(path)) + } + + /// Returns the validated path. + #[must_use] + pub fn as_path(&self) -> &Path { + &self.0 + } +} + +/// Strict adapter request. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct AdapterRequest { + /// Must equal [`ADAPTER_PROTOCOL_VERSION`]. + pub protocol_version: u32, + /// Random 128-bit identifier echoed by the response. + pub invocation_id: InvocationId, + /// Operation echoed by the response. + pub operation: Operation, + /// Private staged Sample path, absent for non-Sample operations. + pub staged_sample: Option, + /// Private Ghidra project or staging directory when required. + pub analysis_path: Option, + /// Selected limits. + pub limits: Limits, + /// Operation-specific validated arguments. + pub arguments: RequestArguments, +} + +/// Operation-specific request arguments. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum RequestArguments { + /// Installation probe. + Doctor, + /// Program metadata extraction. + Inspect, + /// Stable Function page extraction. + Functions { + /// Explicit page bound. + page: PageRequest, + }, + /// Targeted decompilation. + Decompile { + /// Explicit Function Selector. + selector: FunctionSelector, + }, + /// Store cleanup does not cross into Ghidra; reserved for parity. + Clean, +} + +impl RequestArguments { + /// Operation required by this argument variant. + #[must_use] + pub const fn operation(&self) -> Operation { + match self { + Self::Doctor => Operation::Doctor, + Self::Inspect => Operation::Inspect, + Self::Functions { .. } => Operation::Functions, + Self::Decompile { .. } => Operation::Decompile, + Self::Clean => Operation::Clean, + } + } +} + +/// Strict adapter response envelope. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct AdapterResponse { + /// Echoed protocol version. + pub protocol_version: u32, + /// Echoed invocation identifier. + pub invocation_id: InvocationId, + /// Echoed operation. + pub operation: Operation, + /// Exactly one typed success or error result. + pub result: AdapterResult, +} + +/// Adapter result, with operation data validated by the harness afterward. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "status", rename_all = "snake_case", deny_unknown_fields)] +pub enum AdapterResult { + /// Complete operation-specific result. + Success { + /// Strictly schema-validated by the operation dispatcher. + data: serde_json::Value, + }, + /// Typed adapter failure with no partial Query data. + Error { + /// Stable adapter error code. + code: String, + /// Concise diagnostic context. + message: String, + /// Operation-specific error facts. + details: serde_json::Map, + }, +} + +/// Serializes a request only when the exact bytes fit 1 MiB. +pub fn encode_request(request: &AdapterRequest) -> Result, ProtocolCodecError> { + if request.protocol_version != ADAPTER_PROTOCOL_VERSION { + return Err(ProtocolCodecError::VersionMismatch); + } + if request.operation != request.arguments.operation() { + return Err(ProtocolCodecError::OperationMismatch); + } + encode_bounded(request, MAX_REQUEST_BYTES, BoundKind::Request) +} + +/// Reads and decodes a response without accepting more than 256 MiB. +pub fn decode_response(reader: impl Read) -> Result { + decode_bounded(reader, MAX_RESPONSE_BYTES, BoundKind::Response) +} + +/// Validates all response echo fields before dispatching its data. +pub fn validate_response_echo( + request: &AdapterRequest, + response: &AdapterResponse, +) -> Result<(), ProtocolCodecError> { + if request.protocol_version != response.protocol_version { + return Err(ProtocolCodecError::VersionMismatch); + } + if request.invocation_id != response.invocation_id { + return Err(ProtocolCodecError::InvocationMismatch); + } + if request.operation != response.operation { + return Err(ProtocolCodecError::OperationMismatch); + } + Ok(()) +} + +fn encode_bounded( + value: &T, + limit: usize, + kind: BoundKind, +) -> Result, ProtocolCodecError> { + let bytes = serde_json::to_vec(value)?; + if bytes.len() > limit { + return Err(kind.to_error(limit, bytes.len())); + } + Ok(bytes) +} + +fn decode_bounded( + reader: impl Read, + limit: usize, + kind: BoundKind, +) -> Result { + let read_limit = u64::try_from(limit) + .map_err(|_| ProtocolCodecError::PlatformLimit)? + .saturating_add(1); + let mut bytes = Vec::new(); + reader.take(read_limit).read_to_end(&mut bytes)?; + if bytes.len() > limit { + return Err(kind.to_error(limit, bytes.len())); + } + Ok(serde_json::from_slice(&bytes)?) +} + +#[derive(Clone, Copy)] +enum BoundKind { + Request, + Response, +} + +impl BoundKind { + fn to_error(self, limit: usize, observed: usize) -> ProtocolCodecError { + match self { + Self::Request => ProtocolCodecError::RequestTooLarge { limit, observed }, + Self::Response => ProtocolCodecError::ResponseTooLarge { limit, observed }, + } + } +} + +/// Protocol construction, framing, or validation failure. +#[derive(Debug, Error)] +pub enum ProtocolCodecError { + /// A tool-owned path was relative or non-UTF-8. + #[error("worker path must be absolute UTF-8")] + InvalidWorkerPath, + /// Serialized request exceeded the fixed ceiling. + #[error("request exceeds {limit} bytes (observed {observed})")] + RequestTooLarge { + /// Fixed ceiling. + limit: usize, + /// Exact observed bytes. + observed: usize, + }, + /// Serialized response exceeded the fixed ceiling. + #[error("response exceeds {limit} bytes (observed at least {observed})")] + ResponseTooLarge { + /// Fixed ceiling. + limit: usize, + /// Bounded observed lower bound. + observed: usize, + }, + /// Protocol versions differ. + #[error("adapter protocol version mismatch")] + VersionMismatch, + /// Invocation identifier echo differs. + #[error("adapter invocation ID mismatch")] + InvocationMismatch, + /// Operation echo differs. + #[error("adapter operation mismatch")] + OperationMismatch, + /// Platform cannot represent the protocol bound. + #[error("platform cannot represent protocol size bound")] + PlatformLimit, + /// Filesystem or reader failure. + #[error("protocol I/O failed: {0}")] + Io(#[from] io::Error), + /// JSON was malformed or violated a typed contract. + #[error("protocol JSON failed validation: {0}")] + Json(#[from] serde_json::Error), +} + +/// Returns true when an OS argument exactly requests human formatting. +#[must_use] +pub fn requests_human_format(arguments: &[impl AsRef]) -> bool { + arguments.windows(2).any(|pair| { + pair[0].as_ref() == OsStr::new("--format") && pair[1].as_ref() == OsStr::new("human") + }) || arguments + .iter() + .any(|argument| argument.as_ref() == OsStr::new("--format=human")) +} + +#[cfg(test)] +mod tests { + use std::io::Cursor; + + use super::{ProtocolCodecError, decode_bounded, requests_human_format}; + + #[test] + fn response_reader_stops_at_bound_plus_one() { + let bytes = vec![b' '; 9]; + let result = + decode_bounded::(Cursor::new(bytes), 8, super::BoundKind::Response); + assert!(matches!( + result, + Err(ProtocolCodecError::ResponseTooLarge { .. }) + )); + } + + #[test] + fn detects_only_explicit_human_format() { + assert!(requests_human_format(&[ + "ghidr", "--format", "human", "doctor" + ])); + assert!(requests_human_format(&[ + "ghidr", + "doctor", + "--format=human" + ])); + assert!(!requests_human_format(&["ghidr", "doctor"])); + } + + #[test] + fn request_serialization_rejects_bytes_over_fixed_bound() { + let oversized = "x".repeat(super::MAX_REQUEST_BYTES); + let result = super::encode_bounded( + &oversized, + super::MAX_REQUEST_BYTES, + super::BoundKind::Request, + ); + assert!(matches!( + result, + Err(ProtocolCodecError::RequestTooLarge { .. }) + )); + } +} diff --git a/src/schema.rs b/src/schema.rs new file mode 100644 index 0000000..3b7c6d4 --- /dev/null +++ b/src/schema.rs @@ -0,0 +1,163 @@ +//! Deterministic JSON Schema generation from the public Rust types. + +use std::collections::BTreeMap; + +use schemars::{Schema, schema_for}; +use serde_json::{Value, json}; + +use crate::{ + error::ErrorEnvelope, + operation::{ + AnalysisProfile, CleanupData, DecompilationData, DoctorData, FunctionsData, InspectionData, + QueryProvenance, SuccessEnvelope, ToolProvenance, + }, + protocol::{AdapterRequest, AdapterResponse}, +}; + +/// Generates every committed version 1 schema keyed by filename. +#[must_use] +pub fn v1_schemas() -> BTreeMap<&'static str, Schema> { + BTreeMap::from([ + ( + "analysis-profile.schema.json", + with_constant(schema_for!(AnalysisProfile), "profile_version", json!(1)), + ), + ( + "cleanup.schema.json", + with_kind( + schema_for!(SuccessEnvelope), + "cleanup", + ), + ), + ( + "decompilation.schema.json", + with_kind( + schema_for!(SuccessEnvelope), + "decompilation", + ), + ), + ( + "doctor.schema.json", + with_kind( + schema_for!(SuccessEnvelope), + "doctor", + ), + ), + ( + "error.schema.json", + with_constant( + make_extensible(schema_for!(ErrorEnvelope)), + "schema_version", + json!(1), + ), + ), + ( + "functions.schema.json", + with_kind( + schema_for!(SuccessEnvelope), + "functions", + ), + ), + ( + "inspection.schema.json", + with_kind( + schema_for!(SuccessEnvelope), + "inspection", + ), + ), + ( + "protocol-request.schema.json", + with_constant(schema_for!(AdapterRequest), "protocol_version", json!(1)), + ), + ( + "protocol-response.schema.json", + with_constant(schema_for!(AdapterResponse), "protocol_version", json!(1)), + ), + ]) +} + +fn with_kind(schema: Schema, kind: &str) -> Schema { + let schema = with_constant(make_extensible(schema), "schema_version", json!(1)); + with_constant(schema, "kind", json!(kind)) +} + +fn make_extensible(mut schema: Schema) -> Schema { + fn visit(value: &mut Value) { + match value { + Value::Object(object) => { + if object.get("additionalProperties") == Some(&Value::Bool(false)) { + object.remove("additionalProperties"); + } + for child in object.values_mut() { + visit(child); + } + } + Value::Array(array) => { + for child in array { + visit(child); + } + } + _ => {} + } + } + + for value in schema.ensure_object().values_mut() { + visit(value); + } + schema +} + +fn with_constant(mut schema: Schema, property: &str, value: Value) -> Schema { + if let Some(Value::Object(properties)) = schema.ensure_object().get_mut("properties") { + let property_type = if value.is_string() { + "string" + } else { + "integer" + }; + properties.insert( + property.to_owned(), + json!({ "const": value, "type": property_type }), + ); + } + schema +} + +/// Serializes a schema with stable pretty formatting and one trailing LF. +pub fn serialize(schema: &Schema) -> Result { + let mut output = serde_json::to_string_pretty(schema)?; + output.push('\n'); + Ok(output) +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod tests { + use std::{fs, path::PathBuf}; + + use super::{serialize, v1_schemas}; + + fn schema_directory() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("schemas/v1") + } + + #[test] + #[ignore = "explicit reviewed schema update workflow"] + fn generate_committed_schemas() { + let directory = schema_directory(); + fs::create_dir_all(&directory).expect("create schema directory"); + for (name, schema) in v1_schemas() { + let content = serialize(&schema).expect("serialize schema"); + fs::write(directory.join(name), content).expect("write generated schema"); + } + } + + #[test] + fn committed_schemas_match_rust_types() { + for (name, schema) in v1_schemas() { + let expected = serialize(&schema).expect("serialize schema"); + let actual = + fs::read_to_string(schema_directory().join(name)).expect("read committed schema"); + assert_eq!(actual, expected, "schema drift: {name}"); + } + } +} diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..69a78a8 --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,43 @@ +#![allow(clippy::expect_used)] +#![doc = "Black-box CLI framing and exit-status tests."] + +use std::process::Command; + +#[test] +fn unimplemented_operation_is_a_typed_json_runtime_failure() { + let output = Command::new(env!("CARGO_BIN_EXE_ghidr")) + .arg("doctor") + .output() + .expect("run ghidr"); + assert_eq!(output.status.code(), Some(1)); + assert!(output.stdout.is_empty()); + let error: serde_json::Value = + serde_json::from_slice(&output.stderr).expect("valid JSON error"); + assert_eq!(error["error"]["code"], "internal_not_implemented"); + assert_eq!(error["error"]["details"]["operation"], "doctor"); +} + +#[test] +fn invalid_invocation_is_one_json_document_on_stderr() { + let output = Command::new(env!("CARGO_BIN_EXE_ghidr")) + .args(["functions", "sample", "--limit", "0"]) + .output() + .expect("run ghidr"); + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + let error: serde_json::Value = + serde_json::from_slice(&output.stderr).expect("valid JSON error"); + assert_eq!(error["error"]["code"], "invalid_arguments"); + assert_eq!(output.stderr.last(), Some(&b'\n')); +} + +#[test] +fn explicit_human_parse_failure_is_not_json() { + let output = Command::new(env!("CARGO_BIN_EXE_ghidr")) + .args(["--format", "human", "decompile", "sample"]) + .output() + .expect("run ghidr"); + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + assert!(serde_json::from_slice::(&output.stderr).is_err()); +} diff --git a/tests/contracts.rs b/tests/contracts.rs new file mode 100644 index 0000000..6011ef8 --- /dev/null +++ b/tests/contracts.rs @@ -0,0 +1,58 @@ +#![allow(clippy::expect_used)] +#![doc = "Published schema, registry, and reviewed-fixture parity tests."] + +use std::{collections::BTreeSet, fs, path::PathBuf}; + +use ghidra_cli::{ + error::ErrorEnvelope, + operation::{ + CleanupData, DecompilationData, DoctorData, FunctionsData, InspectionData, OPERATIONS, + QueryProvenance, SuccessEnvelope, ToolProvenance, + }, +}; + +fn root(relative: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(relative) +} + +fn fixture(name: &str) -> String { + fs::read_to_string(root(&format!("fixtures/golden/v1/{name}"))).expect("read fixture") +} + +#[test] +fn registry_has_exact_schema_and_golden_coverage() { + let mut operations = BTreeSet::new(); + for descriptor in OPERATIONS { + assert!(operations.insert(descriptor.operation.as_str())); + assert!(root(&format!("schemas/v1/{}", descriptor.schema)).is_file()); + assert!(root(&format!("fixtures/golden/v1/{}", descriptor.golden)).is_file()); + let value: serde_json::Value = + serde_json::from_str(&fixture(descriptor.golden)).expect("fixture JSON"); + assert_eq!(value["kind"], descriptor.public_kind); + } + assert_eq!( + operations, + BTreeSet::from(["clean", "decompile", "doctor", "functions", "inspect"]) + ); +} + +#[test] +fn reviewed_goldens_deserialize_through_public_contracts() { + serde_json::from_str::>(&fixture("doctor.json")) + .expect("doctor contract"); + serde_json::from_str::>(&fixture( + "inspection.json", + )) + .expect("inspection contract"); + serde_json::from_str::>(&fixture( + "functions.json", + )) + .expect("functions contract"); + serde_json::from_str::>(&fixture( + "decompilation.json", + )) + .expect("decompilation contract"); + serde_json::from_str::>(&fixture("cleanup.json")) + .expect("cleanup contract"); + serde_json::from_str::(&fixture("error.json")).expect("error contract"); +}