feat: implement navigation tree and content view for package inspection

- Add `tree.rs` to build a navigation tree for packages, including classes and files.
- Introduce `content_view.rs` to display the contents of the selected node with preview and hex modes.
- Create `highlight.rs` for lightweight syntax highlighting of smali and XML files.
- Implement `info_panel.rs` to show details about the selected node in the right panel.
- Add `tree_view.rs` for a virtualized tree view of the package structure, supporting filtering and keyboard navigation.
This commit is contained in:
Jan Kammerath committed 2026-10-28 11:02:47 +01:00
1 parent f1ac6a2704
commit 04018695b5
28 files changed
+6892 -40

No files matched your search

Generated
+306 -16
View File
@@ -437,6 +437,15 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]]
name = "block2"
version = "0.5.1"
@@ -446,6 +455,15 @@ dependencies = [
"objc2 0.5.3",
]
[[package]]
name = "block2"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
dependencies = [
"objc2 0.6.5",
]
[[package]]
name = "blocking"
version = "1.7.0"
@@ -464,6 +482,11 @@ name = "bogusmips"
version = "0.1.0"
dependencies = [
"eframe",
"egui_extras",
"flate2",
"image",
"rfd",
"sha2",
]
[[package]]
@@ -608,6 +631,12 @@ dependencies = [
"unicode-width",
]
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "combine"
version = "4.6.9"
@@ -627,6 +656,12 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -667,6 +702,15 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.3"
@@ -688,12 +732,32 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]]
name = "cursor-icon"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer",
"const-oid",
"crypto-common",
]
[[package]]
name = "dispatch"
version = "0.2.0"
@@ -707,6 +771,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags 2.13.2",
"block2 0.6.2",
"libc",
"objc2 0.6.5",
]
@@ -855,6 +921,21 @@ dependencies = [
"winit",
]
[[package]]
name = "egui_extras"
version = "0.32.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dddbceddf39805fc6c62b1f7f9c05e23590b40844dc9ed89c6dc6dbc886e3e3b"
dependencies = [
"ahash",
"egui",
"enum-map",
"image",
"log",
"mime_guess2",
"profiling",
]
[[package]]
name = "egui_glow"
version = "0.32.3"
@@ -888,6 +969,26 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enum-map"
version = "2.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9"
dependencies = [
"enum-map-derive",
]
[[package]]
name = "enum-map-derive"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "enumflags2"
version = "0.7.12"
@@ -1143,6 +1244,16 @@ dependencies = [
"r-efi 6.0.0",
]
[[package]]
name = "gif"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
dependencies = [
"color_quant",
"weezl",
]
[[package]]
name = "gl_generator"
version = "0.14.0"
@@ -1334,6 +1445,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
[[package]]
name = "hybrid-array"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
dependencies = [
"typenum",
]
[[package]]
name = "icu_collections"
version = "2.1.1"
@@ -1444,10 +1564,25 @@ checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
dependencies = [
"bytemuck",
"byteorder-lite",
"color_quant",
"gif",
"image-webp",
"moxcms",
"num-traits",
"png",
"tiff",
"zune-core 0.5.3",
"zune-jpeg 0.5.15",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]]
@@ -1677,6 +1812,24 @@ dependencies = [
"paste",
]
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess2"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1706dc14a2e140dec0a7a07109d9a3d5890b81e85bd6c60b906b249a77adf0ca"
dependencies = [
"mime",
"phf",
"phf_shared",
"unicase",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -1850,7 +2003,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
dependencies = [
"bitflags 2.13.2",
"block2",
"block2 0.5.1",
"libc",
"objc2 0.5.3",
"objc2-core-data",
@@ -1866,6 +2019,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.13.2",
"block2 0.6.2",
"objc2 0.6.5",
"objc2-core-foundation",
"objc2-core-graphics",
@@ -1879,7 +2033,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
dependencies = [
"bitflags 2.13.2",
"block2",
"block2 0.5.1",
"objc2 0.5.3",
"objc2-core-location",
"objc2-foundation 0.2.2",
@@ -1891,7 +2045,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"
dependencies = [
"block2",
"block2 0.5.1",
"objc2 0.5.3",
"objc2-foundation 0.2.2",
]
@@ -1903,7 +2057,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
dependencies = [
"bitflags 2.13.2",
"block2",
"block2 0.5.1",
"objc2 0.5.3",
"objc2-foundation 0.2.2",
]
@@ -1938,7 +2092,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
dependencies = [
"block2",
"block2 0.5.1",
"objc2 0.5.3",
"objc2-foundation 0.2.2",
"objc2-metal",
@@ -1950,7 +2104,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"
dependencies = [
"block2",
"block2 0.5.1",
"objc2 0.5.3",
"objc2-contacts",
"objc2-foundation 0.2.2",
@@ -1969,7 +2123,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
dependencies = [
"bitflags 2.13.2",
"block2",
"block2 0.5.1",
"dispatch",
"libc",
"objc2 0.5.3",
@@ -2003,7 +2157,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
dependencies = [
"block2",
"block2 0.5.1",
"objc2 0.5.3",
"objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2",
@@ -2016,7 +2170,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
dependencies = [
"bitflags 2.13.2",
"block2",
"block2 0.5.1",
"objc2 0.5.3",
"objc2-foundation 0.2.2",
]
@@ -2028,7 +2182,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
dependencies = [
"bitflags 2.13.2",
"block2",
"block2 0.5.1",
"objc2 0.5.3",
"objc2-foundation 0.2.2",
"objc2-metal",
@@ -2051,7 +2205,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
dependencies = [
"bitflags 2.13.2",
"block2",
"block2 0.5.1",
"objc2 0.5.3",
"objc2-cloud-kit",
"objc2-core-data",
@@ -2071,7 +2225,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"
dependencies = [
"block2",
"block2 0.5.1",
"objc2 0.5.3",
"objc2-foundation 0.2.2",
]
@@ -2083,7 +2237,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
dependencies = [
"bitflags 2.13.2",
"block2",
"block2 0.5.1",
"objc2 0.5.3",
"objc2-core-location",
"objc2-foundation 0.2.2",
@@ -2174,6 +2328,50 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "phf"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_macros",
"phf_shared",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared",
"rand",
]
[[package]]
name = "phf_macros"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
dependencies = [
"phf_generator",
"phf_shared",
"proc-macro2",
"quote",
"syn 2.0.119",
"unicase",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
"unicase",
]
[[package]]
name = "pin-project"
version = "1.1.13"
@@ -2250,6 +2448,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "pollster"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
[[package]]
name = "portable-atomic"
version = "1.15.0"
@@ -2347,6 +2551,21 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
dependencies = [
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
[[package]]
name = "range-alloc"
version = "0.1.5"
@@ -2392,6 +2611,33 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
[[package]]
name = "rfd"
version = "0.17.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220"
dependencies = [
"block2 0.6.2",
"dispatch2",
"js-sys",
"libc",
"log",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"percent-encoding",
"pollster",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"web-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustc-hash"
version = "1.1.0"
@@ -2526,6 +2772,17 @@ dependencies = [
"syn 3.0.9",
]
[[package]]
name = "sha2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "shlex"
version = "2.0.1"
@@ -2564,6 +2821,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "siphasher"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
[[package]]
name = "slab"
version = "0.4.12"
@@ -2812,7 +3075,7 @@ dependencies = [
"half",
"quick-error",
"weezl",
"zune-jpeg",
"zune-jpeg 0.4.21",
]
[[package]]
@@ -2936,6 +3199,12 @@ dependencies = [
"rustc-hash 2.1.3",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "uds_windows"
version = "1.2.1"
@@ -2947,6 +3216,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "unicase"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f"
[[package]]
name = "unicode-ident"
version = "1.0.27"
@@ -3712,7 +3987,7 @@ dependencies = [
"android-activity",
"atomic-waker",
"bitflags 2.13.2",
"block2",
"block2 0.5.1",
"bytemuck",
"calloop 0.13.0",
"cfg_aliases",
@@ -4053,13 +4328,28 @@ version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
[[package]]
name = "zune-core"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
[[package]]
name = "zune-jpeg"
version = "0.4.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
dependencies = [
"zune-core",
"zune-core 0.4.12",
]
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core 0.5.3",
]
[[package]]
+5
View File
@@ -8,6 +8,11 @@ readme = "README.md"
[dependencies]
eframe = "0.32.3"
egui_extras = { version = "0.32", features = ["image"] }
flate2 = "1.1.10"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp"] }
rfd = "0.17.2"
sha2 = "0.11.0"
[profile.release]
lto = true
+61 -7
View File
@@ -1,23 +1,77 @@
# BogusMips
BogusMips is written in Rust and allows performing analyses on how far your Android app can be disassembled. It allows you to open the `*.apk` or `*.aab` file of your app to see how far your app can be disassembled.
BogusMips is written in Rust and allows performing analyses on how far your Android app can be disassembled. It allows you to open the `*.apk` or `*.xapk` file of your app to see how far your app can be disassembled.
## Features
- Open an `*.apk` or `*.xapk` with **File → Open…** (⌘O / Ctrl+O), by dropping it onto the window, or as a command-line argument.
- **Left: package tree**
- **Source code**: every class from all `classes*.dex` files, grouped by Java package and disassembled to smali.
- **Files**: every entry in the archive.
- XAPK bundles show each contained APK (base and split APKs) separately.
- Filter box (⌘F) and keyboard navigation with the arrow keys.
- **Center: content of the selected item**
- Classes are shown as smali (baksmali-compatible output).
- Binary XML (`AndroidManifest.xml`, `res/**/*.xml`) is decoded.
- Images are previewed, text is shown as text, and everything else as a hex dump.
- A hex view is available for any file.
- **Right: collapsible file information** (⌘I): size, compression, CRC, SHA-256, DEX header details, class details, manifest summary, permissions, and any anomalies found.
All parsers (ZIP, DEX, smali disassembler, binary XML) are implemented in this repository. They follow Android's behaviour rather than the specs, so tricks that break other tools still open. Examples: malformed extra fields, fake encryption flags, CRC mismatches, and bogus compression methods. Such tricks are reported as warnings.
## Building & running
Requires Rust 1.85 or newer.
```sh
cargo run # debug build
cargo run --release # optimized build
cargo run # debug build
cargo run --release -- app.apk # optimized build, opening a file directly
```
## Testing
```sh
cargo test
BOGUSMIPS_TEST_APK=path/to/app.apk cargo test --release # also render every node of a real package
```
Developer tools:
```sh
# Write the smali of every class (apktool layout), e.g. to diff against `apktool d -r`
cargo run --release --example dump_smali -- app.apk out-dir
# Open packages and inspect every node, reporting timings and decode errors
cargo run --release --example inspect_all -- app.apk other.xapk
```
## Project structure
```
src/
├── main.rs # entry point, window setup
├── app.rs # application state and main UI loop
├── main.rs # entry point, window setup
├── lib.rs
├── app.rs # application state, menus, panels, background loading
├── inspect.rs # turns a tree node into content + file information
├── package/
│ ├── mod.rs # APK/XAPK loading, entry extraction, manifest summary
│ └── tree.rs # navigation tree (source code by package, files by folder)
├── formats/
│ ├── reader.rs # bounds-checked binary reader
│ ├── zip.rs # lenient, Android-like ZIP reader
│ ├── axml.rs # Android binary XML decoder
│ ├── detect.rs # file type detection
│ └── dex/
│ ├── mod.rs # DEX parser
│ ├── opcodes.rs # Dalvik opcode table
│ ├── disasm.rs # bytecode → smali instructions
│ ├── smali.rs # class → smali file
│ ├── values.rs # encoded values and annotations
│ ├── javafmt.rs # Java-compatible number formatting (baksmali parity)
│ └── access.rs # access flags
└── ui/
├── mod.rs
└── theme.rs # dark theme (egui widgets + native window frame)
├── theme.rs # dark theme (egui widgets + native window frame)
├── tree_view.rs # left panel
├── content_view.rs # center panel (text, hex, image)
├── info_panel.rs # right panel
└── highlight.rs # smali / XML syntax highlighting
```
+47
View File
@@ -0,0 +1,47 @@
//! Developer tool: writes the smali of every class in an APK/XAPK to a
//! directory (one file per class, apktool layout) for comparison with baksmali.
//!
//! Usage: cargo run --release --example dump_smali -- <app.apk> <out-dir>
use std::path::PathBuf;
use bogusmips::formats::dex::smali::class_to_smali;
use bogusmips::package::Package;
fn main() {
let mut args = std::env::args_os().skip(1);
let (Some(input), Some(out)) = (args.next(), args.next()) else {
eprintln!("usage: dump_smali <app.apk> <out-dir>");
std::process::exit(2);
};
let pkg = Package::open(&PathBuf::from(input)).unwrap_or_else(|e| {
eprintln!("{e}");
std::process::exit(1);
});
let out = PathBuf::from(out);
let mut count = 0;
for (i, entry) in pkg.dex_files.iter().enumerate() {
let dir = if i == 0 {
"smali".to_owned()
} else {
format!("smali_classes{}", i + 1)
};
for class in &entry.dex.class_defs {
let desc = entry.dex.class_descriptor(class);
let rel = desc.trim_start_matches('L').trim_end_matches(';');
let path = out.join(&dir).join(format!("{rel}.smali"));
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, class_to_smali(&entry.dex, class)).unwrap();
count += 1;
}
for w in &entry.dex.warnings {
eprintln!("{}: {w}", entry.name);
}
}
for a in &pkg.archives {
for w in &a.warnings {
eprintln!("{}: {w}", a.name);
}
}
eprintln!("wrote {count} classes");
}
+51
View File
@@ -0,0 +1,51 @@
//! Developer tool: opens packages and inspects every tree node, reporting
//! timings and any content that could not be decoded.
//!
//! Usage: cargo run --release --example inspect_all -- <app.apk>...
use std::time::Instant;
use bogusmips::inspect::{Content, inspect};
use bogusmips::package::Package;
fn main() {
for path in std::env::args_os().skip(1) {
let path = std::path::PathBuf::from(path);
let start = Instant::now();
let pkg = match Package::open(&path) {
Ok(p) => p,
Err(e) => {
println!("{}: ERROR {e}", path.display());
continue;
}
};
let opened = start.elapsed();
let start = Instant::now();
let mut messages = 0;
for node in 0..pkg.nodes.len() {
let inspection = inspect(&pkg, node);
if let Content::Message(m) = &inspection.content {
if m.contains("could not") {
messages += 1;
println!(" {}: {m}", inspection.title);
}
}
for section in &inspection.sections {
for (k, v) in &section.rows {
if k.starts_with('⚠') {
println!(" {} [{k}] {v}", inspection.title);
}
}
}
}
println!(
"{}: {} nodes, {} files, {} dex, opened in {:?}, inspected all in {:?}, {messages} decode errors",
path.display(),
pkg.nodes.len(),
pkg.files.len(),
pkg.dex_files.len(),
opened,
start.elapsed()
);
}
}
+471 -10
View File
@@ -1,30 +1,491 @@
use eframe::egui;
use std::path::{Path, PathBuf};
use std::sync::mpsc::{Receiver, TryRecvError, channel};
use crate::ui::theme;
use eframe::egui::{self, Key, KeyboardShortcut, Modifiers, RichText};
use crate::inspect::{Content, Inspection, group_digits, inspect};
use crate::package::{NodeId, Package, PackageKind};
use crate::ui::content_view::{self, ViewMode};
use crate::ui::tree_view::{self, TreeState};
use crate::ui::{info_panel, theme};
const OPEN: KeyboardShortcut = KeyboardShortcut::new(Modifiers::COMMAND, Key::O);
const CLOSE: KeyboardShortcut = KeyboardShortcut::new(Modifiers::COMMAND, Key::W);
const TOGGLE_INFO: KeyboardShortcut = KeyboardShortcut::new(Modifiers::COMMAND, Key::I);
const FIND: KeyboardShortcut = KeyboardShortcut::new(Modifiers::COMMAND, Key::F);
struct Loading {
path: PathBuf,
rx: Receiver<Result<Package, String>>,
}
struct Document {
package: Package,
tree: TreeState,
selected: Option<NodeId>,
inspection: Option<Inspection>,
view_mode: ViewMode,
}
#[derive(Default)]
pub struct BogusMipsApp {
document: Option<Document>,
loading: Option<Loading>,
pending_open: Option<PathBuf>,
error: Option<String>,
show_info: bool,
native_theme_applied: bool,
}
impl BogusMipsApp {
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
theme::apply_dark(&cc.egui_ctx);
Self::default()
pub fn new(cc: &eframe::CreationContext<'_>, initial_file: Option<PathBuf>) -> Self {
Self::with_context(&cc.egui_ctx, initial_file)
}
pub fn with_context(ctx: &egui::Context, initial_file: Option<PathBuf>) -> Self {
theme::apply_dark(ctx);
egui_extras::install_image_loaders(ctx);
Self {
document: None,
loading: None,
pending_open: initial_file,
error: None,
show_info: true,
native_theme_applied: false,
}
}
fn pick_file(&mut self) {
let picked = rfd::FileDialog::new()
.set_title("Open Android package")
.add_filter("Android package (*.apk, *.xapk)", &["apk", "xapk"])
.pick_file();
if let Some(path) = picked {
self.pending_open = Some(path);
}
}
fn start_loading(&mut self, ctx: &egui::Context, path: PathBuf) {
self.close(ctx);
let (tx, rx) = channel();
let worker_path = path.clone();
let repaint = ctx.clone();
std::thread::spawn(move || {
let _ = tx.send(Package::open(&worker_path));
repaint.request_repaint();
});
self.loading = Some(Loading { path, rx });
}
fn poll_loading(&mut self, ctx: &egui::Context) {
let Some(loading) = &self.loading else { return };
match loading.rx.try_recv() {
Ok(Ok(package)) => {
let tree = TreeState::new(&package);
let root = package.root();
let title = format!("{} — BogusMips", package.archives[0].name);
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title));
let mut doc = Document {
package,
tree,
selected: None,
inspection: None,
view_mode: ViewMode::default(),
};
select(ctx, &mut doc, root);
self.document = Some(doc);
self.loading = None;
}
Ok(Err(e)) => {
self.error = Some(e);
self.loading = None;
}
Err(TryRecvError::Empty) => {}
Err(TryRecvError::Disconnected) => {
self.error = Some(format!(
"Loading {} failed unexpectedly.",
loading.path.display()
));
self.loading = None;
}
}
}
fn close(&mut self, ctx: &egui::Context) {
if self.document.take().is_some() {
ctx.forget_all_images();
ctx.send_viewport_cmd(egui::ViewportCommand::Title("BogusMips".to_owned()));
}
}
fn handle_input(&mut self, ctx: &egui::Context) {
let (open, close, toggle_info, find, dropped) = ctx.input_mut(|i| {
(
i.consume_shortcut(&OPEN),
i.consume_shortcut(&CLOSE),
i.consume_shortcut(&TOGGLE_INFO),
i.consume_shortcut(&FIND),
i.raw.dropped_files.iter().find_map(|f| f.path.clone()),
)
});
if open {
self.pick_file();
}
if close {
self.close(ctx);
}
if toggle_info {
self.show_info = !self.show_info;
}
if find {
ctx.memory_mut(|m| m.request_focus(egui::Id::new(tree_view::FILTER_ID)));
}
if let Some(path) = dropped {
self.pending_open = Some(path);
}
}
fn menu_bar(&mut self, ctx: &egui::Context) {
egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
egui::MenuBar::new().ui(ui, |ui| {
ui.menu_button("File", |ui| {
if ui
.add(egui::Button::new("Open…").shortcut_text(ctx.format_shortcut(&OPEN)))
.clicked()
{
self.pick_file();
}
if ui
.add_enabled(
self.document.is_some(),
egui::Button::new("Close").shortcut_text(ctx.format_shortcut(&CLOSE)),
)
.clicked()
{
self.close(ctx);
}
ui.separator();
if ui.button("Quit").clicked() {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
});
ui.menu_button("View", |ui| {
let label = if self.show_info {
"Hide file information"
} else {
"Show file information"
};
if ui
.add(
egui::Button::new(label)
.shortcut_text(ctx.format_shortcut(&TOGGLE_INFO)),
)
.clicked()
{
self.show_info = !self.show_info;
}
});
});
});
}
fn status_bar(&self, ctx: &egui::Context) {
egui::TopBottomPanel::bottom("status_bar").show(ctx, |ui| {
ui.horizontal(|ui| {
if let Some(loading) = &self.loading {
ui.spinner();
ui.label(format!("Opening {}…", loading.path.display()));
} else if let Some(doc) = &self.document {
let pkg = &doc.package;
let kind = match pkg.kind {
PackageKind::Apk => "APK",
PackageKind::Xapk => "XAPK",
};
let classes: usize = pkg.dex_files.iter().map(|d| d.dex.class_defs.len()).sum();
let warnings: usize =
pkg.archives.iter().map(|a| a.warnings.len()).sum::<usize>()
+ pkg
.dex_files
.iter()
.map(|d| d.dex.warnings.len())
.sum::<usize>();
ui.label(format!(
"{kind} · {} files · {} DEX · {} classes",
group_digits(pkg.files.len()),
pkg.dex_files.len(),
group_digits(classes)
));
if warnings > 0 {
ui.label(
RichText::new(format!("⚠ {warnings} warnings"))
.color(ui.visuals().warn_fg_color),
)
.on_hover_text("Select the package root to see the warnings");
}
} else {
ui.label("No file open");
}
});
});
}
fn info_panel(&mut self, ctx: &egui::Context) {
let inspection = self.document.as_ref().and_then(|d| d.inspection.as_ref());
let mut toggle = false;
let expanded = egui::SidePanel::right("info_panel")
.resizable(true)
.default_width(300.0)
.min_width(220.0);
let collapsed = egui::SidePanel::right("info_panel_collapsed")
.resizable(false)
.exact_width(28.0);
egui::SidePanel::show_animated_between(
ctx,
self.show_info,
collapsed,
expanded,
|ui, how_expanded| {
if how_expanded < 0.5 {
ui.vertical_centered(|ui| {
if ui
.add(egui::Button::new("«").frame(false))
.on_hover_text("Show file information (⌘I)")
.clicked()
{
toggle = true;
}
});
return;
}
ui.horizontal(|ui| {
ui.strong("File information");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add(egui::Button::new("»").frame(false))
.on_hover_text("Hide file information (⌘I)")
.clicked()
{
toggle = true;
}
});
});
ui.separator();
info_panel::show(ui, inspection);
},
);
if toggle {
self.show_info = !self.show_info;
}
}
fn welcome(&mut self, ui: &mut egui::Ui) {
ui.vertical_centered(|ui| {
ui.add_space((ui.available_height() / 2.0 - 90.0).max(0.0));
if let Some(loading) = &self.loading {
ui.spinner();
ui.label(format!("Opening {}…", file_name(&loading.path)));
return;
}
ui.heading(RichText::new("BogusMips").size(32.0));
ui.add_space(4.0);
ui.label(
RichText::new("See how far your Android app can be disassembled.")
.color(ui.visuals().weak_text_color()),
);
ui.add_space(16.0);
if ui
.add(egui::Button::new(
RichText::new("Open APK or XAPK…").size(16.0),
))
.clicked()
{
self.pick_file();
}
ui.add_space(8.0);
ui.label(
RichText::new("or drop a file onto this window")
.small()
.color(ui.visuals().weak_text_color()),
);
});
}
}
fn file_name(path: &Path) -> String {
path.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string())
}
fn select(ctx: &egui::Context, doc: &mut Document, node: NodeId) {
if let Some(Inspection {
content: Content::Image { uri, .. },
..
}) = &doc.inspection
{
ctx.forget_image(uri);
}
doc.selected = Some(node);
doc.tree.reveal(&doc.package, node);
// Parsers are hardened against hostile input; this is a last line of
// defense so a missed edge case cannot take down the whole app.
let package = &doc.package;
let inspection =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| inspect(package, node)))
.unwrap_or_else(|_| Inspection {
node,
title: package.node_path(node),
content: Content::Message("Internal error while decoding this item.".to_owned()),
raw: None,
sections: Vec::new(),
});
doc.inspection = Some(inspection);
doc.view_mode = ViewMode::Preview;
}
impl eframe::App for BogusMipsApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.ui(ctx);
}
}
impl BogusMipsApp {
pub fn ui(&mut self, ctx: &egui::Context) {
if !self.native_theme_applied {
theme::apply_dark_window_frame(ctx);
self.native_theme_applied = true;
}
egui::CentralPanel::default().show(ctx, |ui| {
ui.centered_and_justified(|ui| {
ui.heading("Hello, World!");
});
self.handle_input(ctx);
if let Some(path) = self.pending_open.take() {
self.start_loading(ctx, path);
}
self.poll_loading(ctx);
self.menu_bar(ctx);
self.status_bar(ctx);
if let Some(doc) = &mut self.document {
egui::SidePanel::left("tree_panel")
.resizable(true)
.default_width(320.0)
.min_width(200.0)
.show(ctx, |ui| {
if let Some(node) =
tree_view::show(ui, &doc.package, &mut doc.tree, doc.selected)
{
select(ctx, doc, node);
}
});
}
if self.document.is_some() {
self.info_panel(ctx);
}
egui::CentralPanel::default().show(ctx, |ui| match &mut self.document {
Some(Document {
inspection: Some(inspection),
view_mode,
..
}) => content_view::show(ui, inspection, view_mode),
Some(_) => {}
None => self.welcome(ui),
});
if ctx.input(|i| !i.raw.hovered_files.is_empty()) {
let painter = ctx.layer_painter(egui::LayerId::new(
egui::Order::Foreground,
egui::Id::new("drop_overlay"),
));
let rect = ctx.screen_rect();
painter.rect_filled(rect, 0.0, egui::Color32::from_black_alpha(160));
painter.text(
rect.center(),
egui::Align2::CENTER_CENTER,
"Drop to open",
egui::FontId::proportional(24.0),
egui::Color32::WHITE,
);
}
if let Some(error) = self.error.clone() {
let modal = egui::Modal::new(egui::Id::new("error_modal")).show(ctx, |ui| {
ui.set_max_width(420.0);
ui.heading("Could not open file");
ui.add_space(8.0);
ui.label(error);
ui.add_space(12.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.button("OK").clicked()
})
.inner
});
if modal.inner || modal.should_close() {
self.error = None;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{Duration, Instant};
fn frame(ctx: &egui::Context, app: &mut BogusMipsApp) {
let _ = ctx.run(egui::RawInput::default(), |ctx| app.ui(ctx));
}
#[test]
fn renders_welcome_screen() {
let ctx = egui::Context::default();
let mut app = BogusMipsApp::with_context(&ctx, None);
frame(&ctx, &mut app);
frame(&ctx, &mut app);
assert!(app.document.is_none());
}
#[test]
fn reports_open_errors() {
let ctx = egui::Context::default();
let mut app = BogusMipsApp::with_context(&ctx, Some(PathBuf::from("/nonexistent.apk")));
let deadline = Instant::now() + Duration::from_secs(10);
while app.error.is_none() && Instant::now() < deadline {
frame(&ctx, &mut app);
std::thread::sleep(Duration::from_millis(5));
}
assert!(app.error.is_some());
frame(&ctx, &mut app);
}
/// Renders every node of a real package headlessly. Opt-in, as no APK
/// ships with the repository: `BOGUSMIPS_TEST_APK=app.apk cargo test`.
#[test]
fn renders_every_node_of_real_package() {
let Some(path) = std::env::var_os("BOGUSMIPS_TEST_APK") else {
return;
};
let ctx = egui::Context::default();
let mut app = BogusMipsApp::with_context(&ctx, Some(PathBuf::from(path)));
let deadline = Instant::now() + Duration::from_secs(60);
while app.document.is_none() && app.error.is_none() && Instant::now() < deadline {
frame(&ctx, &mut app);
std::thread::sleep(Duration::from_millis(5));
}
assert!(app.error.is_none(), "{:?}", app.error);
let nodes = app.document.as_ref().expect("loaded").package.nodes.len();
for node in 0..nodes {
let doc = app.document.as_mut().expect("loaded");
select(&ctx, doc, node);
frame(&ctx, &mut app);
let doc = app.document.as_mut().expect("loaded");
if doc.inspection.as_ref().is_some_and(|i| i.raw.is_some()) {
doc.view_mode = ViewMode::Hex;
frame(&ctx, &mut app);
}
}
app.show_info = false;
frame(&ctx, &mut app);
app.document.as_mut().expect("loaded").tree.filter = "main".to_owned();
frame(&ctx, &mut app);
}
}
+583
View File
@@ -0,0 +1,583 @@
//! Decoder for Android binary XML (AXML), as used by `AndroidManifest.xml`
//! and compiled `res/**/*.xml` files inside an APK.
use std::collections::HashMap;
use std::fmt::Write;
use super::reader::{Reader, Result, err, read_u16, read_u32};
const RES_STRING_POOL_TYPE: u16 = 0x0001;
const RES_XML_TYPE: u16 = 0x0003;
const RES_XML_START_NAMESPACE_TYPE: u16 = 0x0100;
const RES_XML_END_NAMESPACE_TYPE: u16 = 0x0101;
const RES_XML_START_ELEMENT_TYPE: u16 = 0x0102;
const RES_XML_END_ELEMENT_TYPE: u16 = 0x0103;
const RES_XML_CDATA_TYPE: u16 = 0x0104;
const RES_XML_RESOURCE_MAP_TYPE: u16 = 0x0180;
const UTF8_FLAG: u32 = 1 << 8;
const NO_INDEX: u32 = 0xffff_ffff;
/// Maximum element nesting; deeper elements are skipped (Android layouts
/// rarely exceed a few dozen levels, crafted files can nest millions).
const MAX_DEPTH: usize = 256;
pub const ANDROID_NS: &str = "http://schemas.android.com/apk/res/android";
#[derive(Debug, Clone, Default)]
pub struct XmlDocument {
pub roots: Vec<XmlElement>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, Default)]
pub struct XmlElement {
pub name: String,
pub namespaces: Vec<(String, String)>,
pub attributes: Vec<XmlAttribute>,
pub children: Vec<XmlNode>,
}
#[derive(Debug, Clone)]
pub enum XmlNode {
Element(XmlElement),
Text(String),
}
#[derive(Debug, Clone)]
pub struct XmlAttribute {
pub prefix: Option<String>,
pub name: String,
pub value: String,
pub resource_id: Option<u32>,
}
impl XmlElement {
/// Value of the first attribute with the given local name (any namespace).
pub fn attr(&self, name: &str) -> Option<&str> {
self.attributes
.iter()
.find(|a| a.name == name)
.map(|a| a.value.as_str())
}
pub fn child_elements(&self) -> impl Iterator<Item = &XmlElement> {
self.children.iter().filter_map(|c| match c {
XmlNode::Element(e) => Some(e),
XmlNode::Text(_) => None,
})
}
}
pub fn is_binary_xml(data: &[u8]) -> bool {
data.len() >= 8 && data[0..2] == [0x03, 0x00] && data[2..4] == [0x08, 0x00]
}
/// Android framework attribute IDs, used when obfuscators strip attribute
/// names from the string pool (Android itself resolves attributes by ID).
fn android_attr_name(id: u32) -> Option<&'static str> {
Some(match id {
0x0101_0000 => "theme",
0x0101_0001 => "label",
0x0101_0002 => "icon",
0x0101_0003 => "name",
0x0101_0006 => "permission",
0x0101_000e => "enabled",
0x0101_000f => "debuggable",
0x0101_0010 => "exported",
0x0101_0011 => "process",
0x0101_0018 => "authorities",
0x0101_0024 => "value",
0x0101_0025 => "resource",
0x0101_020c => "minSdkVersion",
0x0101_021b => "versionCode",
0x0101_021c => "versionName",
0x0101_0270 => "targetSdkVersion",
0x0101_0280 => "allowBackup",
0x0101_0572 => "compileSdkVersion",
0x0101_0573 => "compileSdkVersionCodename",
_ => return None,
})
}
fn parse_string_pool(data: &[u8], start: usize, size: usize) -> Result<Vec<String>> {
let mut r = Reader::at(data, start)?;
r.skip(2)?;
let header_size = usize::from(r.u16()?);
r.skip(4)?;
let count = r.u32()? as usize;
let _style_count = r.u32()?;
let flags = r.u32()?;
let strings_start = r.u32()? as usize;
if count.saturating_mul(4) > size {
return err(format!(
"string pool claims {count} strings, exceeding its chunk"
));
}
let utf8 = flags & UTF8_FLAG != 0;
let mut offsets = Reader::at(data, start + header_size)?;
let mut strings = Vec::with_capacity(count);
for _ in 0..count {
let off = offsets.u32()? as usize;
let pos = start.saturating_add(strings_start).saturating_add(off);
strings.push(read_pool_string(data, pos, utf8).unwrap_or_default());
}
Ok(strings)
}
fn read_pool_string(data: &[u8], pos: usize, utf8: bool) -> Result<String> {
let mut r = Reader::at(data, pos)?;
if utf8 {
let len8 = |r: &mut Reader| -> Result<usize> {
let b = r.u8()?;
Ok(if b & 0x80 != 0 {
(usize::from(b & 0x7f) << 8) | usize::from(r.u8()?)
} else {
usize::from(b)
})
};
let _utf16_len = len8(&mut r)?;
let len = len8(&mut r)?;
Ok(String::from_utf8_lossy(r.bytes(len)?).into_owned())
} else {
let mut len = r.u16()? as usize;
if len & 0x8000 != 0 {
len = ((len & 0x7fff) << 16) | usize::from(r.u16()?);
}
if len.saturating_mul(2) > r.remaining() {
return err("string exceeds data");
}
let units: Vec<u16> = (0..len).map(|_| r.u16()).collect::<Result<_>>()?;
Ok(String::from_utf16_lossy(&units))
}
}
fn complex_to_float(data: u32) -> f64 {
const RADIX_MULTS: [f64; 4] = [
1.0 / 256.0,
1.0 / 256.0 / 128.0,
1.0 / 256.0 / 32768.0,
1.0 / 256.0 / 8_388_608.0,
];
let mantissa = f64::from((data & 0xffff_ff00) as i32);
mantissa * RADIX_MULTS[((data >> 4) & 0x3) as usize]
}
fn trim_float(v: f64) -> String {
let s = format!("{v:.4}");
let s = s.trim_end_matches('0').trim_end_matches('.');
s.to_owned()
}
fn format_typed_value(data_type: u8, data: u32, strings: &[String]) -> String {
match data_type {
0x00 => String::new(),
0x01 | 0x07 => match data {
0 => "@null".to_owned(),
d if d >> 24 == 0x01 => format!("@android:0x{d:08x}"),
d => format!("@0x{d:08x}"),
},
0x02 | 0x08 => format!("?0x{data:08x}"),
0x03 => strings.get(data as usize).cloned().unwrap_or_default(),
0x04 => trim_float(f64::from(f32::from_bits(data))),
0x05 => {
const UNITS: [&str; 6] = ["px", "dp", "sp", "pt", "in", "mm"];
let unit = UNITS.get((data & 0xf) as usize).copied().unwrap_or("?");
format!("{}{unit}", trim_float(complex_to_float(data)))
}
0x06 => {
let unit = if data & 0xf == 0 { "%" } else { "%p" };
format!("{}{unit}", trim_float(complex_to_float(data) * 100.0))
}
0x10 => (data as i32).to_string(),
0x11 => format!("0x{data:x}"),
0x12 => (data != 0).to_string(),
0x1c | 0x1e => format!("#{data:08x}"),
0x1d | 0x1f => format!("#{:06x}", data & 0x00ff_ffff),
t => format!("0x{data:08x} (type 0x{t:02x})"),
}
}
pub fn parse(data: &[u8]) -> Result<XmlDocument> {
if !is_binary_xml(data) {
return err("not an Android binary XML file");
}
let header_size = usize::from(read_u16(data, 2)?);
let declared = read_u32(data, 4)? as usize;
let mut doc = XmlDocument::default();
if declared != data.len() {
doc.warnings.push(format!(
"header declares {declared} bytes, file has {}",
data.len()
));
}
let end = data.len();
let mut strings: Vec<String> = Vec::new();
let mut resource_ids: Vec<u32> = Vec::new();
let mut ns_prefixes: HashMap<String, String> = HashMap::new();
let mut pending_ns: Vec<(String, String)> = Vec::new();
let mut stack: Vec<XmlElement> = Vec::new();
let mut skipped_depth = 0usize;
let str_at = |strings: &[String], idx: u32| -> String {
if idx == NO_INDEX {
String::new()
} else {
strings.get(idx as usize).cloned().unwrap_or_default()
}
};
let mut pos = header_size.max(8);
while pos + 8 <= end {
let chunk_type = read_u16(data, pos)?;
let chunk_header = usize::from(read_u16(data, pos + 2)?);
let chunk_size = read_u32(data, pos + 4)? as usize;
if chunk_size < 8 || pos + chunk_size > end {
doc.warnings.push(format!(
"invalid chunk size {chunk_size} at offset 0x{pos:x}"
));
break;
}
let ext = pos + chunk_header;
match chunk_type {
RES_STRING_POOL_TYPE => strings = parse_string_pool(data, pos, chunk_size)?,
RES_XML_RESOURCE_MAP_TYPE => {
resource_ids = (ext..pos + chunk_size)
.step_by(4)
.filter_map(|o| read_u32(data, o).ok())
.collect();
}
RES_XML_START_NAMESPACE_TYPE => {
let prefix = str_at(&strings, read_u32(data, ext)?);
let uri = str_at(&strings, read_u32(data, ext + 4)?);
ns_prefixes.insert(uri.clone(), prefix.clone());
pending_ns.push((prefix, uri));
}
RES_XML_END_NAMESPACE_TYPE => {}
RES_XML_START_ELEMENT_TYPE if stack.len() >= MAX_DEPTH => {
if skipped_depth == 0 {
doc.warnings.push(format!(
"elements nested deeper than {MAX_DEPTH} levels were skipped"
));
}
skipped_depth += 1;
}
RES_XML_END_ELEMENT_TYPE if skipped_depth > 0 => skipped_depth -= 1,
RES_XML_START_ELEMENT_TYPE => {
let mut r = Reader::at(data, ext)?;
let ns = r.u32()?;
let name = str_at(&strings, r.u32()?);
let attr_start = usize::from(r.u16()?);
let attr_size = usize::from(r.u16()?).max(20);
let attr_count = usize::from(r.u16()?);
let prefix_for = |ns_idx: u32| -> Option<String> {
if ns_idx == NO_INDEX {
return None;
}
let uri = str_at(&strings, ns_idx);
Some(ns_prefixes.get(&uri).cloned().unwrap_or_else(|| {
if uri == ANDROID_NS {
"android".to_owned()
} else {
format!("ns{}", ns_idx)
}
}))
};
let element_name = match prefix_for(ns) {
Some(p) if !p.is_empty() => format!("{p}:{name}"),
_ => name,
};
let mut element = XmlElement {
name: element_name,
namespaces: std::mem::take(&mut pending_ns),
..Default::default()
};
for i in 0..attr_count {
let mut a = Reader::at(data, ext + attr_start + i * attr_size)?;
let attr_ns = a.u32()?;
let name_idx = a.u32()?;
let raw_value = a.u32()?;
a.skip(3)?;
let data_type = a.u8()?;
let value_data = a.u32()?;
let resource_id = resource_ids.get(name_idx as usize).copied();
let mut attr_name = str_at(&strings, name_idx);
if attr_name.is_empty() {
attr_name = match resource_id {
Some(id) => android_attr_name(id)
.map(str::to_owned)
.unwrap_or_else(|| format!("attr_0x{id:08x}")),
None => format!("attr_{name_idx}"),
};
}
let value = if data_type == 0x03 && raw_value != NO_INDEX {
str_at(&strings, raw_value)
} else {
format_typed_value(data_type, value_data, &strings)
};
element.attributes.push(XmlAttribute {
prefix: prefix_for(attr_ns).filter(|p| !p.is_empty()),
name: attr_name,
value,
resource_id,
});
}
stack.push(element);
}
RES_XML_END_ELEMENT_TYPE => {
if let Some(element) = stack.pop() {
attach(&mut stack, &mut doc.roots, element);
} else {
doc.warnings
.push(format!("unbalanced end element at offset 0x{pos:x}"));
}
}
RES_XML_CDATA_TYPE => {
let text = str_at(&strings, read_u32(data, ext)?);
if let Some(parent) = stack.last_mut() {
if !text.trim().is_empty() {
parent.children.push(XmlNode::Text(text));
}
}
}
RES_XML_TYPE => {
doc.warnings
.push(format!("nested XML chunk at offset 0x{pos:x} ignored"));
}
_ => {}
}
pos += chunk_size;
}
if !stack.is_empty() {
doc.warnings
.push(format!("{} element(s) were not closed", stack.len()));
while let Some(element) = stack.pop() {
attach(&mut stack, &mut doc.roots, element);
}
}
Ok(doc)
}
fn attach(stack: &mut [XmlElement], roots: &mut Vec<XmlElement>, element: XmlElement) {
match stack.last_mut() {
Some(parent) => parent.children.push(XmlNode::Element(element)),
None => roots.push(element),
}
}
fn escape_xml(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\n' => out.push_str("&#10;"),
c if (c as u32) < 0x20 && c != '\t' => {
let _ = write!(out, "&#{};", c as u32);
}
c => out.push(c),
}
}
out
}
impl XmlDocument {
pub fn to_xml_string(&self) -> String {
let mut out = String::from("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");
for w in &self.warnings {
let _ = writeln!(out, "<!-- WARNING: {} -->", w.replace("--", "- -"));
}
for root in &self.roots {
write_element(&mut out, root, 0);
}
out
}
pub fn root(&self) -> Option<&XmlElement> {
self.roots.first()
}
}
fn write_element(out: &mut String, e: &XmlElement, depth: usize) {
let pad = " ".repeat(depth);
let mut attrs: Vec<String> = e
.namespaces
.iter()
.map(|(prefix, uri)| {
if prefix.is_empty() {
format!("xmlns=\"{}\"", escape_xml(uri))
} else {
format!("xmlns:{prefix}=\"{}\"", escape_xml(uri))
}
})
.collect();
attrs.extend(e.attributes.iter().map(|a| match &a.prefix {
Some(p) => format!("{p}:{}=\"{}\"", a.name, escape_xml(&a.value)),
None => format!("{}=\"{}\"", a.name, escape_xml(&a.value)),
}));
let _ = write!(out, "{pad}<{}", e.name);
let attr_pad = format!("\n{pad} ");
for (i, a) in attrs.iter().enumerate() {
out.push_str(if i == 0 { " " } else { &attr_pad });
out.push_str(a);
}
if e.children.is_empty() {
out.push_str(" />\n");
return;
}
out.push_str(">\n");
for child in &e.children {
match child {
XmlNode::Element(c) => write_element(out, c, depth + 1),
XmlNode::Text(t) => {
let _ = writeln!(out, "{pad} {}", escape_xml(t.trim()));
}
}
}
let _ = writeln!(out, "{pad}</{}>", e.name);
}
#[cfg(test)]
mod tests {
use super::*;
fn chunk(kind: u16, header: u16, body: &[u8]) -> Vec<u8> {
let mut v = Vec::new();
v.extend(kind.to_le_bytes());
v.extend(header.to_le_bytes());
v.extend(((body.len() + 8) as u32).to_le_bytes());
v.extend(body);
v
}
fn string_pool(strings: &[&str]) -> Vec<u8> {
let mut offsets = Vec::new();
let mut data = Vec::new();
for s in strings {
offsets.extend((data.len() as u32).to_le_bytes());
data.extend((s.len() as u16).to_le_bytes());
for u in s.encode_utf16() {
data.extend(u.to_le_bytes());
}
data.extend([0, 0]);
}
let mut body = Vec::new();
body.extend((strings.len() as u32).to_le_bytes());
body.extend(0u32.to_le_bytes());
body.extend(0u32.to_le_bytes());
body.extend((28 + offsets.len() as u32).to_le_bytes());
body.extend(0u32.to_le_bytes());
body.extend(offsets);
body.extend(data);
chunk(RES_STRING_POOL_TYPE, 28, &body)
}
fn node(kind: u16, ext: &[u8]) -> Vec<u8> {
let mut body = vec![0u8; 8];
body.extend(ext);
chunk(kind, 16, &body)
}
#[test]
fn decodes_minimal_manifest() {
let pool = string_pool(&[
"android",
ANDROID_NS,
"manifest",
"package",
"com.example",
"",
]);
let mut start = Vec::new();
start.extend(NO_INDEX.to_le_bytes());
start.extend(2u32.to_le_bytes());
start.extend(20u16.to_le_bytes());
start.extend(20u16.to_le_bytes());
start.extend(2u16.to_le_bytes());
start.extend([0u8; 6]);
// package="com.example"
start.extend(NO_INDEX.to_le_bytes());
start.extend(3u32.to_le_bytes());
start.extend(4u32.to_le_bytes());
start.extend([8, 0, 0, 0x03]);
start.extend(4u32.to_le_bytes());
// android:<stripped name>=0x1 with resource id versionCode
start.extend(1u32.to_le_bytes());
start.extend(5u32.to_le_bytes());
start.extend(NO_INDEX.to_le_bytes());
start.extend([8, 0, 0, 0x10]);
start.extend(1u32.to_le_bytes());
let mut res_map = Vec::new();
for _ in 0..5 {
res_map.extend(0u32.to_le_bytes());
}
res_map.extend(0x0101_021bu32.to_le_bytes());
let mut ns = Vec::new();
ns.extend(0u32.to_le_bytes());
ns.extend(1u32.to_le_bytes());
let mut end = Vec::new();
end.extend(NO_INDEX.to_le_bytes());
end.extend(2u32.to_le_bytes());
let mut body = pool;
body.extend(chunk(RES_XML_RESOURCE_MAP_TYPE, 8, &res_map));
body.extend(node(RES_XML_START_NAMESPACE_TYPE, &ns));
body.extend(node(RES_XML_START_ELEMENT_TYPE, &start));
body.extend(node(RES_XML_END_ELEMENT_TYPE, &end));
let file = chunk(RES_XML_TYPE, 8, &body);
let doc = parse(&file).unwrap();
let root = doc.root().unwrap();
assert_eq!(root.name, "manifest");
assert_eq!(root.attr("package"), Some("com.example"));
assert_eq!(root.attr("versionCode"), Some("1"));
let xml = doc.to_xml_string();
assert!(xml.contains("xmlns:android=\"http://schemas.android.com/apk/res/android\""));
assert!(xml.contains("android:versionCode=\"1\""));
assert!(doc.warnings.is_empty());
}
#[test]
fn limits_nesting_depth() {
let pool = string_pool(&["a"]);
let mut start = Vec::new();
start.extend(NO_INDEX.to_le_bytes());
start.extend(0u32.to_le_bytes());
start.extend(20u16.to_le_bytes());
start.extend(20u16.to_le_bytes());
start.extend([0u8; 8]);
let mut body = pool;
let element = node(RES_XML_START_ELEMENT_TYPE, &start);
for _ in 0..100_000 {
body.extend(&element);
}
let doc = parse(&chunk(RES_XML_TYPE, 8, &body)).unwrap();
assert!(doc.warnings.iter().any(|w| w.contains("nested deeper")));
assert!(doc.to_xml_string().len() < 1_000_000);
}
#[test]
fn rejects_truncated() {
assert!(parse(&[0x03, 0x00, 0x08, 0x00]).is_err());
let doc = parse(&[
0x03, 0x00, 0x08, 0x00, 0xff, 0, 0, 0, 1, 0, 0x1c, 0, 0xff, 0xff, 0, 0,
]);
assert!(doc.is_ok_and(|d| d.roots.is_empty() && !d.warnings.is_empty()));
}
#[test]
fn typed_values() {
assert_eq!(format_typed_value(0x12, 0xffff_ffff, &[]), "true");
assert_eq!(format_typed_value(0x1c, 0xff00_ff00, &[]), "#ff00ff00");
assert_eq!(format_typed_value(0x01, 0x7f01_0001, &[]), "@0x7f010001");
// 16dp: mantissa 16 << 8, radix 0, unit dp (1)
assert_eq!(format_typed_value(0x05, (16 << 8) | 1, &[]), "16dp");
}
}
+116
View File
@@ -0,0 +1,116 @@
//! Content-based file type detection.
use super::{axml, dex};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum FileKind {
Empty,
Dex,
BinaryXml,
ResourceTable,
Image(&'static str),
Elf,
Zip,
Text,
Binary,
}
impl FileKind {
pub fn description(self, data: &[u8]) -> String {
match self {
FileKind::Empty => "Empty file".to_owned(),
FileKind::Dex => "Dalvik executable (DEX)".to_owned(),
FileKind::BinaryXml => "Android binary XML".to_owned(),
FileKind::ResourceTable => "Android resource table".to_owned(),
FileKind::Image(fmt) => format!("{fmt} image"),
FileKind::Elf => format!("ELF binary ({})", elf_machine(data)),
FileKind::Zip => "ZIP archive".to_owned(),
FileKind::Text => "Text".to_owned(),
FileKind::Binary => "Binary data".to_owned(),
}
}
}
pub fn detect(name: &str, data: &[u8]) -> FileKind {
let lower = name.to_ascii_lowercase();
if data.is_empty() {
FileKind::Empty
} else if dex::is_dex(data) {
FileKind::Dex
} else if axml::is_binary_xml(data) {
FileKind::BinaryXml
} else if data.len() >= 4 && data[0..4] == [0x02, 0x00, 0x0c, 0x00] {
FileKind::ResourceTable
} else if data.starts_with(b"\x89PNG\r\n\x1a\n") {
FileKind::Image("PNG")
} else if data.starts_with(&[0xff, 0xd8, 0xff]) {
FileKind::Image("JPEG")
} else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
FileKind::Image("GIF")
} else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
FileKind::Image("WebP")
} else if data.starts_with(b"BM") && lower.ends_with(".bmp") {
FileKind::Image("BMP")
} else if data.starts_with(b"\x7fELF") {
FileKind::Elf
} else if data.starts_with(b"PK\x03\x04") {
FileKind::Zip
} else if looks_like_text(data) {
FileKind::Text
} else {
FileKind::Binary
}
}
fn looks_like_text(data: &[u8]) -> bool {
let sample = &data[..data.len().min(64 * 1024)];
if sample.contains(&0) {
return false;
}
let valid = match std::str::from_utf8(sample) {
Ok(_) => true,
// A multi-byte character cut off by the sample boundary is fine.
Err(e) => e.error_len().is_none(),
};
if !valid {
return false;
}
let control = sample
.iter()
.filter(|&&b| b < 0x20 && !matches!(b, b'\n' | b'\r' | b'\t' | 0x0c))
.count();
control * 100 <= sample.len()
}
fn elf_machine(data: &[u8]) -> &'static str {
let machine = data
.get(18..20)
.map(|b| u16::from_le_bytes([b[0], b[1]]))
.unwrap_or(0);
match machine {
0x03 => "x86",
0x28 => "ARM",
0x3e => "x86-64",
0xb7 => "AArch64",
0xf3 => "RISC-V",
_ => "unknown architecture",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects() {
assert_eq!(detect("a", b""), FileKind::Empty);
assert_eq!(detect("a.txt", b"hello\nworld"), FileKind::Text);
assert_eq!(detect("a.bin", &[1, 2, 0, 3]), FileKind::Binary);
assert_eq!(
detect("x.png", b"\x89PNG\r\n\x1a\n...."),
FileKind::Image("PNG")
);
assert_eq!(detect("classes.dex", b"dex\n035\0rest"), FileKind::Dex);
assert_eq!(detect("lib.so", b"\x7fELF"), FileKind::Elf);
}
}
+79
View File
@@ -0,0 +1,79 @@
//! Access flag rendering (`public static final ...`) as used by smali.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Target {
Class,
Field,
Method,
}
const FLAGS: &[(u32, &str, &[Target])] = &[
(
0x1,
"public",
&[Target::Class, Target::Field, Target::Method],
),
(
0x2,
"private",
&[Target::Class, Target::Field, Target::Method],
),
(
0x4,
"protected",
&[Target::Class, Target::Field, Target::Method],
),
(
0x8,
"static",
&[Target::Class, Target::Field, Target::Method],
),
(
0x10,
"final",
&[Target::Class, Target::Field, Target::Method],
),
(0x20, "synchronized", &[Target::Method]),
(0x40, "volatile", &[Target::Field]),
(0x40, "bridge", &[Target::Method]),
(0x80, "transient", &[Target::Field]),
(0x80, "varargs", &[Target::Method]),
(0x100, "native", &[Target::Method]),
(0x200, "interface", &[Target::Class]),
(0x400, "abstract", &[Target::Class, Target::Method]),
(0x800, "strictfp", &[Target::Method]),
(
0x1000,
"synthetic",
&[Target::Class, Target::Field, Target::Method],
),
(0x2000, "annotation", &[Target::Class]),
(0x4000, "enum", &[Target::Class, Target::Field]),
(0x10000, "constructor", &[Target::Method]),
(0x20000, "declared-synchronized", &[Target::Method]),
];
pub fn flags_to_string(flags: u32, target: Target) -> String {
FLAGS
.iter()
.filter(|(bit, _, targets)| flags & bit != 0 && targets.contains(&target))
.map(|(_, name, _)| *name)
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn renders_flags() {
assert_eq!(flags_to_string(0x11, Target::Class), "public final");
assert_eq!(
flags_to_string(0x10001, Target::Method),
"public constructor"
);
assert_eq!(flags_to_string(0x40, Target::Field), "volatile");
assert_eq!(flags_to_string(0x40, Target::Method), "bridge");
}
}
+693
View File
@@ -0,0 +1,693 @@
//! Dalvik bytecode disassembler producing smali (baksmali-style) text.
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::fmt::Write;
use super::javafmt::{double_comment, float_comment, signed_int_or_long};
use super::opcodes::{Format, OpInfo, RefKind, op_info};
use super::smali::escape_string;
use super::values::hex_signed;
use super::{CodeItem, DexFile};
enum RegList {
None,
List(Vec<u16>),
Range { first: u16, count: u16 },
}
struct Insn {
addr: u32,
op: u8,
info: OpInfo,
regs: Vec<u16>,
list: RegList,
literal: i64,
target: i64,
index: u32,
index2: u32,
}
enum Item {
Insn(Insn),
PackedSwitch {
addr: u32,
first_key: i32,
targets: Vec<i32>,
},
SparseSwitch {
addr: u32,
keys: Vec<i32>,
targets: Vec<i32>,
},
ArrayData {
addr: u32,
width: u16,
data: Vec<u8>,
},
Invalid {
addr: u32,
reason: String,
},
}
impl Item {
fn addr(&self) -> u32 {
match self {
Item::Insn(i) => i.addr,
Item::PackedSwitch { addr, .. }
| Item::SparseSwitch { addr, .. }
| Item::ArrayData { addr, .. }
| Item::Invalid { addr, .. } => *addr,
}
}
}
fn i32_at(u: &[u16], k: usize) -> i32 {
(u32::from(u[k]) | (u32::from(u[k + 1]) << 16)) as i32
}
fn decode(insns: &[u16]) -> Vec<Item> {
let mut items = Vec::new();
let mut pc = 0usize;
while pc < insns.len() {
let addr = pc as u32;
let unit = insns[pc];
let op = (unit & 0xff) as u8;
let rest = &insns[pc..];
if op == 0 && (1..=3).contains(&(unit >> 8)) {
match decode_payload(addr, rest) {
Ok((item, size)) => {
items.push(item);
pc += size;
}
Err(reason) => {
items.push(Item::Invalid { addr, reason });
break;
}
}
continue;
}
let info = op_info(op);
let size = info.format.units();
if rest.len() < size {
items.push(Item::Invalid {
addr,
reason: format!("truncated {} instruction", info.name),
});
break;
}
items.push(Item::Insn(decode_insn(addr, op, info, &rest[..size])));
pc += size;
}
items
}
fn decode_payload(addr: u32, u: &[u16]) -> Result<(Item, usize), String> {
let need = |n: usize, what: &str| {
if u.len() < n {
Err(format!("truncated {what} payload"))
} else {
Ok(())
}
};
match u[0] {
0x0100 => {
need(4, "packed-switch")?;
let size = usize::from(u[1]);
let total = 4 + size * 2;
need(total, "packed-switch")?;
let targets = (0..size).map(|i| i32_at(u, 4 + i * 2)).collect();
Ok((
Item::PackedSwitch {
addr,
first_key: i32_at(u, 2),
targets,
},
total,
))
}
0x0200 => {
need(2, "sparse-switch")?;
let size = usize::from(u[1]);
let total = 2 + size * 4;
need(total, "sparse-switch")?;
let keys = (0..size).map(|i| i32_at(u, 2 + i * 2)).collect();
let targets = (0..size).map(|i| i32_at(u, 2 + size * 2 + i * 2)).collect();
Ok((
Item::SparseSwitch {
addr,
keys,
targets,
},
total,
))
}
_ => {
need(4, "array-data")?;
let width = u[1];
let count = i32_at(u, 2) as u32 as usize;
let bytes = usize::from(width)
.checked_mul(count)
.ok_or("array-data payload too large")?;
let total = 4 + bytes.div_ceil(2);
need(total, "array-data")?;
let data = u[4..total]
.iter()
.flat_map(|w| w.to_le_bytes())
.take(bytes)
.collect();
Ok((Item::ArrayData { addr, width, data }, total))
}
}
}
fn decode_insn(addr: u32, op: u8, info: OpInfo, u: &[u16]) -> Insn {
use Format::*;
let aa = u[0] >> 8;
let a = (u[0] >> 8) & 0xf;
let b = u[0] >> 12;
let base = i64::from(addr);
let mut insn = Insn {
addr,
op,
info,
regs: Vec::new(),
list: RegList::None,
literal: 0,
target: 0,
index: 0,
index2: 0,
};
match info.format {
F10x | Unused => {}
F12x => insn.regs = vec![a, b],
F11n => {
insn.regs = vec![a];
insn.literal = i64::from(((b as i8) << 4) >> 4);
}
F11x => insn.regs = vec![aa],
F10t => insn.target = base + i64::from(aa as u8 as i8),
F20t => insn.target = base + i64::from(u[1] as i16),
F22x => insn.regs = vec![aa, u[1]],
F21t => {
insn.regs = vec![aa];
insn.target = base + i64::from(u[1] as i16);
}
F21s => {
insn.regs = vec![aa];
insn.literal = i64::from(u[1] as i16);
}
F21h => {
insn.regs = vec![aa];
let shift = if op == 0x19 { 48 } else { 16 };
insn.literal = i64::from(u[1] as i16) << shift;
}
F21c => {
insn.regs = vec![aa];
insn.index = u32::from(u[1]);
}
F23x => insn.regs = vec![aa, u[1] & 0xff, u[1] >> 8],
F22b => {
insn.regs = vec![aa, u[1] & 0xff];
insn.literal = i64::from((u[1] >> 8) as u8 as i8);
}
F22t => {
insn.regs = vec![a, b];
insn.target = base + i64::from(u[1] as i16);
}
F22s => {
insn.regs = vec![a, b];
insn.literal = i64::from(u[1] as i16);
}
F22c => {
insn.regs = vec![a, b];
insn.index = u32::from(u[1]);
}
F32x => insn.regs = vec![u[1], u[2]],
F30t => insn.target = base + i64::from(i32_at(u, 1)),
F31t => {
insn.regs = vec![aa];
insn.target = base + i64::from(i32_at(u, 1));
}
F31i => {
insn.regs = vec![aa];
insn.literal = i64::from(i32_at(u, 1));
}
F31c => {
insn.regs = vec![aa];
insn.index = i32_at(u, 1) as u32;
}
F35c | F45cc => {
let count = usize::from(b);
let all = [
u[2] & 0xf,
(u[2] >> 4) & 0xf,
(u[2] >> 8) & 0xf,
u[2] >> 12,
a,
];
insn.list = RegList::List(all[..count.min(5)].to_vec());
insn.index = u32::from(u[1]);
if info.format == F45cc {
insn.index2 = u32::from(u[3]);
}
}
F3rc | F4rcc => {
insn.list = RegList::Range {
first: u[2],
count: aa,
};
insn.index = u32::from(u[1]);
if info.format == F4rcc {
insn.index2 = u32::from(u[3]);
}
}
F51l => {
insn.regs = vec![aa];
insn.literal = (u64::from(u[1])
| (u64::from(u[2]) << 16)
| (u64::from(u[3]) << 32)
| (u64::from(u[4]) << 48)) as i64;
}
}
insn
}
fn branch_prefix(op: u8) -> &'static str {
match op {
0x26 => "array",
0x2b => "pswitch_data",
0x2c => "sswitch_data",
0x28..=0x2a => "goto",
_ => "cond",
}
}
/// Collects label references, then numbers them per prefix in address order
/// (`:cond_0`, `:cond_1`, ...), matching baksmali's naming.
#[derive(Default)]
struct Labels {
refs: BTreeMap<&'static str, BTreeSet<i64>>,
names: HashMap<(&'static str, i64), String>,
/// Labels placed before the instruction at an address (excludes try_end).
at: BTreeMap<i64, Vec<String>>,
}
impl Labels {
fn add(&mut self, prefix: &'static str, addr: i64) {
self.refs.entry(prefix).or_default().insert(addr);
}
fn finalize(&mut self) {
for (&prefix, addrs) in &self.refs {
for (i, &addr) in addrs.iter().enumerate() {
let name = format!(":{prefix}_{i:x}");
if prefix != "try_end" {
self.at.entry(addr).or_default().push(name.clone());
}
self.names.insert((prefix, addr), name);
}
}
}
fn name(&self, prefix: &'static str, addr: i64) -> String {
self.names
.get(&(prefix, addr))
.cloned()
.unwrap_or_else(|| format!(":{prefix}_at_{addr:x}"))
}
}
struct Formatter<'a> {
dex: &'a DexFile,
labels: &'a Labels,
param_base: u16,
has_params: bool,
}
impl Formatter<'_> {
fn reg(&self, r: u16) -> String {
if self.has_params && r >= self.param_base {
format!("p{}", r - self.param_base)
} else {
format!("v{r}")
}
}
fn reference(&self, kind: RefKind, idx: u32) -> String {
match kind {
RefKind::None => String::new(),
RefKind::String => format!("\"{}\"", escape_string(&self.dex.string(idx))),
RefKind::Type => self.dex.type_descriptor(idx).into_owned(),
RefKind::Field => self.dex.field_ref(idx),
RefKind::Method => self.dex.method_ref(idx),
RefKind::Proto => self.dex.proto_descriptor(idx),
RefKind::CallSite => format!("call_site_{idx}"),
RefKind::MethodHandle => format!("method_handle@{idx}"),
}
}
fn reg_list(&self, list: &RegList) -> String {
match list {
RegList::None => "{}".to_owned(),
RegList::List(regs) => {
let regs: Vec<String> = regs.iter().map(|&r| self.reg(r)).collect();
format!("{{{}}}", regs.join(", "))
}
RegList::Range { count: 0, .. } => "{}".to_owned(),
RegList::Range { first, count } => {
let last = first.saturating_add(count - 1);
format!("{{{} .. {}}}", self.reg(*first), self.reg(last))
}
}
}
fn insn(&self, i: &Insn) -> String {
use Format::*;
let name = i.info.name;
let regs: Vec<String> = i.regs.iter().map(|&r| self.reg(r)).collect();
let lit = || hex_signed(i.literal);
let target = || self.labels.name(branch_prefix(i.op), i.target);
match i.info.format {
F10x => name.to_owned(),
Unused => format!("# unknown opcode 0x{:02x}", i.op),
F12x | F22x | F32x | F23x | F11x => format!("{name} {}", regs.join(", ")),
F11n | F22b | F22s => format!("{name} {}, {}", regs.join(", "), lit()),
F21s | F21h | F31i | F51l => {
let comment = if matches!(i.op, 0x16..=0x19) {
double_comment(i.literal)
} else {
float_comment(i.literal as i32)
};
format!(
"{name} {}, {}{}",
regs.join(", "),
signed_int_or_long(i.literal),
comment.unwrap_or_default()
)
}
F10t | F20t | F30t => format!("{name} {}", target()),
F21t | F22t | F31t => format!("{name} {}, {}", regs.join(", "), target()),
F21c | F31c | F22c => format!(
"{name} {}, {}",
regs.join(", "),
self.reference(i.info.ref_kind, i.index)
),
F35c | F3rc => format!(
"{name} {}, {}",
self.reg_list(&i.list),
self.reference(i.info.ref_kind, i.index)
),
F45cc | F4rcc => format!(
"{name} {}, {}, {}",
self.reg_list(&i.list),
self.reference(RefKind::Method, i.index),
self.reference(RefKind::Proto, i.index2)
),
}
}
}
fn array_element(chunk: &[u8]) -> String {
let mut buf = [0u8; 8];
buf[..chunk.len()].copy_from_slice(chunk);
let raw = u64::from_le_bytes(buf);
let shift = 64 - 8 * chunk.len() as u32;
let value = ((raw << shift) as i64) >> shift;
match chunk.len() {
1 => format!("{}t", hex_signed(value)),
2 => format!("{}s", hex_signed(value)),
4 => format!(
"{}{}",
hex_signed(value),
float_comment(value as i32).unwrap_or_default()
),
8 => format!(
"{}{}",
signed_int_or_long(value),
double_comment(value).unwrap_or_default()
),
_ => signed_int_or_long(value),
}
}
fn is_branch(format: Format) -> bool {
matches!(
format,
Format::F10t | Format::F20t | Format::F30t | Format::F21t | Format::F22t | Format::F31t
)
}
/// Field indices written by `sput*` instructions in the given code.
pub fn static_puts(code: &CodeItem) -> Vec<u32> {
decode(&code.insns)
.iter()
.filter_map(|item| match item {
Item::Insn(i) if (0x67..=0x6d).contains(&i.op) => Some(i.index),
_ => None,
})
.collect()
}
/// Appends the smali body (`.locals`, instructions, labels, try/catch) of a
/// method to `out`. Each line is prefixed with `indent`.
pub fn disassemble(dex: &DexFile, code: &CodeItem, out: &mut String, indent: &str) {
let items = decode(&code.insns);
let mut labels = Labels::default();
let mut payload_base: HashMap<i64, i64> = HashMap::new();
for item in &items {
if let Item::Insn(i) = item {
if is_branch(i.info.format) {
labels.add(branch_prefix(i.op), i.target);
if matches!(i.op, 0x2b | 0x2c) {
payload_base.insert(i.target, i64::from(i.addr));
}
}
}
}
let switch_target = |item: &Item| -> Option<(&'static str, i64, Vec<i64>)> {
let (prefix, targets) = match item {
Item::PackedSwitch { targets, .. } => ("pswitch", targets),
Item::SparseSwitch { targets, .. } => ("sswitch", targets),
_ => return None,
};
let addr = i64::from(item.addr());
let base = payload_base.get(&addr).copied().unwrap_or(addr);
Some((
prefix,
base,
targets.iter().map(|&t| base + i64::from(t)).collect(),
))
};
for item in &items {
if let Some((prefix, _, targets)) = switch_target(item) {
for t in targets {
labels.add(prefix, t);
}
}
}
for t in &code.tries {
let start = i64::from(t.start_addr);
labels.add("try_start", start);
labels.add("try_end", start + i64::from(t.insn_count));
for &(_, handler) in &t.handlers {
labels.add("catch", i64::from(handler));
}
if let Some(handler) = t.catch_all {
labels.add("catchall", i64::from(handler));
}
}
labels.finalize();
// try_end labels and .catch directives are emitted right after the last
// instruction covered by the try block, as baksmali does.
let mut catches: BTreeMap<i64, Vec<String>> = BTreeMap::new();
for t in &code.tries {
let start = i64::from(t.start_addr);
let end = start + i64::from(t.insn_count);
let start_label = labels.name("try_start", start);
let end_label = labels.name("try_end", end);
let entry = catches.entry(end).or_default();
if !entry.contains(&end_label) {
entry.push(end_label.clone());
}
for &(type_idx, handler) in &t.handlers {
entry.push(format!(
".catch {} {{{start_label} .. {end_label}}} {}",
dex.type_descriptor(type_idx),
labels.name("catch", i64::from(handler))
));
}
if let Some(handler) = t.catch_all {
entry.push(format!(
".catchall {{{start_label} .. {end_label}}} {}",
labels.name("catchall", i64::from(handler))
));
}
}
let fmt = Formatter {
dex,
labels: &labels,
param_base: code.registers_size.saturating_sub(code.ins_size),
has_params: code.ins_size > 0 && code.ins_size <= code.registers_size,
};
if code.ins_size <= code.registers_size {
let _ = writeln!(
out,
"{indent}.locals {}",
code.registers_size - code.ins_size
);
} else {
let _ = writeln!(out, "{indent}.registers {}", code.registers_size);
}
let emit_catches = |out: &mut String, addr: i64| {
if let Some(lines) = catches.get(&addr) {
for line in lines {
let _ = writeln!(out, "{indent}{line}");
}
}
};
let mut emitted: BTreeSet<i64> = BTreeSet::new();
for item in &items {
let addr = i64::from(item.addr());
emit_catches(out, addr);
out.push('\n');
if let Some(names) = labels.at.get(&addr) {
for name in names {
let _ = writeln!(out, "{indent}{name}");
}
emitted.insert(addr);
}
match item {
Item::Insn(i) => {
let _ = writeln!(out, "{indent}{}", fmt.insn(i));
}
Item::PackedSwitch { first_key, .. } => {
let (prefix, _, targets) = switch_target(item).expect("switch payload");
let _ = writeln!(
out,
"{indent}.packed-switch {}",
hex_signed(i64::from(*first_key))
);
for t in targets {
let _ = writeln!(out, "{indent} {}", labels.name(prefix, t));
}
let _ = writeln!(out, "{indent}.end packed-switch");
}
Item::SparseSwitch { keys, .. } => {
let (prefix, _, targets) = switch_target(item).expect("switch payload");
let _ = writeln!(out, "{indent}.sparse-switch");
for (&k, t) in keys.iter().zip(targets) {
let _ = writeln!(
out,
"{indent} {} -> {}",
hex_signed(i64::from(k)),
labels.name(prefix, t)
);
}
let _ = writeln!(out, "{indent}.end sparse-switch");
}
Item::ArrayData { width, data, .. } => {
let _ = writeln!(out, "{indent}.array-data {width}");
if *width > 0 {
for chunk in data.chunks(usize::from(*width)) {
if chunk.len() <= 8 {
let _ = writeln!(out, "{indent} {}", array_element(chunk));
}
}
}
let _ = writeln!(out, "{indent}.end array-data");
}
Item::Invalid { reason, .. } => {
let _ = writeln!(out, "{indent}# ERROR at 0x{addr:x}: {reason}");
}
}
}
let end = code.insns.len() as i64;
for (&addr, _) in catches.range(end..) {
emit_catches(out, addr);
}
let dangling: Vec<String> = labels
.at
.iter()
.filter(|(addr, _)| !emitted.contains(addr))
.flat_map(|(addr, names)| names.iter().map(move |n| format!("{n} (0x{addr:x})")))
.collect();
if !dangling.is_empty() {
let _ = writeln!(
out,
"\n{indent}# WARNING: labels not on an instruction boundary: {}",
dangling.join(", ")
);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn decode_one(units: &[u16]) -> Insn {
match decode(units).into_iter().next().unwrap() {
Item::Insn(i) => i,
_ => panic!("expected instruction"),
}
}
#[test]
fn decodes_formats() {
// const/4 v1, -0x1
let i = decode_one(&[0xf112]);
assert_eq!((i.regs.as_slice(), i.literal), (&[1u16][..], -1));
// const/high16 v0, 0x7f010000
let i = decode_one(&[0x0015, 0x7f01]);
assert_eq!(i.literal, 0x7f01_0000);
// const-wide/high16 v0, 0x4000000000000000L
let i = decode_one(&[0x0019, 0x4000]);
assert_eq!(i.literal, 0x4000_0000_0000_0000);
// add-int/lit8 v0, v1, -0x2
let i = decode_one(&[0x00d8, 0xfe01]);
assert_eq!((i.regs.as_slice(), i.literal), (&[0u16, 1][..], -2));
// invoke-virtual {v1, v2, v3}, method@5
let i = decode_one(&[0x306e, 0x0005, 0x0321]);
assert!(matches!(&i.list, RegList::List(r) if r == &[1, 2, 3]));
assert_eq!(i.index, 5);
// goto -0x2 from address 0
let i = decode_one(&[0xfe28]);
assert_eq!(i.target, -2);
}
#[test]
fn decodes_payloads_and_truncation() {
// packed-switch v0, +3 ; packed-switch payload (1 target)
let items = decode(&[
0x002b, 0x0003, 0x0000, 0x0100, 0x0001, 0x0005, 0x0000, 0x0010, 0x0000,
]);
assert!(matches!(items[1], Item::PackedSwitch { first_key: 5, .. }));
// truncated const-wide
let items = decode(&[0x0018, 0x0001]);
assert!(matches!(items[0], Item::Invalid { .. }));
// truncated array payload
let items = decode(&[0x0300, 0x0004, 0xffff, 0xffff]);
assert!(matches!(items[0], Item::Invalid { .. }));
}
#[test]
fn array_elements() {
assert_eq!(array_element(&[0xff]), "-0x1t");
assert_eq!(array_element(&[0x01, 0x00]), "0x1s");
assert_eq!(array_element(&[0x10, 0, 0, 0]), "0x10");
assert_eq!(array_element(&[0, 0, 0x80, 0x3f]), "0x3f800000 # 1.0f");
assert_eq!(
array_element(&[0, 0, 0, 0, 0, 0, 0, 0x40]),
"0x4000000000000000L # 2.0"
);
}
}
+289
View File
@@ -0,0 +1,289 @@
//! Java-compatible number formatting, so that the disassembly matches what
//! baksmali (and therefore apktool) produces.
/// Shortest round-trip digits and decimal exponent of a finite, non-zero value,
/// e.g. 1234.5 -> ("12345", 3).
fn digits_and_exponent(scientific: &str) -> (String, i32) {
let (mantissa, exp) = scientific.split_once('e').unwrap_or((scientific, "0"));
let digits: String = mantissa.chars().filter(|c| c.is_ascii_digit()).collect();
(digits, exp.parse().unwrap_or(0))
}
/// Digits as Java's `Float/Double.toString` picks them: the shortest length
/// that round-trips (at least two digits), and among those the decimal
/// closest to the exact binary value, ties going to the even digit. E.g.
/// `Double.MIN_VALUE` is `4.9E-324`, and 3567412.25f is `3567412.2`.
fn java_digits(
shortest: &str,
rounded: impl Fn(usize) -> String,
roundtrips: impl Fn(&str) -> bool,
) -> (String, i32) {
let (digits, exp) = digits_and_exponent(shortest);
let precision = digits.len().max(2) - 1;
let candidate = rounded(precision);
if roundtrips(&candidate) {
digits_and_exponent(&candidate)
} else {
(digits, exp)
}
}
/// Java's `Float.toString` / `Double.toString` given the shortest digits.
fn java_to_string(negative: bool, digits: &str, exp: i32, magnitude: f64) -> String {
let sign = if negative { "-" } else { "" };
let digits = digits.trim_end_matches('0');
let digits = if digits.is_empty() { "0" } else { digits };
if (1e-3..1e7).contains(&magnitude) {
if exp >= 0 {
let int_len = exp as usize + 1;
let int_part: String = if digits.len() >= int_len {
digits[..int_len].to_owned()
} else {
format!("{digits}{}", "0".repeat(int_len - digits.len()))
};
let frac = digits
.get(int_len..)
.filter(|f| !f.is_empty())
.unwrap_or("0");
format!("{sign}{int_part}.{frac}")
} else {
format!("{sign}0.{}{digits}", "0".repeat((-exp - 1) as usize))
}
} else {
let frac = if digits.len() > 1 { &digits[1..] } else { "0" };
format!("{sign}{}.{frac}E{exp}", &digits[..1])
}
}
pub fn float_to_string(v: f32) -> String {
if v.is_nan() {
return "NaN".to_owned();
}
if v.is_infinite() {
return if v < 0.0 { "-Infinity" } else { "Infinity" }.to_owned();
}
if v == 0.0 {
return if v.is_sign_negative() { "-0.0" } else { "0.0" }.to_owned();
}
let x = v.abs();
let (digits, exp) = java_digits(
&format!("{x:e}"),
|p| format!("{x:.p$e}"),
|s| s.parse::<f32>() == Ok(x),
);
java_to_string(v < 0.0, &digits, exp, f64::from(x))
}
pub fn double_to_string(v: f64) -> String {
if v.is_nan() {
return "NaN".to_owned();
}
if v.is_infinite() {
return if v < 0.0 { "-Infinity" } else { "Infinity" }.to_owned();
}
if v == 0.0 {
return if v.is_sign_negative() { "-0.0" } else { "0.0" }.to_owned();
}
let x = v.abs();
let (digits, exp) = java_digits(
&format!("{x:e}"),
|p| format!("{x:.p$e}"),
|s| s.parse::<f64>() == Ok(x),
);
java_to_string(v < 0.0, &digits, exp, x)
}
/// `new DecimalFormat("0.####################E0").format(...)`
fn decimal_format(negative: bool, digits: &str, exp: i32) -> String {
let sign = if negative { "-" } else { "" };
let digits = digits.trim_end_matches('0');
if digits.is_empty() {
return format!("{sign}0E0");
}
if digits.len() == 1 {
format!("{sign}{digits}E{exp}")
} else {
format!("{sign}{}.{}E{exp}", &digits[..1], &digits[1..])
}
}
fn decimal_format_integer(v: i128) -> String {
let s = v.unsigned_abs().to_string();
decimal_format(v < 0, &s, s.len() as i32 - 1)
}
fn decimal_format_double(v: f64) -> String {
if v == 0.0 {
return decimal_format(v.is_sign_negative(), "0", 0);
}
let (digits, exp) = digits_and_exponent(&format!("{:e}", v.abs()));
decimal_format(v < 0.0, &digits, exp)
}
/// Java `String.indexOf` semantics: -1 when not found.
fn index_of(s: &str, pat: &str) -> i64 {
s.find(pat).map_or(-1, |i| i as i64)
}
fn strip_imprecision(s: String) -> String {
let decimal_point = index_of(&s, ".");
let exponent = index_of(&s, "E");
for pat in ["000", "999"] {
let at = index_of(&s, pat);
if at > decimal_point && at < exponent {
return format!("{}{}", &s[..at as usize], &s[exponent as usize..]);
}
}
s
}
pub fn is_likely_float(value: i32) -> bool {
const CANONICAL_NAN: i32 = 0x7fc0_0000;
const MAX: i32 = 0x7f7f_ffff;
let pi = std::f32::consts::PI.to_bits() as i32;
let e = std::f32::consts::E.to_bits() as i32;
if [CANONICAL_NAN, MAX, pi, e].contains(&value) {
return true;
}
if value == i32::MAX || value == i32::MIN {
return false;
}
let package_id = value >> 24;
let resource_type = (value >> 16) & 0xff;
let resource_id = value & 0xffff;
if (package_id == 0x7f || package_id == 1) && resource_type < 0x1f && resource_id < 0xfff {
return false;
}
let float = f32::from_bits(value as u32);
if float.is_nan() {
return false;
}
let as_int = decimal_format_integer(i128::from(value));
let as_float = strip_imprecision(decimal_format_double(f64::from(float)));
as_float.len() < as_int.len()
}
pub fn is_likely_double(value: i64) -> bool {
const CANONICAL_NAN: i64 = 0x7ff8_0000_0000_0000;
const MAX: i64 = 0x7fef_ffff_ffff_ffff;
let pi = std::f64::consts::PI.to_bits() as i64;
let e = std::f64::consts::E.to_bits() as i64;
if [CANONICAL_NAN, MAX, pi, e].contains(&value) {
return true;
}
if value == i64::MAX || value == i64::MIN {
return false;
}
let double = f64::from_bits(value as u64);
if double.is_nan() {
return false;
}
let as_long = decimal_format_integer(i128::from(value));
let as_double = strip_imprecision(decimal_format_double(double));
as_double.len() < as_long.len()
}
/// baksmali's ` # 1.0f` comment for literals that are likely floats.
pub fn float_comment(value: i32) -> Option<String> {
if !is_likely_float(value) {
return None;
}
let f = f32::from_bits(value as u32);
let text = if f == f32::INFINITY {
"Float.POSITIVE_INFINITY".to_owned()
} else if f == f32::NEG_INFINITY {
"Float.NEGATIVE_INFINITY".to_owned()
} else if f.is_nan() {
"Float.NaN".to_owned()
} else if f == f32::MAX {
"Float.MAX_VALUE".to_owned()
} else if f == std::f32::consts::PI {
"(float)Math.PI".to_owned()
} else if f == std::f32::consts::E {
"(float)Math.E".to_owned()
} else {
format!("{}f", float_to_string(f))
};
Some(format!(" # {text}"))
}
/// baksmali's ` # 2.0` comment for wide literals that are likely doubles.
pub fn double_comment(value: i64) -> Option<String> {
if !is_likely_double(value) {
return None;
}
let d = f64::from_bits(value as u64);
let text = if d == f64::INFINITY {
"Double.POSITIVE_INFINITY".to_owned()
} else if d == f64::NEG_INFINITY {
"Double.NEGATIVE_INFINITY".to_owned()
} else if d.is_nan() {
"Double.NaN".to_owned()
} else if d == f64::MAX {
"Double.MAX_VALUE".to_owned()
} else if d == std::f64::consts::PI {
"Math.PI".to_owned()
} else if d == std::f64::consts::E {
"Math.E".to_owned()
} else {
double_to_string(d)
};
Some(format!(" # {text}"))
}
/// baksmali's `writeSignedIntOrLongTo`: hex with an `L` suffix only when the
/// value does not fit in an `int`.
pub fn signed_int_or_long(v: i64) -> String {
let suffix = if i32::try_from(v).is_err() { "L" } else { "" };
format!("{}{suffix}", super::values::hex_signed(v))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn java_strings() {
assert_eq!(float_to_string(1.0), "1.0");
assert_eq!(float_to_string(2.0009763), "2.0009763");
assert_eq!(float_to_string(0.001), "0.001");
assert_eq!(float_to_string(1e7), "1.0E7");
assert_eq!(float_to_string(1.5e-4), "1.5E-4");
assert_eq!(float_to_string(-123.25), "-123.25");
assert_eq!(double_to_string(2.0), "2.0");
assert_eq!(double_to_string(35.0), "35.0");
assert_eq!(double_to_string(1e21), "1.0E21");
assert_eq!(double_to_string(100.0), "100.0");
assert_eq!(double_to_string(f64::from_bits(1)), "4.9E-324");
assert_eq!(float_to_string(f32::from_bits(1)), "1.4E-45");
assert_eq!(float_to_string(f32::from_bits(0x4a59_bcd1)), "3567412.2");
assert_eq!(float_to_string(0.1), "0.1");
assert_eq!(double_to_string(0.1 + 0.2), "0.30000000000000004");
}
#[test]
fn decimal_format_matches_java() {
assert_eq!(decimal_format_integer(1000), "1E3");
assert_eq!(decimal_format_integer(0), "0E0");
assert_eq!(decimal_format_integer(-15), "-1.5E1");
assert_eq!(decimal_format_double(2.0), "2E0");
}
#[test]
fn likely_values() {
assert!(is_likely_float(0x3f80_0000)); // 1.0f
assert!(is_likely_float(0x4000_0fff)); // 2.0009763f
assert!(!is_likely_float(0x7f01_0001)); // resource id
assert!(!is_likely_float(1000));
assert!(!is_likely_float(0));
assert!(is_likely_double(0x4000_0000_0000_0000)); // 2.0
assert!(!is_likely_double(0));
assert_eq!(float_comment(0x3f80_0000).unwrap(), " # 1.0f");
assert_eq!(double_comment(0x4041_8000_0000_0000).unwrap(), " # 35.0");
assert_eq!(signed_int_or_long(0x7a120), "0x7a120");
assert_eq!(
signed_int_or_long(0x4000_0000_0000_0000),
"0x4000000000000000L"
);
}
}
+695
View File
@@ -0,0 +1,695 @@
//! Parser for Dalvik Executable (DEX) files.
//!
//! Format reference: <https://source.android.com/docs/core/runtime/dex-format>
pub mod access;
pub mod disasm;
pub mod javafmt;
pub mod opcodes;
pub mod smali;
pub mod values;
use std::borrow::Cow;
use std::collections::HashMap;
use std::sync::Arc;
use crate::formats::reader::{Reader, Result, err};
pub const NO_INDEX: u32 = 0xffff_ffff;
const HEADER_SIZE: usize = 0x70;
const ENDIAN_CONSTANT: u32 = 0x1234_5678;
#[derive(Debug, Clone)]
pub struct Header {
pub version: String,
pub checksum: u32,
pub signature: [u8; 20],
pub file_size: u32,
pub header_size: u32,
pub endian_tag: u32,
pub map_off: u32,
pub string_ids: Section,
pub type_ids: Section,
pub proto_ids: Section,
pub field_ids: Section,
pub method_ids: Section,
pub class_defs: Section,
pub data: Section,
}
#[derive(Debug, Clone, Copy)]
pub struct Section {
pub size: u32,
pub off: u32,
}
#[derive(Debug, Clone)]
pub struct ProtoId {
pub shorty: u32,
pub return_type: u32,
pub parameters: Vec<u32>,
}
#[derive(Debug, Clone, Copy)]
pub struct FieldId {
pub class: u32,
pub type_: u32,
pub name: u32,
}
#[derive(Debug, Clone, Copy)]
pub struct MethodId {
pub class: u32,
pub proto: u32,
pub name: u32,
}
#[derive(Debug, Clone, Copy)]
pub struct ClassDef {
pub class_idx: u32,
pub access_flags: u32,
pub superclass: Option<u32>,
pub interfaces_off: u32,
pub source_file: Option<u32>,
pub annotations_off: u32,
pub class_data_off: u32,
pub static_values_off: u32,
}
#[derive(Debug, Clone, Copy)]
pub struct EncodedField {
pub field_idx: u32,
pub access_flags: u32,
}
#[derive(Debug, Clone, Copy)]
pub struct EncodedMethod {
pub method_idx: u32,
pub access_flags: u32,
pub code_off: u32,
}
#[derive(Debug, Clone, Default)]
pub struct ClassData {
pub static_fields: Vec<EncodedField>,
pub instance_fields: Vec<EncodedField>,
pub direct_methods: Vec<EncodedMethod>,
pub virtual_methods: Vec<EncodedMethod>,
}
#[derive(Debug, Clone)]
pub struct CodeItem {
pub registers_size: u16,
pub ins_size: u16,
pub outs_size: u16,
pub debug_info_off: u32,
pub insns: Vec<u16>,
pub tries: Vec<TryItem>,
}
#[derive(Debug, Clone)]
pub struct TryItem {
pub start_addr: u32,
pub insn_count: u16,
pub handlers: Vec<(u32, u32)>,
pub catch_all: Option<u32>,
}
pub struct DexFile {
data: Arc<[u8]>,
pub header: Header,
strings: Vec<Result<Arc<str>>>,
types: Vec<u32>,
protos: Vec<ProtoId>,
fields: Vec<FieldId>,
methods: Vec<MethodId>,
pub class_defs: Vec<ClassDef>,
/// Non-fatal problems found while parsing (often a sign of tampering).
pub warnings: Vec<String>,
}
impl DexFile {
pub fn parse(data: Arc<[u8]>) -> Result<Self> {
let header = parse_header(&data)?;
let mut warnings = Vec::new();
if header.endian_tag != ENDIAN_CONSTANT {
warnings.push(format!(
"unexpected endian tag 0x{:08x} (big-endian DEX files are not supported)",
header.endian_tag
));
}
if header.file_size as usize != data.len() {
warnings.push(format!(
"header file_size {} does not match actual size {}",
header.file_size,
data.len()
));
}
let computed = adler32(data.get(12..).unwrap_or_default());
if computed != header.checksum {
warnings.push(format!(
"checksum mismatch: header 0x{:08x}, computed 0x{computed:08x}",
header.checksum
));
}
// Overlapping or shared string offsets are a known way to make parsers
// do quadratic work, so decoding is cached per offset and the total
// number of scanned bytes is bounded by the file size.
let mut budget = data.len().saturating_mul(2);
let mut by_offset: HashMap<u32, Result<Arc<str>>> = HashMap::new();
let strings = table(&data, header.string_ids, 4, "string_ids", |r| {
let off = r.u32()?;
Ok(by_offset
.entry(off)
.or_insert_with(|| read_string_data(&data, off as usize, &mut budget))
.clone())
})?;
let types = table(&data, header.type_ids, 4, "type_ids", |r| r.u32())?;
let protos = table(&data, header.proto_ids, 12, "proto_ids", |r| {
let shorty = r.u32()?;
let return_type = r.u32()?;
let parameters_off = r.u32()?;
let parameters = read_type_list(&data, parameters_off).unwrap_or_default();
Ok(ProtoId {
shorty,
return_type,
parameters,
})
})?;
let fields = table(&data, header.field_ids, 8, "field_ids", |r| {
Ok(FieldId {
class: u32::from(r.u16()?),
type_: u32::from(r.u16()?),
name: r.u32()?,
})
})?;
let methods = table(&data, header.method_ids, 8, "method_ids", |r| {
Ok(MethodId {
class: u32::from(r.u16()?),
proto: u32::from(r.u16()?),
name: r.u32()?,
})
})?;
let class_defs = table(&data, header.class_defs, 32, "class_defs", |r| {
let opt = |v: u32| (v != NO_INDEX).then_some(v);
Ok(ClassDef {
class_idx: r.u32()?,
access_flags: r.u32()?,
superclass: opt(r.u32()?),
interfaces_off: r.u32()?,
source_file: opt(r.u32()?),
annotations_off: r.u32()?,
class_data_off: r.u32()?,
static_values_off: r.u32()?,
})
})?;
let bad_strings = strings.iter().filter(|s| s.is_err()).count();
if bad_strings > 0 {
warnings.push(format!("{bad_strings} string(s) could not be decoded"));
}
Ok(Self {
data,
header,
strings,
types,
protos,
fields,
methods,
class_defs,
warnings,
})
}
pub fn data(&self) -> &[u8] {
&self.data
}
pub fn string_count(&self) -> usize {
self.strings.len()
}
pub fn type_count(&self) -> usize {
self.types.len()
}
pub fn method_count(&self) -> usize {
self.methods.len()
}
pub fn field_count(&self) -> usize {
self.fields.len()
}
pub fn string(&self, idx: u32) -> Cow<'_, str> {
match self.strings.get(idx as usize) {
Some(Ok(s)) => Cow::Borrowed(s),
Some(Err(_)) => Cow::Owned(format!("<invalid string@{idx}>")),
None => Cow::Owned(format!("<string@{idx} out of range>")),
}
}
pub fn type_descriptor(&self, idx: u32) -> Cow<'_, str> {
match self.types.get(idx as usize) {
Some(&s) => self.string(s),
None => Cow::Owned(format!("<type@{idx} out of range>")),
}
}
pub fn proto_descriptor(&self, idx: u32) -> String {
match self.protos.get(idx as usize) {
Some(proto) => {
let mut out = String::from("(");
for &p in &proto.parameters {
out.push_str(&self.type_descriptor(p));
}
out.push(')');
out.push_str(&self.type_descriptor(proto.return_type));
out
}
None => format!("<proto@{idx} out of range>"),
}
}
pub fn field(&self, idx: u32) -> Option<&FieldId> {
self.fields.get(idx as usize)
}
pub fn method(&self, idx: u32) -> Option<&MethodId> {
self.methods.get(idx as usize)
}
pub fn proto(&self, idx: u32) -> Option<&ProtoId> {
self.protos.get(idx as usize)
}
/// `name:Type`
pub fn field_name_and_type(&self, idx: u32) -> String {
match self.field(idx) {
Some(f) => format!("{}:{}", self.string(f.name), self.type_descriptor(f.type_)),
None => format!("<field@{idx} out of range>"),
}
}
/// `Lclass;->name:Type`
pub fn field_ref(&self, idx: u32) -> String {
match self.field(idx) {
Some(f) => format!(
"{}->{}",
self.type_descriptor(f.class),
self.field_name_and_type(idx)
),
None => format!("<field@{idx} out of range>"),
}
}
/// `name(Params)Return`
pub fn method_name_and_proto(&self, idx: u32) -> String {
match self.method(idx) {
Some(m) => format!("{}{}", self.string(m.name), self.proto_descriptor(m.proto)),
None => format!("<method@{idx} out of range>"),
}
}
/// `Lclass;->name(Params)Return`
pub fn method_ref(&self, idx: u32) -> String {
match self.method(idx) {
Some(m) => format!(
"{}->{}",
self.type_descriptor(m.class),
self.method_name_and_proto(idx)
),
None => format!("<method@{idx} out of range>"),
}
}
pub fn class_descriptor(&self, class: &ClassDef) -> Cow<'_, str> {
self.type_descriptor(class.class_idx)
}
pub fn interfaces(&self, class: &ClassDef) -> Result<Vec<u32>> {
read_type_list(&self.data, class.interfaces_off)
}
pub fn class_data(&self, class: &ClassDef) -> Result<ClassData> {
if class.class_data_off == 0 {
return Ok(ClassData::default());
}
let mut r = Reader::at(&self.data, class.class_data_off as usize)?;
let static_size = r.uleb128()?;
let instance_size = r.uleb128()?;
let direct_size = r.uleb128()?;
let virtual_size = r.uleb128()?;
let read_fields = |r: &mut Reader, n: u32| -> Result<Vec<EncodedField>> {
let mut out = Vec::with_capacity((n as usize).min(r.remaining() / 2));
let mut idx = 0u32;
for _ in 0..n {
idx = idx.wrapping_add(r.uleb128()?);
out.push(EncodedField {
field_idx: idx,
access_flags: r.uleb128()?,
});
}
Ok(out)
};
let read_methods = |r: &mut Reader, n: u32| -> Result<Vec<EncodedMethod>> {
let mut out = Vec::with_capacity((n as usize).min(r.remaining() / 3));
let mut idx = 0u32;
for _ in 0..n {
idx = idx.wrapping_add(r.uleb128()?);
out.push(EncodedMethod {
method_idx: idx,
access_flags: r.uleb128()?,
code_off: r.uleb128()?,
});
}
Ok(out)
};
Ok(ClassData {
static_fields: read_fields(&mut r, static_size)?,
instance_fields: read_fields(&mut r, instance_size)?,
direct_methods: read_methods(&mut r, direct_size)?,
virtual_methods: read_methods(&mut r, virtual_size)?,
})
}
pub fn code_item(&self, off: u32) -> Result<CodeItem> {
let mut r = Reader::at(&self.data, off as usize)?;
let registers_size = r.u16()?;
let ins_size = r.u16()?;
let outs_size = r.u16()?;
let tries_size = r.u16()?;
let debug_info_off = r.u32()?;
let insns_size = r.u32()? as usize;
if insns_size.saturating_mul(2) > r.remaining() {
return err(format!(
"code item at 0x{off:x} claims {insns_size} code units, exceeding the file"
));
}
let mut insns = Vec::with_capacity(insns_size);
for _ in 0..insns_size {
insns.push(r.u16()?);
}
let mut tries = Vec::with_capacity(usize::from(tries_size));
if tries_size > 0 {
if insns_size % 2 == 1 {
r.skip(2)?;
}
let mut raw = Vec::with_capacity(usize::from(tries_size));
for _ in 0..tries_size {
raw.push((r.u32()?, r.u16()?, r.u16()?));
}
let handlers_base = r.pos();
for (start_addr, insn_count, handler_off) in raw {
let mut h = Reader::at(&self.data, handlers_base + usize::from(handler_off))?;
let size = h.sleb128()?;
let mut handlers = Vec::new();
for _ in 0..size.unsigned_abs().min(0xffff) {
handlers.push((h.uleb128()?, h.uleb128()?));
}
let catch_all = if size <= 0 { Some(h.uleb128()?) } else { None };
tries.push(TryItem {
start_addr,
insn_count,
handlers,
catch_all,
});
}
}
Ok(CodeItem {
registers_size,
ins_size,
outs_size,
debug_info_off,
insns,
tries,
})
}
}
fn parse_header(data: &[u8]) -> Result<Header> {
if data.len() < HEADER_SIZE {
return err("file is too small to be a DEX file");
}
let magic = &data[..8];
if &magic[..4] != b"dex\n" || magic[7] != 0 {
return err("missing DEX magic");
}
let version = String::from_utf8_lossy(&magic[4..7]).into_owned();
let mut r = Reader::at(data, 8)?;
let checksum = r.u32()?;
let mut signature = [0u8; 20];
signature.copy_from_slice(r.bytes(20)?);
let file_size = r.u32()?;
let header_size = r.u32()?;
let endian_tag = r.u32()?;
let _link_size = r.u32()?;
let _link_off = r.u32()?;
let map_off = r.u32()?;
let mut section = || -> Result<Section> {
Ok(Section {
size: r.u32()?,
off: r.u32()?,
})
};
Ok(Header {
version,
checksum,
signature,
file_size,
header_size,
endian_tag,
map_off,
string_ids: section()?,
type_ids: section()?,
proto_ids: section()?,
field_ids: section()?,
method_ids: section()?,
class_defs: section()?,
data: section()?,
})
}
fn table<T>(
data: &[u8],
section: Section,
item_size: usize,
name: &str,
mut parse: impl FnMut(&mut Reader) -> Result<T>,
) -> Result<Vec<T>> {
let count = section.size as usize;
if count == 0 {
return Ok(Vec::new());
}
let end = (section.off as usize).saturating_add(count.saturating_mul(item_size));
if end > data.len() {
return err(format!(
"{name} table ({count} entries at 0x{:x}) exceeds the file",
section.off
));
}
let mut r = Reader::at(data, section.off as usize)?;
let mut out = Vec::with_capacity(count);
for _ in 0..count {
out.push(parse(&mut r)?);
}
Ok(out)
}
fn read_type_list(data: &[u8], off: u32) -> Result<Vec<u32>> {
if off == 0 {
return Ok(Vec::new());
}
let mut r = Reader::at(data, off as usize)?;
let size = r.u32()? as usize;
if size.saturating_mul(2) > r.remaining() {
return err(format!("type list at 0x{off:x} exceeds the file"));
}
(0..size).map(|_| r.u16().map(u32::from)).collect()
}
fn read_string_data(data: &[u8], off: usize, budget: &mut usize) -> Result<Arc<str>> {
let mut r = Reader::at(data, off)?;
let utf16_size = r.uleb128()? as usize;
let start = r.pos();
// MUTF-8 needs at most three bytes per UTF-16 unit, plus the terminator.
let max_len = utf16_size.saturating_mul(3).saturating_add(1);
let window = max_len.min(data.len() - start);
let scan = window.min(*budget);
match data[start..start + scan].iter().position(|&b| b == 0) {
Some(len) => {
*budget -= len + 1;
decode_mutf8(&data[start..start + len]).map(Arc::from)
}
None => {
*budget -= scan;
if scan < window {
err("string data budget exhausted (overlapping strings)")
} else {
err(format!("unterminated string at 0x{off:x}"))
}
}
}
}
/// Decodes Modified UTF-8 as used by DEX (and JNI): NUL is encoded as two
/// bytes and supplementary characters as surrogate pairs.
pub fn decode_mutf8(bytes: &[u8]) -> Result<String> {
let mut units: Vec<u16> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
let a = bytes[i];
let cont = |j: usize| -> Result<u16> {
match bytes.get(j) {
Some(&b) if b & 0xc0 == 0x80 => Ok(u16::from(b & 0x3f)),
_ => err("invalid MUTF-8 continuation byte"),
}
};
if a < 0x80 {
units.push(u16::from(a));
i += 1;
} else if a & 0xe0 == 0xc0 {
units.push((u16::from(a & 0x1f) << 6) | cont(i + 1)?);
i += 2;
} else if a & 0xf0 == 0xe0 {
units.push((u16::from(a & 0x0f) << 12) | (cont(i + 1)? << 6) | cont(i + 2)?);
i += 3;
} else {
return err(format!("invalid MUTF-8 lead byte 0x{a:02x}"));
}
}
Ok(String::from_utf16_lossy(&units))
}
pub fn adler32(data: &[u8]) -> u32 {
const MOD: u32 = 65521;
let (mut a, mut b) = (1u32, 0u32);
for chunk in data.chunks(5552) {
for &byte in chunk {
a += u32::from(byte);
b += a;
}
a %= MOD;
b %= MOD;
}
(b << 16) | a
}
/// Converts a type descriptor such as `Ljava/lang/String;` or `[I` into its
/// Java source representation (`java.lang.String`, `int[]`).
pub fn descriptor_to_java(desc: &str) -> String {
let dims = desc.chars().take_while(|&c| c == '[').count();
let base = &desc[dims..];
let name = match base {
"V" => "void".to_owned(),
"Z" => "boolean".to_owned(),
"B" => "byte".to_owned(),
"S" => "short".to_owned(),
"C" => "char".to_owned(),
"I" => "int".to_owned(),
"J" => "long".to_owned(),
"F" => "float".to_owned(),
"D" => "double".to_owned(),
_ if base.starts_with('L') && base.ends_with(';') && base.len() >= 2 => {
base[1..base.len() - 1].replace('/', ".")
}
_ => base.to_owned(),
};
format!("{name}{}", "[]".repeat(dims))
}
/// Returns `true` if the data starts with the DEX magic.
pub fn is_dex(data: &[u8]) -> bool {
data.len() >= 8 && &data[..4] == b"dex\n" && data[7] == 0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mutf8() {
assert_eq!(decode_mutf8(b"hello").unwrap(), "hello");
assert_eq!(decode_mutf8(&[0xc0, 0x80]).unwrap(), "\0");
assert_eq!(decode_mutf8("äö€".as_bytes()).unwrap(), "äö€");
// U+1F600 as a CESU-8 surrogate pair
assert_eq!(
decode_mutf8(&[0xed, 0xa0, 0xbd, 0xed, 0xb8, 0x80]).unwrap(),
"😀"
);
assert!(decode_mutf8(&[0xff]).is_err());
}
#[test]
fn adler() {
assert_eq!(adler32(b"Wikipedia"), 0x11E6_0398);
}
#[test]
fn java_names() {
assert_eq!(descriptor_to_java("Ljava/lang/String;"), "java.lang.String");
assert_eq!(descriptor_to_java("[[I"), "int[][]");
}
/// Minimal DEX with `count` string ids all pointing at `string_data`.
fn dex_with_strings(count: usize, string_data: &[u8]) -> Vec<u8> {
let mut d = vec![0u8; HEADER_SIZE];
d[..8].copy_from_slice(b"dex\n035\0");
d[40..44].copy_from_slice(&ENDIAN_CONSTANT.to_le_bytes());
d[56..60].copy_from_slice(&(count as u32).to_le_bytes());
d[60..64].copy_from_slice(&(HEADER_SIZE as u32).to_le_bytes());
let data_off = (HEADER_SIZE + count * 4) as u32;
for _ in 0..count {
d.extend(data_off.to_le_bytes());
}
d.extend(string_data);
d
}
#[test]
fn shares_duplicate_string_offsets() {
let dex = DexFile::parse(Arc::from(dex_with_strings(1000, b"\x05hello\0"))).unwrap();
assert_eq!(dex.string(999), "hello");
}
#[test]
fn bounds_overlapping_strings() {
// Many ids pointing into one long unterminated run, each claiming
// ~2^28 UTF-16 units, must not take quadratic time.
let run: Vec<u8> = [0xff, 0xff, 0xff, 0x7f].repeat(1_000_000);
let mut d = dex_with_strings(1, &run);
let count = 200_000u32;
let base = (d.len() - run.len()) as u32;
let mut ids = Vec::new();
for i in 0..count {
ids.extend((base + (i * 4) % run.len() as u32).to_le_bytes());
}
let ids_off = d.len() as u32;
d.extend(ids);
d[56..60].copy_from_slice(&count.to_le_bytes());
d[60..64].copy_from_slice(&ids_off.to_le_bytes());
let start = std::time::Instant::now();
let dex = DexFile::parse(Arc::from(d)).unwrap();
assert!(start.elapsed().as_secs() < 5, "took {:?}", start.elapsed());
assert!(dex.string(0).contains("invalid"));
assert!(
dex.warnings
.iter()
.any(|w| w.contains("could not be decoded"))
);
}
#[test]
fn rejects_garbage() {
assert!(DexFile::parse(Arc::from(vec![0u8; 10])).is_err());
assert!(DexFile::parse(Arc::from(vec![0u8; 0x70])).is_err());
}
}
+365
View File
@@ -0,0 +1,365 @@
//! Dalvik opcode table.
//!
//! Reference: <https://source.android.com/docs/core/runtime/dalvik-bytecode>
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Format {
F10x,
F12x,
F11n,
F11x,
F10t,
F20t,
F22x,
F21t,
F21s,
F21h,
F21c,
F23x,
F22b,
F22t,
F22s,
F22c,
F32x,
F30t,
F31t,
F31i,
F31c,
F35c,
F3rc,
F45cc,
F4rcc,
F51l,
/// Opcode not assigned in any public DEX version.
Unused,
}
impl Format {
/// Instruction size in 16-bit code units.
pub fn units(self) -> usize {
use Format::*;
match self {
F10x | F12x | F11n | F11x | F10t | Unused => 1,
F20t | F22x | F21t | F21s | F21h | F21c | F23x | F22b | F22t | F22s | F22c => 2,
F32x | F30t | F31t | F31i | F31c | F35c | F3rc => 3,
F45cc | F4rcc => 4,
F51l => 5,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum RefKind {
None,
String,
Type,
Field,
Method,
Proto,
CallSite,
MethodHandle,
}
#[derive(Clone, Copy, Debug)]
pub struct OpInfo {
pub name: &'static str,
pub format: Format,
pub ref_kind: RefKind,
}
const UNOPS: [&str; 21] = [
"neg-int",
"not-int",
"neg-long",
"not-long",
"neg-float",
"neg-double",
"int-to-long",
"int-to-float",
"int-to-double",
"long-to-int",
"long-to-float",
"long-to-double",
"float-to-int",
"float-to-long",
"float-to-double",
"double-to-int",
"double-to-long",
"double-to-float",
"int-to-byte",
"int-to-char",
"int-to-short",
];
const BINOPS: [&str; 32] = [
"add-int",
"sub-int",
"mul-int",
"div-int",
"rem-int",
"and-int",
"or-int",
"xor-int",
"shl-int",
"shr-int",
"ushr-int",
"add-long",
"sub-long",
"mul-long",
"div-long",
"rem-long",
"and-long",
"or-long",
"xor-long",
"shl-long",
"shr-long",
"ushr-long",
"add-float",
"sub-float",
"mul-float",
"div-float",
"rem-float",
"add-double",
"sub-double",
"mul-double",
"div-double",
"rem-double",
];
const BINOPS_2ADDR: [&str; 32] = [
"add-int/2addr",
"sub-int/2addr",
"mul-int/2addr",
"div-int/2addr",
"rem-int/2addr",
"and-int/2addr",
"or-int/2addr",
"xor-int/2addr",
"shl-int/2addr",
"shr-int/2addr",
"ushr-int/2addr",
"add-long/2addr",
"sub-long/2addr",
"mul-long/2addr",
"div-long/2addr",
"rem-long/2addr",
"and-long/2addr",
"or-long/2addr",
"xor-long/2addr",
"shl-long/2addr",
"shr-long/2addr",
"ushr-long/2addr",
"add-float/2addr",
"sub-float/2addr",
"mul-float/2addr",
"div-float/2addr",
"rem-float/2addr",
"add-double/2addr",
"sub-double/2addr",
"mul-double/2addr",
"div-double/2addr",
"rem-double/2addr",
];
const LIT16: [&str; 8] = [
"add-int/lit16",
"rsub-int",
"mul-int/lit16",
"div-int/lit16",
"rem-int/lit16",
"and-int/lit16",
"or-int/lit16",
"xor-int/lit16",
];
const LIT8: [&str; 11] = [
"add-int/lit8",
"rsub-int/lit8",
"mul-int/lit8",
"div-int/lit8",
"rem-int/lit8",
"and-int/lit8",
"or-int/lit8",
"xor-int/lit8",
"shl-int/lit8",
"shr-int/lit8",
"ushr-int/lit8",
];
const ARRAY_OPS: [&str; 14] = [
"aget",
"aget-wide",
"aget-object",
"aget-boolean",
"aget-byte",
"aget-char",
"aget-short",
"aput",
"aput-wide",
"aput-object",
"aput-boolean",
"aput-byte",
"aput-char",
"aput-short",
];
const INSTANCE_OPS: [&str; 14] = [
"iget",
"iget-wide",
"iget-object",
"iget-boolean",
"iget-byte",
"iget-char",
"iget-short",
"iput",
"iput-wide",
"iput-object",
"iput-boolean",
"iput-byte",
"iput-char",
"iput-short",
];
const STATIC_OPS: [&str; 14] = [
"sget",
"sget-wide",
"sget-object",
"sget-boolean",
"sget-byte",
"sget-char",
"sget-short",
"sput",
"sput-wide",
"sput-object",
"sput-boolean",
"sput-byte",
"sput-char",
"sput-short",
];
const INVOKES: [&str; 5] = [
"invoke-virtual",
"invoke-super",
"invoke-direct",
"invoke-static",
"invoke-interface",
];
const INVOKES_RANGE: [&str; 5] = [
"invoke-virtual/range",
"invoke-super/range",
"invoke-direct/range",
"invoke-static/range",
"invoke-interface/range",
];
const CMP: [&str; 5] = [
"cmpl-float",
"cmpg-float",
"cmpl-double",
"cmpg-double",
"cmp-long",
];
const IF_TEST: [&str; 6] = ["if-eq", "if-ne", "if-lt", "if-ge", "if-gt", "if-le"];
const IF_TESTZ: [&str; 6] = ["if-eqz", "if-nez", "if-ltz", "if-gez", "if-gtz", "if-lez"];
pub fn op_info(op: u8) -> OpInfo {
use Format::*;
use RefKind as R;
let i = |base: u8| usize::from(op - base);
let (name, format, ref_kind) = match op {
0x00 => ("nop", F10x, R::None),
0x01 => ("move", F12x, R::None),
0x02 => ("move/from16", F22x, R::None),
0x03 => ("move/16", F32x, R::None),
0x04 => ("move-wide", F12x, R::None),
0x05 => ("move-wide/from16", F22x, R::None),
0x06 => ("move-wide/16", F32x, R::None),
0x07 => ("move-object", F12x, R::None),
0x08 => ("move-object/from16", F22x, R::None),
0x09 => ("move-object/16", F32x, R::None),
0x0a => ("move-result", F11x, R::None),
0x0b => ("move-result-wide", F11x, R::None),
0x0c => ("move-result-object", F11x, R::None),
0x0d => ("move-exception", F11x, R::None),
0x0e => ("return-void", F10x, R::None),
0x0f => ("return", F11x, R::None),
0x10 => ("return-wide", F11x, R::None),
0x11 => ("return-object", F11x, R::None),
0x12 => ("const/4", F11n, R::None),
0x13 => ("const/16", F21s, R::None),
0x14 => ("const", F31i, R::None),
0x15 => ("const/high16", F21h, R::None),
0x16 => ("const-wide/16", F21s, R::None),
0x17 => ("const-wide/32", F31i, R::None),
0x18 => ("const-wide", F51l, R::None),
0x19 => ("const-wide/high16", F21h, R::None),
0x1a => ("const-string", F21c, R::String),
0x1b => ("const-string/jumbo", F31c, R::String),
0x1c => ("const-class", F21c, R::Type),
0x1d => ("monitor-enter", F11x, R::None),
0x1e => ("monitor-exit", F11x, R::None),
0x1f => ("check-cast", F21c, R::Type),
0x20 => ("instance-of", F22c, R::Type),
0x21 => ("array-length", F12x, R::None),
0x22 => ("new-instance", F21c, R::Type),
0x23 => ("new-array", F22c, R::Type),
0x24 => ("filled-new-array", F35c, R::Type),
0x25 => ("filled-new-array/range", F3rc, R::Type),
0x26 => ("fill-array-data", F31t, R::None),
0x27 => ("throw", F11x, R::None),
0x28 => ("goto", F10t, R::None),
0x29 => ("goto/16", F20t, R::None),
0x2a => ("goto/32", F30t, R::None),
0x2b => ("packed-switch", F31t, R::None),
0x2c => ("sparse-switch", F31t, R::None),
0x2d..=0x31 => (CMP[i(0x2d)], F23x, R::None),
0x32..=0x37 => (IF_TEST[i(0x32)], F22t, R::None),
0x38..=0x3d => (IF_TESTZ[i(0x38)], F21t, R::None),
0x44..=0x51 => (ARRAY_OPS[i(0x44)], F23x, R::None),
0x52..=0x5f => (INSTANCE_OPS[i(0x52)], F22c, R::Field),
0x60..=0x6d => (STATIC_OPS[i(0x60)], F21c, R::Field),
0x6e..=0x72 => (INVOKES[i(0x6e)], F35c, R::Method),
0x74..=0x78 => (INVOKES_RANGE[i(0x74)], F3rc, R::Method),
0x7b..=0x8f => (UNOPS[i(0x7b)], F12x, R::None),
0x90..=0xaf => (BINOPS[i(0x90)], F23x, R::None),
0xb0..=0xcf => (BINOPS_2ADDR[i(0xb0)], F12x, R::None),
0xd0..=0xd7 => (LIT16[i(0xd0)], F22s, R::None),
0xd8..=0xe2 => (LIT8[i(0xd8)], F22b, R::None),
0xfa => ("invoke-polymorphic", F45cc, R::Method),
0xfb => ("invoke-polymorphic/range", F4rcc, R::Method),
0xfc => ("invoke-custom", F35c, R::CallSite),
0xfd => ("invoke-custom/range", F3rc, R::CallSite),
0xfe => ("const-method-handle", F21c, R::MethodHandle),
0xff => ("const-method-type", F21c, R::Proto),
_ => ("<unused>", Unused, R::None),
};
OpInfo {
name,
format,
ref_kind,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn table_is_consistent() {
assert_eq!(op_info(0x6e).name, "invoke-virtual");
assert_eq!(op_info(0x78).name, "invoke-interface/range");
assert_eq!(op_info(0xaf).name, "rem-double");
assert_eq!(op_info(0xcf).name, "rem-double/2addr");
assert_eq!(op_info(0xe2).name, "ushr-int/lit8");
assert_eq!(op_info(0x8f).name, "int-to-short");
assert_eq!(op_info(0x6d).name, "sput-short");
assert_eq!(op_info(0x73).format, Format::Unused);
assert_eq!(op_info(0x3e).format, Format::Unused);
assert_eq!(op_info(0xe3).format, Format::Unused);
let assigned = (0..=255u8)
.filter(|&op| op_info(op).format != Format::Unused)
.count();
assert_eq!(assigned, 256 - 6 - 1 - 2 - 23);
}
}
+276
View File
@@ -0,0 +1,276 @@
//! Renders a DEX class definition as a smali source file.
use std::collections::HashSet;
use std::fmt::Write;
use super::access::{Target, flags_to_string};
use super::values::{
AnnotationsDirectory, EncodedValue, read_annotations_directory, read_static_values,
};
use super::{ClassDef, DexFile, EncodedField, EncodedMethod, disasm};
/// Escapes UTF-16 code units the way baksmali does: printable ASCII is kept,
/// everything else becomes `\n`, `\r`, `\t` or `\uXXXX`.
pub fn escape_utf16(units: impl IntoIterator<Item = u16>) -> String {
let mut out = String::new();
for u in units {
match u {
0x27 | 0x22 | 0x5c => {
out.push('\\');
out.push(char::from(u as u8));
}
0x20..=0x7e => out.push(char::from(u as u8)),
0x0a => out.push_str("\\n"),
0x0d => out.push_str("\\r"),
0x09 => out.push_str("\\t"),
_ => {
let _ = write!(out, "\\u{u:04x}");
}
}
}
out
}
/// Escapes a string for use inside a smali string literal.
pub fn escape_string(s: &str) -> String {
escape_utf16(s.encode_utf16())
}
fn with_flags(keyword: &str, flags: &str, rest: &str) -> String {
if flags.is_empty() {
format!("{keyword} {rest}")
} else {
format!("{keyword} {flags} {rest}")
}
}
fn is_default_value(v: &EncodedValue) -> bool {
use EncodedValue as V;
match v {
V::Null | V::Boolean(false) => true,
V::Byte(x) => *x == 0,
V::Short(x) => *x == 0,
V::Char(x) => *x == 0,
V::Int(x) => *x == 0,
V::Long(x) => *x == 0,
V::Float(x) => x.to_bits() == 0,
V::Double(x) => x.to_bits() == 0,
_ => false,
}
}
pub fn class_to_smali(dex: &DexFile, class: &ClassDef) -> String {
let mut out = String::new();
let flags = flags_to_string(class.access_flags, Target::Class);
let _ = writeln!(
out,
"{}",
with_flags(".class", &flags, &dex.class_descriptor(class))
);
if let Some(sup) = class.superclass {
let _ = writeln!(out, ".super {}", dex.type_descriptor(sup));
}
if let Some(src) = class.source_file {
let _ = writeln!(out, ".source \"{}\"", escape_string(&dex.string(src)));
}
match dex.interfaces(class) {
Ok(list) if !list.is_empty() => {
out.push_str("\n\n# interfaces\n");
for i in list {
let _ = writeln!(out, ".implements {}", dex.type_descriptor(i));
}
}
Ok(_) => {}
Err(e) => {
let _ = writeln!(out, "\n# ERROR: interfaces: {e}");
}
}
let annotations = match read_annotations_directory(dex.data(), class.annotations_off) {
Ok(a) => a,
Err(e) => {
let _ = writeln!(out, "\n# ERROR: annotations: {e}");
AnnotationsDirectory::default()
}
};
if !annotations.class.is_empty() {
out.push_str("\n\n# annotations\n");
dex.format_annotations(&mut out, &annotations.class, 0);
}
let data = match dex.class_data(class) {
Ok(d) => d,
Err(e) => {
let _ = writeln!(out, "\n# ERROR: class data: {e}");
return out;
}
};
let static_values = match read_static_values(dex.data(), class.static_values_off) {
Ok(v) => v,
Err(e) => {
let _ = writeln!(out, "\n# ERROR: static values: {e}");
Vec::new()
}
};
let set_in_clinit = fields_set_in_clinit(dex, class, &data.direct_methods);
write_fields(
dex,
&mut out,
"# static fields",
&data.static_fields,
&static_values,
&set_in_clinit,
&annotations,
);
write_fields(
dex,
&mut out,
"# instance fields",
&data.instance_fields,
&[],
&HashSet::new(),
&annotations,
);
write_methods(
dex,
&mut out,
"# direct methods",
&data.direct_methods,
&annotations,
);
write_methods(
dex,
&mut out,
"# virtual methods",
&data.virtual_methods,
&annotations,
);
out
}
/// Fields of this class assigned by `sput*` in its static initializer.
fn fields_set_in_clinit(dex: &DexFile, class: &ClassDef, direct: &[EncodedMethod]) -> HashSet<u32> {
let clinit = direct.iter().find(|m| {
m.code_off != 0
&& dex
.method(m.method_idx)
.is_some_and(|id| dex.string(id.name) == "<clinit>")
});
let Some(code) = clinit.and_then(|m| dex.code_item(m.code_off).ok()) else {
return HashSet::new();
};
disasm::static_puts(&code)
.into_iter()
.filter(|&f| dex.field(f).is_some_and(|id| id.class == class.class_idx))
.collect()
}
fn write_fields(
dex: &DexFile,
out: &mut String,
title: &str,
fields: &[EncodedField],
values: &[EncodedValue],
set_in_clinit: &HashSet<u32>,
annotations: &AnnotationsDirectory,
) {
if fields.is_empty() {
return;
}
let _ = write!(out, "\n\n{title}\n");
for (i, field) in fields.iter().enumerate() {
if i > 0 {
out.push('\n');
}
let flags = flags_to_string(field.access_flags, Target::Field);
let mut value = values.get(i);
let static_final = field.access_flags & 0x18 == 0x18;
if static_final && set_in_clinit.contains(&field.field_idx) {
match value {
Some(v) if !is_default_value(v) => out.push_str(
"# The value of this static final field might be set in the static constructor\n",
),
_ => value = None,
}
}
let mut line = with_flags(".field", &flags, &dex.field_name_and_type(field.field_idx));
if let Some(v) = value {
line.push_str(" = ");
line.push_str(&dex.format_value(v, 0));
}
let _ = writeln!(out, "{line}");
let field_annotations = annotations.for_field(field.field_idx);
if !field_annotations.is_empty() {
dex.format_annotations(out, field_annotations, 4);
out.push_str(".end field\n");
}
}
}
fn write_methods(
dex: &DexFile,
out: &mut String,
title: &str,
methods: &[EncodedMethod],
annotations: &AnnotationsDirectory,
) {
if methods.is_empty() {
return;
}
let _ = write!(out, "\n\n{title}\n");
for (i, method) in methods.iter().enumerate() {
if i > 0 {
out.push('\n');
}
let flags = flags_to_string(method.access_flags, Target::Method);
let _ = writeln!(
out,
"{}",
with_flags(
".method",
&flags,
&dex.method_name_and_proto(method.method_idx)
)
);
let method_annotations = annotations.for_method(method.method_idx);
if method.code_off == 0 {
if !method_annotations.is_empty() {
dex.format_annotations(out, method_annotations, 4);
}
} else {
match dex.code_item(method.code_off) {
Ok(code) => {
let mut body = String::new();
disasm::disassemble(dex, &code, &mut body, " ");
// `.locals` comes first, then annotations, then the code.
let (locals, rest) = body.split_once('\n').unwrap_or((&body, ""));
out.push_str(locals);
out.push('\n');
if !method_annotations.is_empty() {
out.push('\n');
dex.format_annotations(out, method_annotations, 4);
}
out.push_str(rest);
}
Err(e) => {
let _ = writeln!(out, " # ERROR: code item: {e}");
}
}
}
out.push_str(".end method\n");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn escapes() {
assert_eq!(escape_string("a\"b\\c\n\u{1}'"), "a\\\"b\\\\c\\n\\u0001\\'");
assert_eq!(escape_string("ä😀"), "\\u00e4\\ud83d\\ude00");
}
}
+330
View File
@@ -0,0 +1,330 @@
//! `encoded_value`, `encoded_array` and annotation parsing / smali rendering.
use super::DexFile;
use super::javafmt::{double_to_string, float_to_string};
use super::smali::{escape_string, escape_utf16};
use crate::formats::reader::{Reader, Result, err};
const MAX_DEPTH: u32 = 32;
#[derive(Debug, Clone)]
pub enum EncodedValue {
Byte(i8),
Short(i16),
Char(u16),
Int(i32),
Long(i64),
Float(f32),
Double(f64),
MethodType(u32),
MethodHandle(u32),
String(u32),
Type(u32),
Field(u32),
Method(u32),
Enum(u32),
Array(Vec<EncodedValue>),
Annotation(EncodedAnnotation),
Null,
Boolean(bool),
}
#[derive(Debug, Clone)]
pub struct EncodedAnnotation {
pub type_idx: u32,
pub elements: Vec<(u32, EncodedValue)>,
}
#[derive(Debug, Clone)]
pub struct Annotation {
pub visibility: u8,
pub annotation: EncodedAnnotation,
}
#[derive(Debug, Clone, Default)]
pub struct AnnotationsDirectory {
pub class: Vec<Annotation>,
pub fields: Vec<(u32, Vec<Annotation>)>,
pub methods: Vec<(u32, Vec<Annotation>)>,
}
impl AnnotationsDirectory {
pub fn for_field(&self, idx: u32) -> &[Annotation] {
lookup(&self.fields, idx)
}
pub fn for_method(&self, idx: u32) -> &[Annotation] {
lookup(&self.methods, idx)
}
}
fn lookup(list: &[(u32, Vec<Annotation>)], idx: u32) -> &[Annotation] {
list.iter()
.find(|(i, _)| *i == idx)
.map(|(_, a)| a.as_slice())
.unwrap_or_default()
}
fn sign_extend(value: u64, bytes: usize) -> i64 {
let shift = 64 - 8 * bytes as u32;
((value << shift) as i64) >> shift
}
pub fn read_value(r: &mut Reader, depth: u32) -> Result<EncodedValue> {
if depth > MAX_DEPTH {
return err("encoded value nesting too deep");
}
let header = r.u8()?;
let arg = usize::from(header >> 5);
let size = arg + 1;
let value_type = header & 0x1f;
let check = |max: usize| {
if size > max {
err(format!(
"invalid size {size} for encoded value type 0x{value_type:02x}"
))
} else {
Ok(())
}
};
use EncodedValue as V;
Ok(match value_type {
0x00 => {
check(1)?;
V::Byte(r.u8()? as i8)
}
0x02 => {
check(2)?;
V::Short(sign_extend(r.uint_sized(size)?, size) as i16)
}
0x03 => {
check(2)?;
V::Char(r.uint_sized(size)? as u16)
}
0x04 => {
check(4)?;
V::Int(sign_extend(r.uint_sized(size)?, size) as i32)
}
0x06 => {
check(8)?;
V::Long(sign_extend(r.uint_sized(size)?, size))
}
0x10 => {
check(4)?;
let bits = (r.uint_sized(size)? as u32) << (8 * (4 - size));
V::Float(f32::from_bits(bits))
}
0x11 => {
check(8)?;
let bits = r.uint_sized(size)? << (8 * (8 - size));
V::Double(f64::from_bits(bits))
}
0x15..=0x1b => {
check(4)?;
let idx = r.uint_sized(size)? as u32;
match value_type {
0x15 => V::MethodType(idx),
0x16 => V::MethodHandle(idx),
0x17 => V::String(idx),
0x18 => V::Type(idx),
0x19 => V::Field(idx),
0x1a => V::Method(idx),
_ => V::Enum(idx),
}
}
0x1c => V::Array(read_array(r, depth + 1)?),
0x1d => V::Annotation(read_annotation(r, depth + 1)?),
0x1e => V::Null,
0x1f => V::Boolean(arg != 0),
other => return err(format!("unknown encoded value type 0x{other:02x}")),
})
}
pub fn read_array(r: &mut Reader, depth: u32) -> Result<Vec<EncodedValue>> {
let size = r.uleb128()? as usize;
let mut out = Vec::with_capacity(size.min(r.remaining()));
for _ in 0..size {
out.push(read_value(r, depth)?);
}
Ok(out)
}
pub fn read_annotation(r: &mut Reader, depth: u32) -> Result<EncodedAnnotation> {
let type_idx = r.uleb128()?;
let size = r.uleb128()? as usize;
let mut elements = Vec::with_capacity(size.min(r.remaining() / 2));
for _ in 0..size {
let name = r.uleb128()?;
elements.push((name, read_value(r, depth)?));
}
Ok(EncodedAnnotation { type_idx, elements })
}
fn read_annotation_set(data: &[u8], off: u32) -> Result<Vec<Annotation>> {
if off == 0 {
return Ok(Vec::new());
}
let mut r = Reader::at(data, off as usize)?;
let size = r.u32()? as usize;
if size.saturating_mul(4) > r.remaining() {
return err(format!("annotation set at 0x{off:x} exceeds the file"));
}
let mut out = Vec::with_capacity(size);
for _ in 0..size {
let item_off = r.u32()? as usize;
let mut item = Reader::at(data, item_off)?;
let visibility = item.u8()?;
out.push(Annotation {
visibility,
annotation: read_annotation(&mut item, 0)?,
});
}
Ok(out)
}
pub fn read_annotations_directory(data: &[u8], off: u32) -> Result<AnnotationsDirectory> {
if off == 0 {
return Ok(AnnotationsDirectory::default());
}
let mut r = Reader::at(data, off as usize)?;
let class_off = r.u32()?;
let fields_size = r.u32()? as usize;
let methods_size = r.u32()? as usize;
let _params_size = r.u32()?;
let total = fields_size.saturating_add(methods_size).saturating_mul(8);
if total > r.remaining() {
return err(format!(
"annotations directory at 0x{off:x} exceeds the file"
));
}
let mut read_list = |n: usize| -> Result<Vec<(u32, Vec<Annotation>)>> {
let mut out = Vec::with_capacity(n);
for _ in 0..n {
let idx = r.u32()?;
let set_off = r.u32()?;
out.push((idx, read_annotation_set(data, set_off)?));
}
Ok(out)
};
let fields = read_list(fields_size)?;
let methods = read_list(methods_size)?;
Ok(AnnotationsDirectory {
class: read_annotation_set(data, class_off)?,
fields,
methods,
})
}
pub fn read_static_values(data: &[u8], off: u32) -> Result<Vec<EncodedValue>> {
if off == 0 {
return Ok(Vec::new());
}
read_array(&mut Reader::at(data, off as usize)?, 0)
}
pub fn hex_signed(v: i64) -> String {
if v < 0 {
format!("-0x{:x}", v.unsigned_abs())
} else {
format!("0x{v:x}")
}
}
impl DexFile {
pub fn format_value(&self, value: &EncodedValue, indent: usize) -> String {
use EncodedValue as V;
match value {
V::Byte(v) => format!("{}t", hex_signed(i64::from(*v))),
V::Short(v) => format!("{}s", hex_signed(i64::from(*v))),
V::Char(v) => format!("'{}'", escape_utf16([*v])),
V::Int(v) => hex_signed(i64::from(*v)),
V::Long(v) => format!("{}L", hex_signed(*v)),
V::Float(v) => format!("{}f", float_to_string(*v)),
V::Double(v) => double_to_string(*v),
V::MethodType(idx) => self.proto_descriptor(*idx),
V::MethodHandle(idx) => format!("method_handle@{idx}"),
V::String(idx) => format!("\"{}\"", escape_string(&self.string(*idx))),
V::Type(idx) => self.type_descriptor(*idx).into_owned(),
V::Field(idx) => self.field_ref(*idx),
V::Method(idx) => self.method_ref(*idx),
V::Enum(idx) => format!(".enum {}", self.field_ref(*idx)),
V::Array(items) => {
if items.is_empty() {
return "{}".to_owned();
}
let pad = " ".repeat(indent + 4);
let inner: Vec<String> = items
.iter()
.map(|v| format!("{pad}{}", self.format_value(v, indent + 4)))
.collect();
format!("{{\n{}\n{}}}", inner.join(",\n"), " ".repeat(indent))
}
V::Annotation(a) => {
let mut out = format!(".subannotation {}\n", self.type_descriptor(a.type_idx));
self.format_elements(&mut out, a, indent + 4);
out.push_str(&" ".repeat(indent));
out.push_str(".end subannotation");
out
}
V::Null => "null".to_owned(),
V::Boolean(b) => b.to_string(),
}
}
fn format_elements(&self, out: &mut String, a: &EncodedAnnotation, indent: usize) {
for (name, value) in &a.elements {
out.push_str(&" ".repeat(indent));
out.push_str(&self.string(*name));
out.push_str(" = ");
out.push_str(&self.format_value(value, indent));
out.push('\n');
}
}
pub fn format_annotations(&self, out: &mut String, annotations: &[Annotation], indent: usize) {
let pad = " ".repeat(indent);
for (i, a) in annotations.iter().enumerate() {
if i > 0 {
out.push('\n');
}
let visibility = match a.visibility {
0 => "build",
1 => "runtime",
2 => "system",
_ => "unknown",
};
out.push_str(&format!(
"{pad}.annotation {visibility} {}\n",
self.type_descriptor(a.annotation.type_idx)
));
self.format_elements(out, &a.annotation, indent + 4);
out.push_str(&format!("{pad}.end annotation\n"));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decodes_primitives() {
let v = read_value(&mut Reader::new(&[0x04, 0x2a]), 0).unwrap();
assert!(matches!(v, EncodedValue::Int(42)));
let v = read_value(&mut Reader::new(&[0x24, 0xff, 0xff]), 0).unwrap();
assert!(matches!(v, EncodedValue::Int(-1)));
let v = read_value(&mut Reader::new(&[0x3f]), 0).unwrap();
assert!(matches!(v, EncodedValue::Boolean(true)));
// 1.0f is 0x3f800000; only the two high-order bytes are stored.
let v = read_value(&mut Reader::new(&[0x30, 0x80, 0x3f]), 0).unwrap();
assert!(matches!(v, EncodedValue::Float(f) if f == 1.0));
assert!(read_value(&mut Reader::new(&[0xe4, 0, 0, 0, 0, 0, 0, 0, 0]), 0).is_err());
}
#[test]
fn signed_hex() {
assert_eq!(hex_signed(-1), "-0x1");
assert_eq!(hex_signed(255), "0xff");
assert_eq!(hex_signed(i64::MIN), "-0x8000000000000000");
}
}
+7
View File
@@ -0,0 +1,7 @@
//! Parsers for the binary formats found inside Android packages.
pub mod axml;
pub mod detect;
pub mod dex;
pub mod reader;
pub mod zip;
+157
View File
@@ -0,0 +1,157 @@
//! Bounds-checked little-endian byte reader shared by the binary format parsers.
//!
//! Android packages are frequently crafted to crash analysis tools, so every
//! read is checked and reported as an error instead of panicking.
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseError(pub String);
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for ParseError {}
pub type Result<T> = std::result::Result<T, ParseError>;
pub fn err<T>(msg: impl Into<String>) -> Result<T> {
Err(ParseError(msg.into()))
}
#[derive(Clone)]
pub struct Reader<'a> {
data: &'a [u8],
pos: usize,
}
impl<'a> Reader<'a> {
pub fn new(data: &'a [u8]) -> Self {
Self { data, pos: 0 }
}
pub fn at(data: &'a [u8], pos: usize) -> Result<Self> {
if pos > data.len() {
return err(format!(
"offset 0x{pos:x} is outside of the data (length 0x{:x})",
data.len()
));
}
Ok(Self { data, pos })
}
pub fn pos(&self) -> usize {
self.pos
}
pub fn remaining(&self) -> usize {
self.data.len() - self.pos
}
pub fn bytes(&mut self, n: usize) -> Result<&'a [u8]> {
if n > self.remaining() {
return err(format!(
"unexpected end of data reading {n} bytes at offset 0x{:x}",
self.pos
));
}
let slice = &self.data[self.pos..self.pos + n];
self.pos += n;
Ok(slice)
}
pub fn skip(&mut self, n: usize) -> Result<()> {
self.bytes(n).map(|_| ())
}
pub fn u8(&mut self) -> Result<u8> {
Ok(self.bytes(1)?[0])
}
pub fn u16(&mut self) -> Result<u16> {
let b = self.bytes(2)?;
Ok(u16::from_le_bytes([b[0], b[1]]))
}
pub fn u32(&mut self) -> Result<u32> {
let b = self.bytes(4)?;
Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
}
pub fn uleb128(&mut self) -> Result<u32> {
let mut result: u32 = 0;
for i in 0..5 {
let byte = self.u8()?;
result |= u32::from(byte & 0x7f).wrapping_shl(7 * i);
if byte & 0x80 == 0 {
return Ok(result);
}
}
err(format!("malformed uleb128 at offset 0x{:x}", self.pos))
}
pub fn sleb128(&mut self) -> Result<i32> {
let mut result: i32 = 0;
let mut shift = 0;
for _ in 0..5 {
let byte = self.u8()?;
result |= i32::from(byte & 0x7f).wrapping_shl(shift);
shift += 7;
if byte & 0x80 == 0 {
if shift < 32 && byte & 0x40 != 0 {
result |= -1i32 << shift;
}
return Ok(result);
}
}
err(format!("malformed sleb128 at offset 0x{:x}", self.pos))
}
/// Reads `n` (1..=8) bytes as a little-endian unsigned integer.
pub fn uint_sized(&mut self, n: usize) -> Result<u64> {
let bytes = self.bytes(n)?;
Ok(bytes
.iter()
.rev()
.fold(0u64, |acc, &b| (acc << 8) | u64::from(b)))
}
}
pub fn read_u16(data: &[u8], off: usize) -> Result<u16> {
Reader::at(data, off)?.u16()
}
pub fn read_u32(data: &[u8], off: usize) -> Result<u32> {
Reader::at(data, off)?.u32()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn leb128() {
assert_eq!(Reader::new(&[0x00]).uleb128().unwrap(), 0);
assert_eq!(Reader::new(&[0x7f]).uleb128().unwrap(), 127);
assert_eq!(Reader::new(&[0x80, 0x7f]).uleb128().unwrap(), 16256);
assert_eq!(Reader::new(&[0x7f]).sleb128().unwrap(), -1);
assert_eq!(Reader::new(&[0x80, 0x7f]).sleb128().unwrap(), -128);
assert_eq!(Reader::new(&[0x3f]).sleb128().unwrap(), 63);
assert!(
Reader::new(&[0x80, 0x80, 0x80, 0x80, 0x80])
.uleb128()
.is_err()
);
}
#[test]
fn bounds() {
let mut r = Reader::new(&[1, 2, 3]);
assert_eq!(r.u16().unwrap(), 0x0201);
assert!(r.u16().is_err());
assert!(Reader::at(&[1], 2).is_err());
}
}
+474
View File
@@ -0,0 +1,474 @@
//! A lenient ZIP reader modelled on how Android reads APKs.
//!
//! Android (libziparchive) trusts the central directory, uses the local
//! header only to locate the entry data, and ignores malformed extra fields
//! or the encryption flag. Strict ZIP libraries reject such files, which
//! makes them a popular anti-analysis trick, so we mirror Android instead.
use std::io::Read;
use std::sync::Arc;
use flate2::Crc;
use super::reader::{Reader, Result, err, read_u16, read_u32};
const EOCD_SIG: u32 = 0x0605_4b50;
const ZIP64_LOCATOR_SIG: u32 = 0x0706_4b50;
const ZIP64_EOCD_SIG: u32 = 0x0606_4b50;
const CENTRAL_SIG: u32 = 0x0201_4b50;
const LOCAL_SIG: u32 = 0x0403_4b50;
pub const METHOD_STORED: u16 = 0;
pub const METHOD_DEFLATED: u16 = 8;
#[derive(Debug, Clone)]
pub struct ZipEntry {
pub name: String,
pub method: u16,
pub flags: u16,
pub crc32: u32,
pub compressed_size: u64,
pub size: u64,
pub local_header_offset: u64,
pub dos_time: u16,
pub dos_date: u16,
}
impl ZipEntry {
pub fn is_dir(&self) -> bool {
self.name.ends_with('/') && self.size == 0
}
pub fn encrypted(&self) -> bool {
self.flags & 1 != 0
}
pub fn method_name(&self) -> String {
match self.method {
0 => "Stored".to_owned(),
8 => "Deflate".to_owned(),
9 => "Deflate64".to_owned(),
12 => "BZIP2".to_owned(),
14 => "LZMA".to_owned(),
93 => "Zstandard".to_owned(),
99 => "AES".to_owned(),
m => format!("Unknown ({m})"),
}
}
/// Last modification time as `YYYY-MM-DD hh:mm:ss`, if the DOS date is valid.
pub fn modified(&self) -> Option<String> {
let (d, t) = (self.dos_date, self.dos_time);
let (day, month, year) = (d & 0x1f, (d >> 5) & 0xf, 1980 + (d >> 9));
if day == 0 || month == 0 || month > 12 {
return None;
}
Some(format!(
"{year:04}-{month:02}-{day:02} {:02}:{:02}:{:02}",
t >> 11,
(t >> 5) & 0x3f,
(t & 0x1f) * 2
))
}
}
pub struct ZipArchive {
data: Arc<[u8]>,
pub entries: Vec<ZipEntry>,
pub comment: String,
pub warnings: Vec<String>,
}
/// Entry data plus notes about anything unusual found while extracting it.
pub struct Extracted {
pub data: Vec<u8>,
pub notes: Vec<String>,
}
fn find_eocd(data: &[u8]) -> Option<usize> {
if data.len() < 22 {
return None;
}
let min = data.len().saturating_sub(22 + 0xffff);
(min..=data.len() - 22)
.rev()
.find(|&pos| read_u32(data, pos).ok() == Some(EOCD_SIG))
}
/// Applies ZIP64 extended information (extra field 0x0001) leniently.
fn apply_zip64_extra(extra: &[u8], entry: &mut ZipEntry) {
let mut r = Reader::new(extra);
while r.remaining() >= 4 {
let (Ok(id), Ok(len)) = (r.u16(), r.u16()) else {
return;
};
let Ok(body) = r.bytes(usize::from(len)) else {
return;
};
if id != 0x0001 {
continue;
}
let mut b = Reader::new(body);
if entry.size == 0xffff_ffff {
if let Ok(v) = b.uint_sized(8) {
entry.size = v;
}
}
if entry.compressed_size == 0xffff_ffff {
if let Ok(v) = b.uint_sized(8) {
entry.compressed_size = v;
}
}
if entry.local_header_offset == 0xffff_ffff {
if let Ok(v) = b.uint_sized(8) {
entry.local_header_offset = v;
}
}
return;
}
}
impl ZipArchive {
pub fn parse(data: Arc<[u8]>) -> Result<Self> {
let eocd = find_eocd(&data).ok_or_else(|| {
super::reader::ParseError("end of central directory not found".to_owned())
})?;
let mut warnings = Vec::new();
let mut r = Reader::at(&data, eocd + 4)?;
let _disk = r.u16()?;
let _cd_disk = r.u16()?;
let _entries_on_disk = r.u16()?;
let mut total_entries = u64::from(r.u16()?);
let mut cd_size = u64::from(r.u32()?);
let mut cd_offset = u64::from(r.u32()?);
let comment_len = usize::from(r.u16()?);
let comment = r
.bytes(comment_len.min(r.remaining()))
.map(|b| String::from_utf8_lossy(b).into_owned())
.unwrap_or_default();
let needs_zip64 =
total_entries == 0xffff || cd_size == 0xffff_ffff || cd_offset == 0xffff_ffff;
if needs_zip64 && eocd >= 20 && read_u32(&data, eocd - 20).ok() == Some(ZIP64_LOCATOR_SIG) {
let mut loc = Reader::at(&data, eocd - 12)?;
let z64 = loc.uint_sized(8)? as usize;
if read_u32(&data, z64).ok() == Some(ZIP64_EOCD_SIG) {
let mut z = Reader::at(&data, z64 + 24)?;
let _entries_on_disk = z.uint_sized(8)?;
total_entries = z.uint_sized(8)?;
cd_size = z.uint_sized(8)?;
cd_offset = z.uint_sized(8)?;
}
}
let cd_start = usize::try_from(cd_offset).unwrap_or(usize::MAX);
if cd_start >= data.len() {
return err(format!(
"central directory offset 0x{cd_offset:x} is outside the file"
));
}
if cd_offset.saturating_add(cd_size) > eocd as u64 {
warnings.push("central directory overlaps the end record".to_owned());
}
let mut entries = Vec::with_capacity(total_entries.min(1 << 20) as usize);
let mut r = Reader::at(&data, cd_start)?;
for i in 0..total_entries {
match r.u32() {
Ok(CENTRAL_SIG) => {}
_ => {
warnings.push(format!(
"central directory ends after {i} of {total_entries} entries"
));
break;
}
}
let _made_by = r.u16()?;
let _needed = r.u16()?;
let flags = r.u16()?;
let method = r.u16()?;
let dos_time = r.u16()?;
let dos_date = r.u16()?;
let crc32 = r.u32()?;
let compressed_size = u64::from(r.u32()?);
let size = u64::from(r.u32()?);
let name_len = usize::from(r.u16()?);
let extra_len = usize::from(r.u16()?);
let comment_len = usize::from(r.u16()?);
let _disk_start = r.u16()?;
let _internal = r.u16()?;
let _external = r.u32()?;
let local_header_offset = u64::from(r.u32()?);
let name = String::from_utf8_lossy(r.bytes(name_len)?).into_owned();
let extra = r.bytes(extra_len.min(r.remaining()))?;
r.skip(comment_len.min(r.remaining()))?;
let mut entry = ZipEntry {
name,
method,
flags,
crc32,
compressed_size,
size,
local_header_offset,
dos_time,
dos_date,
};
apply_zip64_extra(extra, &mut entry);
entries.push(entry);
}
Ok(Self {
data,
entries,
comment,
warnings,
})
}
/// Offset of the entry's data, derived from its local file header.
pub fn data_offset(&self, entry: &ZipEntry) -> Result<usize> {
let off = usize::try_from(entry.local_header_offset).unwrap_or(usize::MAX);
if read_u32(&self.data, off)? != LOCAL_SIG {
return err(format!("no local file header at offset 0x{off:x}"));
}
let name_len = usize::from(read_u16(&self.data, off + 26)?);
let extra_len = usize::from(read_u16(&self.data, off + 28)?);
let start = off + 30 + name_len + extra_len;
if start > self.data.len() {
return err(format!(
"local file header at 0x{off:x} extends past the end of the file"
));
}
Ok(start)
}
pub fn extract(&self, index: usize, limit: u64) -> Result<Extracted> {
let entry = self
.entries
.get(index)
.ok_or_else(|| super::reader::ParseError("invalid entry index".to_owned()))?;
let mut notes = Vec::new();
let start = self.data_offset(entry)?;
let available = self.data.len().saturating_sub(start);
let mut len = usize::try_from(entry.compressed_size).unwrap_or(usize::MAX);
if len > available {
notes.push(format!(
"compressed size {} exceeds the file; data is truncated",
entry.compressed_size
));
len = available;
}
let raw = &self.data[start..start + len];
if entry.encrypted() {
notes.push("encryption flag is set but ignored (as Android does)".to_owned());
}
let inflate = |input: &[u8]| -> std::io::Result<Vec<u8>> {
let mut out = Vec::new();
flate2::read::DeflateDecoder::new(input)
.take(limit + 1)
.read_to_end(&mut out)?;
Ok(out)
};
let data = match entry.method {
METHOD_STORED => raw.to_vec(),
METHOD_DEFLATED => inflate(raw).map_err(|e| {
super::reader::ParseError(format!("deflate stream is corrupt: {e}"))
})?,
other => match inflate(raw) {
Ok(d) => {
notes.push(format!(
"declared compression method {other} is not supported; data was deflate"
));
d
}
Err(_) => {
notes.push(format!(
"declared compression method {other} is not supported; showing raw data"
));
raw.to_vec()
}
},
};
if data.len() as u64 > limit {
return err(format!(
"entry is larger than the {} MiB limit",
limit >> 20
));
}
if data.len() as u64 != entry.size {
notes.push(format!(
"declared size {} does not match actual size {}",
entry.size,
data.len()
));
}
let mut crc = Crc::new();
crc.update(&data);
if crc.sum() != entry.crc32 {
notes.push(format!(
"CRC-32 mismatch: declared 0x{:08x}, actual 0x{:08x}",
entry.crc32,
crc.sum()
));
}
Ok(Extracted { data, notes })
}
}
/// Writes an uncompressed ZIP; used by tests to build synthetic packages.
#[cfg(test)]
pub(crate) fn write_stored_zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
let mut out = Vec::new();
let mut central = Vec::new();
for (name, content) in entries {
let mut crc = Crc::new();
crc.update(content);
let offset = out.len() as u32;
let header = |sig: u32, buf: &mut Vec<u8>, central: bool| {
buf.extend(sig.to_le_bytes());
if central {
buf.extend(20u16.to_le_bytes());
}
buf.extend(20u16.to_le_bytes());
buf.extend([0u8; 8]);
buf.extend(crc.sum().to_le_bytes());
buf.extend((content.len() as u32).to_le_bytes());
buf.extend((content.len() as u32).to_le_bytes());
buf.extend((name.len() as u16).to_le_bytes());
buf.extend(0u16.to_le_bytes());
if central {
buf.extend([0u8; 10]);
buf.extend(offset.to_le_bytes());
}
buf.extend(name.as_bytes());
};
header(LOCAL_SIG, &mut out, false);
out.extend(*content);
header(CENTRAL_SIG, &mut central, true);
}
let cd_offset = out.len() as u32;
out.extend(&central);
out.extend(EOCD_SIG.to_le_bytes());
out.extend([0u8; 4]);
out.extend((entries.len() as u16).to_le_bytes());
out.extend((entries.len() as u16).to_le_bytes());
out.extend((central.len() as u32).to_le_bytes());
out.extend(cd_offset.to_le_bytes());
out.extend(0u16.to_le_bytes());
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
/// Builds a ZIP with one entry; `cd_extra` is written raw into the
/// central directory record (to simulate malformed extra fields).
fn build_zip(
name: &str,
content: &[u8],
deflate: bool,
flags: u16,
cd_extra: &[u8],
) -> Vec<u8> {
let payload = if deflate {
let mut enc =
flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
enc.write_all(content).unwrap();
enc.finish().unwrap()
} else {
content.to_vec()
};
let mut crc = Crc::new();
crc.update(content);
let method: u16 = if deflate { 8 } else { 0 };
let mut out = Vec::new();
out.extend(LOCAL_SIG.to_le_bytes());
out.extend(20u16.to_le_bytes());
out.extend(flags.to_le_bytes());
out.extend(method.to_le_bytes());
out.extend([0u8; 4]);
out.extend(crc.sum().to_le_bytes());
out.extend((payload.len() as u32).to_le_bytes());
out.extend((content.len() as u32).to_le_bytes());
out.extend((name.len() as u16).to_le_bytes());
out.extend(0u16.to_le_bytes());
out.extend(name.as_bytes());
out.extend(&payload);
let cd_start = out.len();
out.extend(CENTRAL_SIG.to_le_bytes());
out.extend(20u16.to_le_bytes());
out.extend(20u16.to_le_bytes());
out.extend(flags.to_le_bytes());
out.extend(method.to_le_bytes());
out.extend(0x6000u16.to_le_bytes());
out.extend(0x5821u16.to_le_bytes());
out.extend(crc.sum().to_le_bytes());
out.extend((payload.len() as u32).to_le_bytes());
out.extend((content.len() as u32).to_le_bytes());
out.extend((name.len() as u16).to_le_bytes());
out.extend((cd_extra.len() as u16).to_le_bytes());
out.extend(0u16.to_le_bytes());
out.extend([0u8; 8]);
out.extend(0u32.to_le_bytes());
out.extend(name.as_bytes());
out.extend(cd_extra);
let cd_size = out.len() - cd_start;
out.extend(EOCD_SIG.to_le_bytes());
out.extend([0u8; 4]);
out.extend(1u16.to_le_bytes());
out.extend(1u16.to_le_bytes());
out.extend((cd_size as u32).to_le_bytes());
out.extend((cd_start as u32).to_le_bytes());
out.extend(0u16.to_le_bytes());
out
}
#[test]
fn reads_stored_and_deflated() {
for deflate in [false, true] {
let zip = build_zip("a/b.txt", b"hello hello hello", deflate, 0, &[]);
let archive = ZipArchive::parse(zip.into()).unwrap();
assert_eq!(archive.entries.len(), 1);
assert_eq!(archive.entries[0].name, "a/b.txt");
assert_eq!(
archive.entries[0].modified().unwrap(),
"2024-01-01 12:00:00"
);
let x = archive.extract(0, 1 << 20).unwrap();
assert_eq!(x.data, b"hello hello hello");
assert!(x.notes.is_empty(), "{:?}", x.notes);
}
}
#[test]
fn tolerates_android_tricks() {
// Padding-only extra field (as written by old zipalign / gomobile)
// and a fake encryption flag.
let zip = build_zip("classes.dex", b"dex", true, 1, &[0, 0, 0]);
let archive = ZipArchive::parse(zip.into()).unwrap();
let x = archive.extract(0, 1 << 20).unwrap();
assert_eq!(x.data, b"dex");
assert_eq!(x.notes.len(), 1);
}
#[test]
fn writes_readable_zip() {
let zip = write_stored_zip(&[("a", b"1"), ("b/c", b"22")]);
let archive = ZipArchive::parse(zip.into()).unwrap();
assert_eq!(archive.entries.len(), 2);
assert_eq!(archive.extract(1, 100).unwrap().data, b"22");
}
#[test]
fn rejects_garbage() {
assert!(ZipArchive::parse(Arc::from(vec![0u8; 100])).is_err());
let mut zip = build_zip("x", b"y", false, 0, &[]);
let len = zip.len();
zip[len - 6..len - 2].copy_from_slice(&0xffff_fff0u32.to_le_bytes());
assert!(ZipArchive::parse(zip.into()).is_err());
}
}
+503
View File
@@ -0,0 +1,503 @@
//! Turns a selected tree node into displayable content and file information.
use std::io::Cursor;
use std::sync::Arc;
use crate::formats::axml;
use crate::formats::detect::{FileKind, detect};
use crate::formats::dex::access::{Target, flags_to_string};
use crate::formats::dex::smali::class_to_smali;
use crate::formats::dex::{DexFile, descriptor_to_java};
use crate::package::{
ArchiveId, ClassRef, FileId, ManifestInfo, NodeId, NodeKind, Package, PackageKind, sha256_hex,
to_hex,
};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Syntax {
Plain,
Smali,
Xml,
}
pub enum Content {
Text { lines: Vec<String>, syntax: Syntax },
Image { uri: String, bytes: Arc<[u8]> },
Hex(Arc<[u8]>),
Message(String),
}
impl Content {
fn text(text: &str, syntax: Syntax) -> Self {
Content::Text {
lines: text.lines().map(|l| l.replace('\t', " ")).collect(),
syntax,
}
}
}
pub struct InfoSection {
pub title: String,
pub rows: Vec<(String, String)>,
}
impl InfoSection {
fn new(title: impl Into<String>) -> Self {
Self {
title: title.into(),
rows: Vec::new(),
}
}
fn row(&mut self, key: impl Into<String>, value: impl Into<String>) -> &mut Self {
self.rows.push((key.into(), value.into()));
self
}
fn opt(&mut self, key: &str, value: &Option<String>) -> &mut Self {
if let Some(v) = value {
self.row(key, v.clone());
}
self
}
}
pub struct Inspection {
pub node: NodeId,
pub title: String,
pub content: Content,
/// Raw bytes of the selected file, allowing a hex view of any file.
pub raw: Option<Arc<[u8]>>,
pub sections: Vec<InfoSection>,
}
pub fn format_size(bytes: u64) -> String {
const UNITS: [&str; 4] = ["KiB", "MiB", "GiB", "TiB"];
let grouped = group_digits(bytes);
if bytes < 1024 {
return format!("{grouped} bytes");
}
let mut value = bytes as f64;
let mut unit = "";
for u in UNITS {
value /= 1024.0;
unit = u;
if value < 1024.0 {
break;
}
}
format!("{value:.1} {unit} ({grouped} bytes)")
}
pub fn group_digits(n: impl ToString) -> String {
let s = n.to_string();
let mut out = String::with_capacity(s.len() + s.len() / 3);
for (i, c) in s.chars().enumerate() {
if i > 0 && (s.len() - i) % 3 == 0 {
out.push(',');
}
out.push(c);
}
out
}
pub fn inspect(pkg: &Package, node: NodeId) -> Inspection {
let title = pkg.node_path(node);
let mut inspection = Inspection {
node,
title,
content: Content::Message(String::new()),
raw: None,
sections: Vec::new(),
};
match &pkg.nodes[node].kind {
NodeKind::Archive(a) => inspect_archive(pkg, *a, &mut inspection),
NodeKind::SourceRoot(a) => inspect_source_root(pkg, *a, &mut inspection),
NodeKind::FilesRoot(_) | NodeKind::Folder(_) => inspect_folder(pkg, node, &mut inspection),
NodeKind::JavaPackage(name) => inspect_package(pkg, node, name, &mut inspection),
NodeKind::File(f) => inspect_file(pkg, node, *f, &mut inspection),
NodeKind::Class(c) => inspect_class(pkg, *c, &mut inspection),
}
inspection
}
fn descendants(pkg: &Package, node: NodeId) -> impl Iterator<Item = NodeId> + '_ {
let mut stack = vec![node];
std::iter::from_fn(move || {
let id = stack.pop()?;
stack.extend(pkg.nodes[id].children.iter().copied());
Some(id)
})
}
fn manifest_section(m: &ManifestInfo) -> InfoSection {
let mut s = InfoSection::new("Manifest");
s.opt("Package", &m.package)
.opt("Split", &m.split)
.opt("Version name", &m.version_name)
.opt("Version code", &m.version_code)
.opt("Min SDK", &m.min_sdk)
.opt("Target SDK", &m.target_sdk)
.opt("Compile SDK", &m.compile_sdk)
.opt("App label", &m.app_label)
.opt("Application class", &m.app_class)
.opt("Debuggable", &m.debuggable)
.opt("Allow backup", &m.allow_backup);
s.row(
"Components",
format!(
"{} activities, {} services, {} receivers, {} providers",
m.activities, m.services, m.receivers, m.providers
),
);
s
}
fn inspect_archive(pkg: &Package, archive: ArchiveId, out: &mut Inspection) {
let a = &pkg.archives[archive];
let is_outer_xapk = pkg.kind == PackageKind::Xapk && archive == 0;
let file_count = pkg.files.iter().filter(|f| f.archive == archive).count();
let dex: Vec<_> = pkg
.dex_files
.iter()
.filter(|d| d.archive == archive)
.collect();
let mut s = InfoSection::new("Archive");
s.row("Name", a.name.clone()).row(
"Type",
match (is_outer_xapk, a.source_file.is_some()) {
(true, _) => "XAPK bundle",
(false, true) => "APK (inside XAPK)",
(false, false) => "APK",
},
);
if archive == 0 {
s.row("Location", pkg.path.display().to_string());
}
s.row("Size", format_size(a.size))
.row("Files", group_digits(file_count));
if !is_outer_xapk {
s.row("DEX files", dex.len().to_string())
.row("Classes", group_digits(pkg.class_count(archive)));
} else {
s.row("Contained APKs", (pkg.archives.len() - 1).to_string());
}
let mut hashes = InfoSection::new("Hashes");
hashes.row("SHA-256", a.sha256.clone());
let mut text = format!("{}\n\n", a.name);
if let Some(m) = &a.manifest {
let field = |label: &str, v: &Option<String>| {
v.as_ref()
.map(|v| format!("{label:<14}{v}\n"))
.unwrap_or_default()
};
text += &field("Package:", &m.package);
text += &field("Split:", &m.split);
text += &field("Version:", &m.version_name);
text += &field("Version code:", &m.version_code);
text += &field("Min SDK:", &m.min_sdk);
text += &field("Target SDK:", &m.target_sdk);
text.push('\n');
}
if is_outer_xapk {
text += "Contained APKs:\n";
for inner in pkg.archives.iter().skip(1) {
text += &format!(" {} ({})\n", inner.name, format_size(inner.size));
}
} else {
text += &format!("{file_count} files, {} DEX files\n", dex.len());
for d in &dex {
text += &format!(
" {:<16}{} classes, {} methods\n",
d.name,
group_digits(d.dex.class_defs.len()),
group_digits(d.dex.method_count())
);
}
}
if !a.warnings.is_empty() {
text += "\nWarnings:\n";
for w in &a.warnings {
text += &format!(" ⚠ {w}\n");
}
}
out.content = Content::text(&text, Syntax::Plain);
out.sections.push(s);
if let Some(m) = &a.manifest {
out.sections.push(manifest_section(m));
if !m.permissions.is_empty() {
let mut p = InfoSection::new(format!("Permissions ({})", m.permissions.len()));
for perm in &m.permissions {
p.row("", perm.clone());
}
out.sections.push(p);
}
}
out.sections.push(hashes);
push_warnings(&mut out.sections, &a.warnings);
}
fn push_warnings(sections: &mut Vec<InfoSection>, warnings: &[String]) {
if !warnings.is_empty() {
let mut w = InfoSection::new("Warnings");
for warning in warnings {
w.row("⚠", warning.clone());
}
sections.push(w);
}
}
fn dex_section(name: &str, dex: &DexFile) -> InfoSection {
let h = &dex.header;
let mut s = InfoSection::new(name.to_owned());
s.row("Format version", h.version.clone())
.row("Size", format_size(dex.data().len() as u64))
.row("Classes", group_digits(dex.class_defs.len()))
.row("Methods", group_digits(dex.method_count()))
.row("Fields", group_digits(dex.field_count()))
.row("Types", group_digits(dex.type_count()))
.row("Strings", group_digits(dex.string_count()))
.row("Checksum", format!("0x{:08x}", h.checksum))
.row("Signature", to_hex(&h.signature));
for w in &dex.warnings {
s.row("⚠", w.clone());
}
s
}
fn inspect_source_root(pkg: &Package, archive: ArchiveId, out: &mut Inspection) {
let mut text = String::from("Source code (smali disassembly)\n\n");
let mut any = false;
for d in pkg.dex_files.iter().filter(|d| d.archive == archive) {
any = true;
text += &format!(
"{:<16}DEX {} {} classes, {} methods, {} strings\n",
d.name,
d.dex.header.version,
group_digits(d.dex.class_defs.len()),
group_digits(d.dex.method_count()),
group_digits(d.dex.string_count()),
);
out.sections.push(dex_section(&d.name, &d.dex));
}
if !any {
text += "No DEX files were found in this APK.\n";
}
out.content = Content::text(&text, Syntax::Plain);
}
fn inspect_folder(pkg: &Package, node: NodeId, out: &mut Inspection) {
let (mut files, mut size, mut compressed) = (0usize, 0u64, 0u64);
for id in descendants(pkg, node) {
if let NodeKind::File(f) = pkg.nodes[id].kind {
files += 1;
size = size.saturating_add(pkg.files[f].size);
compressed = compressed.saturating_add(pkg.files[f].compressed_size);
}
}
let path = match &pkg.nodes[node].kind {
NodeKind::Folder(p) => p.clone(),
_ => "/".to_owned(),
};
let mut s = InfoSection::new("Folder");
s.row("Path", path)
.row("Files", group_digits(files))
.row("Size", format_size(size))
.row("Compressed", format_size(compressed));
out.sections.push(s);
out.content = Content::Message(format!(
"{} files, {} uncompressed\n\nSelect a file to view its contents.",
group_digits(files),
format_size(size)
));
}
fn inspect_package(pkg: &Package, node: NodeId, name: &str, out: &mut Inspection) {
let classes = descendants(pkg, node)
.filter(|&id| matches!(pkg.nodes[id].kind, NodeKind::Class(_)))
.count();
let mut s = InfoSection::new("Package");
s.row("Name", name.to_owned())
.row("Classes", group_digits(classes));
out.sections.push(s);
out.content = Content::Message(format!(
"package {name}\n{} classes\n\nSelect a class to view its disassembly.",
group_digits(classes)
));
}
fn image_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
image::ImageReader::new(Cursor::new(bytes))
.with_guessed_format()
.ok()?
.into_dimensions()
.ok()
}
fn inspect_file(pkg: &Package, node: NodeId, file: FileId, out: &mut Inspection) {
let entry = &pkg.files[file];
let mut s = InfoSection::new("File");
s.row("Name", entry.name().to_owned())
.row("Path", entry.path.clone())
.row("Archive", pkg.archives[entry.archive].name.clone())
.row("Size", format_size(entry.size))
.row("Compressed", format_size(entry.compressed_size));
if entry.size > 0 {
s.row(
"Ratio",
format!(
"{:.1}%",
entry.compressed_size as f64 * 100.0 / entry.size as f64
),
);
}
s.row("Compression", entry.method.clone())
.row("CRC-32", format!("0x{:08x}", entry.crc32));
if let Some(m) = &entry.modified {
s.row("Modified", m.clone());
}
if let Some(off) = entry.data_offset {
s.row("Data offset", format!("0x{off:x}"));
}
if entry.encrypted {
s.row("⚠ Encrypted flag", "set (Android ignores this flag)");
}
let data = match pkg.read_file(file) {
Ok(d) => d,
Err(e) => {
s.row("⚠ Error", e.clone());
out.sections.push(s);
out.content = Content::Message(format!("This file could not be read:\n\n{e}"));
return;
}
};
if let Some(note) = &data.note {
s.row("⚠ Note", note.clone());
}
let bytes = data.bytes;
let kind = detect(entry.name(), &bytes);
s.row("Type", kind.description(&bytes));
out.sections.push(s);
let mut hashes = InfoSection::new("Hashes");
hashes.row("SHA-256", sha256_hex(&bytes));
out.raw = Some(bytes.clone());
out.content = match kind {
FileKind::Empty => Content::Message("This file is empty.".to_owned()),
FileKind::Dex => match DexFile::parse(bytes.clone()) {
Ok(dex) => {
let section = dex_section("DEX", &dex);
let mut text = String::new();
for (k, v) in &section.rows {
text += &format!("{k:<16}{v}\n");
}
out.sections.push(section);
Content::text(&format!("{}\n\n{text}", entry.path), Syntax::Plain)
}
Err(e) => Content::Message(format!("DEX file could not be parsed: {e}")),
},
FileKind::BinaryXml => match axml::parse(&bytes) {
Ok(doc) => Content::text(&doc.to_xml_string(), Syntax::Xml),
Err(e) => Content::Message(format!("Binary XML could not be decoded: {e}")),
},
FileKind::Image(_) => {
if let Some((w, h)) = image_dimensions(&bytes) {
let mut img = InfoSection::new("Image");
img.row("Dimensions", format!("{w} × {h} px"));
out.sections.push(img);
}
Content::Image {
uri: format!("bytes://{node}/{}", entry.path),
bytes,
}
}
FileKind::Text => {
let syntax = if entry.path.to_ascii_lowercase().ends_with(".xml") {
Syntax::Xml
} else {
Syntax::Plain
};
Content::text(&String::from_utf8_lossy(&bytes), syntax)
}
FileKind::ResourceTable | FileKind::Elf | FileKind::Zip | FileKind::Binary => {
Content::Hex(bytes)
}
};
out.sections.push(hashes);
}
fn inspect_class(pkg: &Package, class: ClassRef, out: &mut Inspection) {
let entry = &pkg.dex_files[class.dex];
let dex = &entry.dex;
let def = &dex.class_defs[class.class_def];
let desc = dex.class_descriptor(def);
let mut s = InfoSection::new("Class");
s.row("Name", descriptor_to_java(&desc))
.row("Descriptor", desc.clone().into_owned());
let flags = flags_to_string(def.access_flags, Target::Class);
s.row(
"Access flags",
if flags.is_empty() {
format!("0x{:x}", def.access_flags)
} else {
format!("{flags} (0x{:x})", def.access_flags)
},
);
if let Some(sup) = def.superclass {
s.row("Superclass", descriptor_to_java(&dex.type_descriptor(sup)));
}
if let Ok(interfaces) = dex.interfaces(def) {
for i in interfaces {
s.row("Implements", descriptor_to_java(&dex.type_descriptor(i)));
}
}
if let Some(src) = def.source_file {
s.row("Source file", dex.string(src).into_owned());
}
s.row(
"DEX file",
format!("{} ({})", entry.name, pkg.archives[entry.archive].name),
);
match dex.class_data(def) {
Ok(data) => {
s.row(
"Fields",
format!(
"{} static, {} instance",
data.static_fields.len(),
data.instance_fields.len()
),
)
.row(
"Methods",
format!(
"{} direct, {} virtual",
data.direct_methods.len(),
data.virtual_methods.len()
),
);
}
Err(e) => {
s.row("⚠ Class data", e.to_string());
}
}
out.sections.push(s);
out.content = Content::text(&class_to_smali(dex, def), Syntax::Smali);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sizes() {
assert_eq!(format_size(12), "12 bytes");
assert_eq!(format_size(1536), "1.5 KiB (1,536 bytes)");
assert_eq!(group_digits(1234567), "1,234,567");
}
}
+7
View File
@@ -0,0 +1,7 @@
//! BogusMips: analyze how far an Android app can be disassembled.
pub mod app;
pub mod formats;
pub mod inspect;
pub mod package;
pub mod ui;
+7 -7
View File
@@ -1,20 +1,20 @@
mod app;
mod ui;
use app::BogusMipsApp;
use bogusmips::app::BogusMipsApp;
fn main() -> eframe::Result {
let initial_file = std::env::args_os().nth(1).map(std::path::PathBuf::from);
let options = eframe::NativeOptions {
viewport: eframe::egui::ViewportBuilder::default()
.with_title("BogusMips")
.with_inner_size([1024.0, 720.0])
.with_min_inner_size([640.0, 400.0]),
.with_inner_size([1280.0, 800.0])
.with_min_inner_size([800.0, 480.0])
.with_drag_and_drop(true),
..Default::default()
};
eframe::run_native(
"BogusMips",
options,
Box::new(|cc| Ok(Box::new(BogusMipsApp::new(cc)))),
Box::new(|cc| Ok(Box::new(BogusMipsApp::new(cc, initial_file)))),
)
}
+433
View File
@@ -0,0 +1,433 @@
//! Loading of `.apk` / `.xapk` files into an in-memory, browsable model.
mod tree;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use sha2::{Digest, Sha256};
use crate::formats::zip::ZipArchive;
use crate::formats::{axml, dex::DexFile};
pub use tree::{ClassRef, Node, NodeKind};
pub type ArchiveId = usize;
pub type FileId = usize;
pub type DexId = usize;
pub type NodeId = usize;
/// Upper bound for a single decompressed entry, protecting against zip bombs.
const MAX_ENTRY_SIZE: u64 = 1 << 30;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PackageKind {
Apk,
Xapk,
}
pub struct Package {
pub path: PathBuf,
pub kind: PackageKind,
pub archives: Vec<Archive>,
pub files: Vec<FileEntry>,
pub dex_files: Vec<DexEntry>,
pub nodes: Vec<Node>,
}
pub struct Archive {
pub name: String,
pub size: u64,
pub sha256: String,
/// The entry this archive was extracted from (for APKs inside an XAPK).
pub source_file: Option<FileId>,
pub manifest: Option<ManifestInfo>,
pub warnings: Vec<String>,
zip: ZipArchive,
}
pub struct FileEntry {
pub archive: ArchiveId,
index: usize,
pub path: String,
pub size: u64,
pub compressed_size: u64,
pub method: String,
pub crc32: u32,
pub modified: Option<String>,
pub encrypted: bool,
pub data_offset: Option<u64>,
}
impl FileEntry {
pub fn name(&self) -> &str {
self.path.rsplit('/').next().unwrap_or(&self.path)
}
}
pub struct DexEntry {
pub archive: ArchiveId,
pub file: FileId,
pub name: String,
pub dex: Arc<DexFile>,
}
#[derive(Debug, Clone, Default)]
pub struct ManifestInfo {
pub package: Option<String>,
pub split: Option<String>,
pub version_code: Option<String>,
pub version_name: Option<String>,
pub min_sdk: Option<String>,
pub target_sdk: Option<String>,
pub compile_sdk: Option<String>,
pub app_label: Option<String>,
pub app_class: Option<String>,
pub debuggable: Option<String>,
pub allow_backup: Option<String>,
pub permissions: Vec<String>,
pub activities: usize,
pub services: usize,
pub receivers: usize,
pub providers: usize,
}
impl ManifestInfo {
fn from_document(doc: &axml::XmlDocument) -> Option<Self> {
let root = doc.root()?;
let attr = |e: &axml::XmlElement, name: &str| e.attr(name).map(str::to_owned);
let mut info = ManifestInfo {
package: attr(root, "package"),
split: attr(root, "split"),
version_code: attr(root, "versionCode"),
version_name: attr(root, "versionName"),
compile_sdk: attr(root, "compileSdkVersion")
.or_else(|| attr(root, "platformBuildVersionCode")),
..Default::default()
};
for child in root.child_elements() {
match child.name.as_str() {
"uses-sdk" => {
info.min_sdk = attr(child, "minSdkVersion");
info.target_sdk = attr(child, "targetSdkVersion");
}
"uses-permission" | "uses-permission-sdk-23" => {
if let Some(name) = child.attr("name") {
info.permissions.push(name.to_owned());
}
}
"application" => {
info.app_label = attr(child, "label");
info.app_class = attr(child, "name");
info.debuggable = attr(child, "debuggable");
info.allow_backup = attr(child, "allowBackup");
for component in child.child_elements() {
match component.name.as_str() {
"activity" | "activity-alias" => info.activities += 1,
"service" => info.services += 1,
"receiver" => info.receivers += 1,
"provider" => info.providers += 1,
_ => {}
}
}
}
_ => {}
}
}
Some(info)
}
}
/// Raw bytes of an archive entry, plus a note if they had to be recovered.
pub struct FileData {
pub bytes: Arc<[u8]>,
pub note: Option<String>,
}
pub fn to_hex(bytes: &[u8]) -> String {
use std::fmt::Write;
bytes
.iter()
.fold(String::with_capacity(bytes.len() * 2), |mut s, b| {
let _ = write!(s, "{b:02x}");
s
})
}
pub fn sha256_hex(data: &[u8]) -> String {
to_hex(&Sha256::digest(data))
}
fn is_dex_name(path: &str) -> Option<u32> {
let rest = path.strip_prefix("classes")?.strip_suffix(".dex")?;
if rest.is_empty() {
Some(1)
} else {
rest.parse().ok().filter(|n| *n >= 2)
}
}
impl Package {
pub fn open(path: &Path) -> Result<Self, String> {
let bytes: Arc<[u8]> = std::fs::read(path)
.map_err(|e| format!("Could not read {}: {e}", path.display()))?
.into();
Self::from_bytes(path, bytes)
}
pub fn from_bytes(path: &Path, bytes: Arc<[u8]>) -> Result<Self, String> {
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string());
let mut pkg = Package {
path: path.to_owned(),
kind: PackageKind::Apk,
archives: Vec::new(),
files: Vec::new(),
dex_files: Vec::new(),
nodes: Vec::new(),
};
let root = pkg
.add_archive(name, bytes, None)
.map_err(|e| format!("{} is not a valid APK/XAPK: {e}", path.display()))?;
let has_manifest = pkg.find_file(root, "AndroidManifest.xml").is_some();
let inner_apks: Vec<FileId> = pkg
.files
.iter()
.enumerate()
.filter(|(_, f)| f.archive == root && f.path.to_ascii_lowercase().ends_with(".apk"))
.map(|(id, _)| id)
.collect();
let is_xapk_ext = path
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("xapk"));
if is_xapk_ext || (!has_manifest && !inner_apks.is_empty()) {
pkg.kind = PackageKind::Xapk;
}
let mut apks = Vec::new();
if pkg.kind == PackageKind::Xapk {
for file in inner_apks {
let entry_name = pkg.files[file].name().to_owned();
let loaded = pkg
.read_file(file)
.and_then(|data| pkg.add_archive(entry_name.clone(), data.bytes, Some(file)));
match loaded {
Ok(id) => apks.push(id),
Err(e) => pkg.archives[root]
.warnings
.push(format!("{entry_name}: {e}")),
}
}
} else {
apks.push(root);
}
for &apk in &apks {
pkg.load_manifest(apk);
pkg.load_dex_files(apk);
}
pkg.nodes = tree::build(&pkg, root, &apks);
Ok(pkg)
}
fn add_archive(
&mut self,
name: String,
bytes: Arc<[u8]>,
source_file: Option<FileId>,
) -> Result<ArchiveId, String> {
let size = bytes.len() as u64;
let sha256 = sha256_hex(&bytes);
let zip = ZipArchive::parse(bytes).map_err(|e| e.to_string())?;
let id = self.archives.len();
let warnings = zip.warnings.clone();
for (index, entry) in zip.entries.iter().enumerate() {
if entry.is_dir() {
continue;
}
self.files.push(FileEntry {
archive: id,
index,
path: entry.name.clone(),
size: entry.size,
compressed_size: entry.compressed_size,
method: entry.method_name(),
crc32: entry.crc32,
modified: entry.modified(),
encrypted: entry.encrypted(),
data_offset: zip.data_offset(entry).ok().map(|o| o as u64),
});
}
self.archives.push(Archive {
name,
size,
sha256,
source_file,
manifest: None,
warnings,
zip,
});
Ok(id)
}
pub fn find_file(&self, archive: ArchiveId, path: &str) -> Option<FileId> {
self.files
.iter()
.position(|f| f.archive == archive && f.path == path)
}
/// Reads and decompresses an entry. Anomalies that Android tolerates (bad
/// CRC, fake encryption flag, wrong sizes) are reported in `note`.
pub fn read_file(&self, id: FileId) -> Result<FileData, String> {
let entry = self.files.get(id).ok_or("invalid file id")?;
let extracted = self.archives[entry.archive]
.zip
.extract(entry.index, MAX_ENTRY_SIZE)
.map_err(|e| e.to_string())?;
Ok(FileData {
bytes: extracted.data.into(),
note: (!extracted.notes.is_empty()).then(|| extracted.notes.join("; ")),
})
}
fn load_manifest(&mut self, archive: ArchiveId) {
let Some(file) = self.find_file(archive, "AndroidManifest.xml") else {
self.archives[archive]
.warnings
.push("AndroidManifest.xml is missing".to_owned());
return;
};
let result = self
.read_file(file)
.and_then(|d| axml::parse(&d.bytes).map_err(|e| e.to_string()));
match result {
Ok(doc) => self.archives[archive].manifest = ManifestInfo::from_document(&doc),
Err(e) => self.archives[archive]
.warnings
.push(format!("AndroidManifest.xml could not be decoded: {e}")),
}
}
fn load_dex_files(&mut self, archive: ArchiveId) {
let mut dex: Vec<(u32, FileId)> = self
.files
.iter()
.enumerate()
.filter(|(_, f)| f.archive == archive)
.filter_map(|(id, f)| is_dex_name(&f.path).map(|n| (n, id)))
.collect();
dex.sort();
for (_, file) in dex {
let name = self.files[file].path.clone();
let parsed = self
.read_file(file)
.and_then(|d| DexFile::parse(d.bytes).map_err(|e| e.to_string()));
match parsed {
Ok(dex) => self.dex_files.push(DexEntry {
archive,
file,
name,
dex: Arc::new(dex),
}),
Err(e) => self.archives[archive]
.warnings
.push(format!("{name} could not be parsed: {e}")),
}
}
}
pub fn root(&self) -> NodeId {
0
}
pub fn class_count(&self, archive: ArchiveId) -> usize {
self.dex_files
.iter()
.filter(|d| d.archive == archive)
.map(|d| d.dex.class_defs.len())
.sum()
}
/// Slash-separated labels from the root to `node`.
pub fn node_path(&self, node: NodeId) -> String {
let mut parts = Vec::new();
let mut current = Some(node);
while let Some(id) = current {
parts.push(self.nodes[id].label.as_str());
current = self.nodes[id].parent;
}
parts.reverse();
parts.join(" / ")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::formats::zip::write_stored_zip;
#[test]
fn opens_xapk_with_split_apks() {
let base = write_stored_zip(&[
("AndroidManifest.xml", b"not binary xml"),
("classes.dex", b"dex\n035\0garbage"),
("res/raw/a.txt", b"hello"),
]);
let split = write_stored_zip(&[("lib/arm64-v8a/libx.so", b"\x7fELF")]);
let xapk = write_stored_zip(&[
("manifest.json", b"{}"),
("com.example.apk", &base),
("config.arm64_v8a.apk", &split),
("Android/obb/com.example/main.1.obb", b"obb"),
]);
let pkg = Package::from_bytes(Path::new("app.xapk"), xapk.into()).unwrap();
assert_eq!(pkg.kind, PackageKind::Xapk);
assert_eq!(pkg.archives.len(), 3);
let root = &pkg.nodes[pkg.root()];
let labels: Vec<&str> = root
.children
.iter()
.map(|&c| pkg.nodes[c].label.as_str())
.collect();
assert_eq!(labels, ["com.example.apk", "config.arm64_v8a.apk", "Files"]);
// Broken manifest and DEX are reported, not fatal.
assert!(
pkg.archives[1]
.warnings
.iter()
.any(|w| w.contains("AndroidManifest"))
);
assert!(
pkg.archives[1]
.warnings
.iter()
.any(|w| w.contains("classes.dex"))
);
let text = pkg.find_file(1, "res/raw/a.txt").unwrap();
assert_eq!(&*pkg.read_file(text).unwrap().bytes, b"hello");
for node in 0..pkg.nodes.len() {
let _ = crate::inspect::inspect(&pkg, node);
}
}
#[test]
fn rejects_non_zip() {
assert!(Package::from_bytes(Path::new("x.apk"), Arc::from(&b"nope"[..])).is_err());
}
#[test]
fn dex_names() {
assert_eq!(is_dex_name("classes.dex"), Some(1));
assert_eq!(is_dex_name("classes2.dex"), Some(2));
assert_eq!(is_dex_name("classes1.dex"), None);
assert_eq!(is_dex_name("assets/classes.dex"), None);
assert_eq!(is_dex_name("classesX.dex"), None);
}
}
+215
View File
@@ -0,0 +1,215 @@
//! Builds the navigation tree shown in the left panel.
use std::collections::BTreeMap;
use super::{ArchiveId, DexId, FileId, NodeId, Package, PackageKind};
/// Hard limit for path / package nesting, protecting the UI against crafted
/// archives with absurdly deep paths.
const MAX_DEPTH: usize = 64;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ClassRef {
pub dex: DexId,
pub class_def: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NodeKind {
Archive(ArchiveId),
SourceRoot(ArchiveId),
FilesRoot(ArchiveId),
Folder(String),
JavaPackage(String),
File(FileId),
Class(ClassRef),
}
impl NodeKind {
pub fn is_container(&self) -> bool {
!matches!(self, NodeKind::File(_) | NodeKind::Class(_))
}
}
#[derive(Debug, Clone)]
pub struct Node {
pub label: String,
pub kind: NodeKind,
pub parent: Option<NodeId>,
pub children: Vec<NodeId>,
}
#[derive(Default)]
struct PackageTree {
packages: BTreeMap<String, PackageTree>,
classes: Vec<(String, ClassRef)>,
}
#[derive(Default)]
struct DirTree {
dirs: BTreeMap<String, DirTree>,
files: Vec<(String, FileId)>,
}
fn split_limited(path: &str) -> Vec<String> {
let mut parts: Vec<String> = path
.split('/')
.filter(|p| !p.is_empty())
.map(str::to_owned)
.collect();
if parts.len() > MAX_DEPTH {
let rest = parts.split_off(MAX_DEPTH - 1).join("/");
parts.push(rest);
}
parts
}
struct Builder {
nodes: Vec<Node>,
}
impl Builder {
fn push(&mut self, label: String, kind: NodeKind, parent: Option<NodeId>) -> NodeId {
let id = self.nodes.len();
self.nodes.push(Node {
label,
kind,
parent,
children: Vec::new(),
});
if let Some(p) = parent {
self.nodes[p].children.push(id);
}
id
}
fn apk(&mut self, pkg: &Package, archive: ArchiveId, parent: Option<NodeId>) -> NodeId {
let node = self.push(
pkg.archives[archive].name.clone(),
NodeKind::Archive(archive),
parent,
);
self.source(pkg, archive, node);
self.files(pkg, archive, node);
node
}
fn source(&mut self, pkg: &Package, archive: ArchiveId, parent: NodeId) {
let root = self.push(
"Source code".to_owned(),
NodeKind::SourceRoot(archive),
Some(parent),
);
let mut tree = PackageTree::default();
for (dex_id, entry) in pkg.dex_files.iter().enumerate() {
if entry.archive != archive {
continue;
}
for (idx, class) in entry.dex.class_defs.iter().enumerate() {
let desc = entry.dex.class_descriptor(class);
let class_ref = ClassRef {
dex: dex_id,
class_def: idx,
};
let inner = desc
.strip_prefix('L')
.and_then(|d| d.strip_suffix(';'))
.filter(|d| !d.is_empty());
let (packages, name) = match inner {
Some(inner) => {
let mut parts = split_limited(inner);
let name = parts.pop().unwrap_or_else(|| inner.to_owned());
(parts, name)
}
None => (vec!["(invalid descriptors)".to_owned()], desc.into_owned()),
};
let mut node = &mut tree;
for p in packages {
node = node.packages.entry(p).or_default();
}
node.classes.push((name, class_ref));
}
}
self.emit_packages(tree, root, "");
}
fn emit_packages(&mut self, tree: PackageTree, parent: NodeId, prefix: &str) {
for (name, mut sub) in tree.packages {
let mut label = name;
// Collapse chains like com -> example -> app into "com.example.app".
while sub.classes.is_empty() && sub.packages.len() == 1 {
let (child_name, child) = sub.packages.pop_first().expect("one child");
label = format!("{label}.{child_name}");
sub = child;
}
let full = if prefix.is_empty() {
label.clone()
} else {
format!("{prefix}.{label}")
};
let node = self.push(label, NodeKind::JavaPackage(full.clone()), Some(parent));
self.emit_packages(sub, node, &full);
}
let mut classes = tree.classes;
classes.sort_by(|a, b| a.0.cmp(&b.0));
for (name, class_ref) in classes {
self.push(name, NodeKind::Class(class_ref), Some(parent));
}
}
fn files(&mut self, pkg: &Package, archive: ArchiveId, parent: NodeId) {
let root = self.push(
"Files".to_owned(),
NodeKind::FilesRoot(archive),
Some(parent),
);
let mut tree = DirTree::default();
for (id, file) in pkg.files.iter().enumerate() {
if file.archive != archive {
continue;
}
let mut parts = split_limited(&file.path);
let name = parts.pop().unwrap_or_else(|| file.path.clone());
let mut node = &mut tree;
for p in parts {
node = node.dirs.entry(p).or_default();
}
node.files.push((name, id));
}
self.emit_dirs(tree, root, "");
}
fn emit_dirs(&mut self, tree: DirTree, parent: NodeId, prefix: &str) {
for (name, sub) in tree.dirs {
let path = format!("{prefix}{name}/");
let node = self.push(name, NodeKind::Folder(path.clone()), Some(parent));
self.emit_dirs(sub, node, &path);
}
let mut files = tree.files;
files.sort_by(|a, b| a.0.cmp(&b.0));
for (name, id) in files {
self.push(name, NodeKind::File(id), Some(parent));
}
}
}
pub fn build(pkg: &Package, root: ArchiveId, apks: &[ArchiveId]) -> Vec<Node> {
let mut b = Builder { nodes: Vec::new() };
match pkg.kind {
PackageKind::Apk => {
b.apk(pkg, root, None);
}
PackageKind::Xapk => {
let node = b.push(
pkg.archives[root].name.clone(),
NodeKind::Archive(root),
None,
);
for &apk in apks {
b.apk(pkg, apk, Some(node));
}
b.files(pkg, root, node);
}
}
b.nodes
}
+129
View File
@@ -0,0 +1,129 @@
//! Center panel: shows the contents of the selected node.
use eframe::egui::{
self, Align, Layout, RichText, ScrollArea, TextStyle, load::Bytes, text::LayoutJob,
};
use super::highlight::{GUTTER, TEXT, append, highlight_into};
use crate::inspect::{Content, Inspection, Syntax};
#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
pub enum ViewMode {
#[default]
Preview,
Hex,
}
pub fn show(ui: &mut egui::Ui, inspection: &Inspection, mode: &mut ViewMode) {
ui.horizontal(|ui| {
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if inspection.raw.is_some() && !matches!(inspection.content, Content::Hex(_)) {
ui.selectable_value(mode, ViewMode::Hex, "Hex");
ui.selectable_value(mode, ViewMode::Preview, "Preview");
}
if let Content::Text { lines, .. } = &inspection.content {
if *mode == ViewMode::Preview
&& ui
.button("Copy")
.on_hover_text("Copy the full text")
.clicked()
{
ui.ctx().copy_text(lines.join("\n"));
}
}
ui.with_layout(Layout::left_to_right(Align::Center), |ui| {
ui.add(egui::Label::new(RichText::new(&inspection.title).strong()).truncate());
});
});
});
ui.separator();
let id = egui::Id::new(("content", inspection.node));
match (&inspection.content, &inspection.raw, *mode) {
(_, Some(raw), ViewMode::Hex) | (Content::Hex(raw), _, _) => hex_view(ui, id, raw),
(Content::Text { lines, syntax }, _, _) => text_view(ui, id, lines, *syntax),
(Content::Image { uri, bytes }, _, _) => {
ScrollArea::both()
.id_salt(id)
.auto_shrink(false)
.show(ui, |ui| {
ui.add_space(8.0);
ui.vertical_centered(|ui| {
ui.add(
egui::Image::from_bytes(uri.clone(), Bytes::Shared(bytes.clone()))
.fit_to_original_size(1.0),
);
});
});
}
(Content::Message(msg), _, _) => {
ui.centered_and_justified(|ui| {
ui.label(RichText::new(msg).color(ui.visuals().weak_text_color()));
});
}
}
}
fn monospace_rows(
ui: &mut egui::Ui,
id: egui::Id,
count: usize,
mut row: impl FnMut(usize, &egui::FontId) -> LayoutJob,
) {
let font = TextStyle::Monospace.resolve(ui.style());
let row_height = ui.fonts(|f| f.row_height(&font));
ui.scope(|ui| {
ui.spacing_mut().item_spacing.y = 0.0;
ScrollArea::both().id_salt(id).auto_shrink(false).show_rows(
ui,
row_height,
count,
|ui, range| {
for i in range {
ui.add(egui::Label::new(row(i, &font)).extend());
}
},
);
});
}
fn text_view(ui: &mut egui::Ui, id: egui::Id, lines: &[String], syntax: Syntax) {
let digits = lines.len().max(1).to_string().len();
monospace_rows(ui, id, lines.len(), |i, font| {
let mut job = LayoutJob::default();
append(&mut job, &format!("{:>digits$} ", i + 1), GUTTER, font);
highlight_into(&mut job, &lines[i], syntax, font);
job
});
}
fn hex_view(ui: &mut egui::Ui, id: egui::Id, data: &[u8]) {
const ASCII: egui::Color32 = egui::Color32::from_rgb(0xce, 0x91, 0x78);
let rows = data.len().div_ceil(16);
monospace_rows(ui, id.with("hex"), rows, |row, font| {
let start = row * 16;
let chunk = &data[start..(start + 16).min(data.len())];
let mut hex = String::with_capacity(16 * 3 + 1);
for (i, b) in chunk.iter().enumerate() {
if i == 8 {
hex.push(' ');
}
hex.push_str(&format!("{b:02x} "));
}
let ascii: String = chunk
.iter()
.map(|&b| {
if b.is_ascii_graphic() || b == b' ' {
b as char
} else {
'.'
}
})
.collect();
let mut job = LayoutJob::default();
append(&mut job, &format!("{start:08x} "), GUTTER, font);
append(&mut job, &format!("{hex:<50}"), TEXT, font);
append(&mut job, &ascii, ASCII, font);
job
});
}
+238
View File
@@ -0,0 +1,238 @@
//! Lightweight, line-based syntax highlighting for smali and XML.
use eframe::egui::text::{LayoutJob, TextFormat};
use eframe::egui::{Color32, FontId};
use crate::inspect::Syntax;
pub const TEXT: Color32 = Color32::from_rgb(0xd4, 0xd4, 0xd4);
pub const GUTTER: Color32 = Color32::from_rgb(0x6e, 0x76, 0x81);
const COMMENT: Color32 = Color32::from_rgb(0x6a, 0x99, 0x55);
const DIRECTIVE: Color32 = Color32::from_rgb(0xc5, 0x86, 0xc0);
const KEYWORD: Color32 = Color32::from_rgb(0x56, 0x9c, 0xd6);
const LABEL: Color32 = Color32::from_rgb(0xdc, 0xdc, 0xaa);
const STRING: Color32 = Color32::from_rgb(0xce, 0x91, 0x78);
const REGISTER: Color32 = Color32::from_rgb(0x9c, 0xdc, 0xfe);
const TYPE: Color32 = Color32::from_rgb(0x4e, 0xc9, 0xb0);
const NUMBER: Color32 = Color32::from_rgb(0xb5, 0xce, 0xa8);
const PUNCT: Color32 = Color32::from_rgb(0x80, 0x80, 0x80);
pub fn append(job: &mut LayoutJob, text: &str, color: Color32, font: &FontId) {
if !text.is_empty() {
job.append(text, 0.0, TextFormat::simple(font.clone(), color));
}
}
pub fn highlight_into(job: &mut LayoutJob, line: &str, syntax: Syntax, font: &FontId) {
match syntax {
Syntax::Plain => append(job, line, TEXT, font),
Syntax::Smali => smali(job, line, font),
Syntax::Xml => xml(job, line, font),
}
}
fn is_word(c: char) -> bool {
c.is_alphanumeric() || matches!(c, '_' | '$' | '-' | '/' | '<' | '>')
}
/// Returns the byte index just past a quoted literal starting at `start`.
fn quoted_end(s: &str, start: usize, quote: char) -> usize {
let mut escaped = false;
for (i, c) in s[start + 1..].char_indices() {
if escaped {
escaped = false;
} else if c == '\\' {
escaped = true;
} else if c == quote {
return start + 1 + i + c.len_utf8();
}
}
s.len()
}
fn smali(job: &mut LayoutJob, line: &str, font: &FontId) {
let trimmed = line.trim_start();
append(job, &line[..line.len() - trimmed.len()], TEXT, font);
if trimmed.starts_with('#') {
append(job, trimmed, COMMENT, font);
return;
}
let first_end = trimmed.find(' ').unwrap_or(trimmed.len());
let (first, rest) = trimmed.split_at(first_end);
let color = match first.chars().next() {
Some('.') => DIRECTIVE,
Some(':') => LABEL,
_ => KEYWORD,
};
append(job, first, color, font);
let s = rest;
let mut i = 0;
let mut plain_start = 0;
let flush = |job: &mut LayoutJob, from: usize, to: usize| append(job, &s[from..to], TEXT, font);
while i < s.len() {
let c = s[i..].chars().next().expect("in bounds");
let prev_is_word = s[..i].chars().next_back().is_some_and(is_word);
let token_end = match c {
'"' => Some((quoted_end(s, i, '"'), STRING)),
'\'' => Some((quoted_end(s, i, '\''), STRING)),
'#' => Some((s.len(), COMMENT)),
':' if !prev_is_word => {
let end = s[i + 1..]
.find(|ch: char| !(ch.is_alphanumeric() || ch == '_'))
.map_or(s.len(), |e| i + 1 + e);
Some((end, LABEL))
}
'L' | '[' if !prev_is_word => {
let start_of_class = s[i..].find(|ch| ch != '[').map(|e| i + e);
match start_of_class {
Some(p) if s[p..].starts_with('L') => s[p..]
.find(';')
.filter(|&e| !s[p..p + e].contains(' '))
.map(|e| (p + e + 1, TYPE)),
Some(p) if c == '[' => {
Some((p + s[p..].chars().next().map_or(0, char::len_utf8), TYPE))
}
_ => None,
}
}
'v' | 'p' if !prev_is_word => {
let digits = s[i + 1..]
.find(|ch: char| !ch.is_ascii_digit())
.unwrap_or(s.len() - i - 1);
let end = i + 1 + digits;
let next_is_word = s[end..].chars().next().is_some_and(is_word);
(digits > 0 && !next_is_word).then_some((end, REGISTER))
}
'-' | '0'..='9' if !prev_is_word => {
let end = s[i + 1..]
.find(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '.'))
.map_or(s.len(), |e| i + 1 + e);
(c != '-' || s[i + 1..].starts_with(|ch: char| ch.is_ascii_digit()))
.then_some((end, NUMBER))
}
_ => None,
};
match token_end {
Some((end, color)) => {
flush(job, plain_start, i);
append(job, &s[i..end], color, font);
i = end;
plain_start = end;
}
None => i += c.len_utf8(),
}
}
flush(job, plain_start, s.len());
}
fn xml(job: &mut LayoutJob, line: &str, font: &FontId) {
let s = line;
let mut i = 0;
let mut plain_start = 0;
let flush = |job: &mut LayoutJob, from: usize, to: usize| append(job, &s[from..to], TEXT, font);
while i < s.len() {
let rest = &s[i..];
let c = rest.chars().next().expect("in bounds");
let token: Option<(usize, Color32)> = if rest.starts_with("<!--") {
Some((rest.find("-->").map_or(s.len(), |e| i + e + 3), COMMENT))
} else if c == '<' {
let punct_len = if rest.starts_with("</") || rest.starts_with("<?") {
2
} else {
1
};
flush(job, plain_start, i);
append(job, &rest[..punct_len], PUNCT, font);
i += punct_len;
plain_start = i;
let name_len = s[i..]
.find(|ch: char| ch.is_whitespace() || matches!(ch, '>' | '/' | '?'))
.unwrap_or(s.len() - i);
Some((i + name_len, KEYWORD))
} else if c == '"' {
Some((quoted_end(s, i, '"'), STRING))
} else if matches!(c, '>' | '/' | '?' | '=') {
Some((i + 1, PUNCT))
} else if c.is_alphabetic()
&& !s[..i]
.chars()
.next_back()
.is_some_and(char::is_alphanumeric)
{
let len = rest
.find(|ch: char| !(ch.is_alphanumeric() || matches!(ch, ':' | '_' | '-' | '.')))
.unwrap_or(rest.len());
rest[len..].starts_with('=').then_some((i + len, REGISTER))
} else {
None
};
match token {
Some((end, color)) => {
flush(job, plain_start, i);
append(job, &s[i..end], color, font);
i = end;
plain_start = end;
}
None => i += c.len_utf8(),
}
}
flush(job, plain_start, s.len());
}
#[cfg(test)]
mod tests {
use super::*;
fn sections(line: &str, syntax: Syntax) -> Vec<(String, Color32)> {
let mut job = LayoutJob::default();
highlight_into(&mut job, line, syntax, &FontId::monospace(12.0));
job.sections
.iter()
.map(|s| (job.text[s.byte_range.clone()].to_owned(), s.format.color))
.collect()
}
#[test]
fn smali_tokens() {
let line = " invoke-virtual {p0, v1}, Lcom/Foo;->bar(I)V # call";
let s = sections(line, Syntax::Smali);
let text: String = s.iter().map(|(t, _)| t.as_str()).collect();
assert_eq!(text, line);
assert!(s.contains(&("invoke-virtual".into(), KEYWORD)));
assert!(s.contains(&("p0".into(), REGISTER)));
assert!(s.contains(&("Lcom/Foo;".into(), TYPE)));
assert!(s.contains(&("# call".into(), COMMENT)));
let s = sections(" const-string v0, \"a\\\"b\"", Syntax::Smali);
assert!(s.contains(&("\"a\\\"b\"".into(), STRING)));
let s = sections(" if-eqz v0, :cond_1a", Syntax::Smali);
assert!(s.contains(&(":cond_1a".into(), LABEL)));
let s = sections(" const/4 v0, -0x1", Syntax::Smali);
assert!(s.contains(&("-0x1".into(), NUMBER)));
}
#[test]
fn xml_tokens() {
let line = "<uses-sdk android:minSdkVersion=\"21\" />";
let s = sections(line, Syntax::Xml);
let text: String = s.iter().map(|(t, _)| t.as_str()).collect();
assert_eq!(text, line);
assert!(s.contains(&("uses-sdk".into(), KEYWORD)));
assert!(s.contains(&("android:minSdkVersion".into(), REGISTER)));
assert!(s.contains(&("\"21\"".into(), STRING)));
}
#[test]
fn handles_unicode_and_garbage() {
for line in [
" ä\"ö", "<", "\"", " :", " L", " [", "#ü", " v", "<!-- x",
] {
for syntax in [Syntax::Smali, Syntax::Xml] {
let s = sections(line, syntax);
let text: String = s.iter().map(|(t, _)| t.as_str()).collect();
assert_eq!(text, line);
}
}
}
}
+45
View File
@@ -0,0 +1,45 @@
//! Right panel: details about the selected node.
use eframe::egui::{self, RichText, ScrollArea};
use crate::inspect::Inspection;
pub fn show(ui: &mut egui::Ui, inspection: Option<&Inspection>) {
ScrollArea::vertical()
.id_salt("info_scroll")
.auto_shrink(false)
.show(ui, |ui| {
let Some(inspection) = inspection else {
ui.label(
RichText::new("Select a file or class to see its details.")
.color(ui.visuals().weak_text_color()),
);
return;
};
for (i, section) in inspection.sections.iter().enumerate() {
egui::CollapsingHeader::new(RichText::new(&section.title).strong())
.id_salt(("info_section", i, &section.title))
.default_open(true)
.show(ui, |ui| {
for (key, value) in &section.rows {
if !key.is_empty() {
ui.label(
RichText::new(key)
.small()
.color(ui.visuals().weak_text_color()),
);
}
let monospace = key.contains("SHA") || key == "Signature";
let text = if monospace {
RichText::new(value).monospace()
} else {
RichText::new(value)
};
ui.add(egui::Label::new(text).wrap());
ui.add_space(4.0);
}
});
ui.add_space(4.0);
}
});
}
+4
View File
@@ -1 +1,5 @@
pub mod content_view;
pub mod highlight;
pub mod info_panel;
pub mod theme;
pub mod tree_view;
+306
View File
@@ -0,0 +1,306 @@
//! Left panel: virtualized tree of the package (source code and files).
use std::collections::HashSet;
use eframe::egui::{
self, Color32, Key, ScrollArea, Sense, TextWrapMode, collapsing_header::paint_default_icon,
text::LayoutJob,
};
use super::highlight::append;
use crate::package::{NodeId, NodeKind, Package};
const INDENT: f32 = 14.0;
pub const FILTER_ID: &str = "tree_filter";
pub struct TreeState {
pub expanded: HashSet<NodeId>,
pub filter: String,
filter_cache: Option<(String, Vec<bool>, usize)>,
scroll_offset: f32,
view_height: f32,
}
impl TreeState {
pub fn new(pkg: &Package) -> Self {
let root = pkg.root();
let mut expanded = HashSet::from([root]);
expanded.extend(pkg.nodes[root].children.iter().copied());
Self {
expanded,
filter: String::new(),
filter_cache: None,
scroll_offset: 0.0,
view_height: 0.0,
}
}
/// Expands all ancestors of `node` so it becomes visible.
pub fn reveal(&mut self, pkg: &Package, node: NodeId) {
let mut current = pkg.nodes[node].parent;
while let Some(id) = current {
self.expanded.insert(id);
current = pkg.nodes[id].parent;
}
}
/// Visibility mask for the current filter: matching nodes plus ancestors.
fn visible_mask(&mut self, pkg: &Package) -> Option<(&[bool], usize)> {
let needle = self.filter.trim().to_lowercase();
if needle.is_empty() {
return None;
}
let stale = self
.filter_cache
.as_ref()
.is_none_or(|(cached, _, _)| *cached != needle);
if stale {
let mut mask = vec![false; pkg.nodes.len()];
let mut matches = 0;
for (id, node) in pkg.nodes.iter().enumerate() {
if node.parent.is_some() && node.label.to_lowercase().contains(&needle) {
matches += 1;
let mut current = Some(id);
while let Some(c) = current {
if mask[c] {
break;
}
mask[c] = true;
current = pkg.nodes[c].parent;
}
}
}
mask[pkg.root()] = true;
self.filter_cache = Some((needle, mask, matches));
}
self.filter_cache
.as_ref()
.map(|(_, mask, matches)| (mask.as_slice(), *matches))
}
}
fn flatten(
pkg: &Package,
expanded: &HashSet<NodeId>,
mask: Option<&[bool]>,
) -> Vec<(NodeId, usize)> {
let mut rows = Vec::new();
let mut stack = vec![(pkg.root(), 0usize)];
while let Some((id, depth)) = stack.pop() {
if mask.is_some_and(|m| !m[id]) {
continue;
}
rows.push((id, depth));
if mask.is_some() || expanded.contains(&id) {
for &child in pkg.nodes[id].children.iter().rev() {
stack.push((child, depth + 1));
}
}
}
rows
}
fn icon(pkg: &Package, id: NodeId, open: bool) -> (&'static str, Color32) {
const FOLDER: Color32 = Color32::from_rgb(0xdc, 0xb6, 0x7a);
const CODE: Color32 = Color32::from_rgb(0x4e, 0xc9, 0xb0);
const GRAY: Color32 = Color32::from_rgb(0xa0, 0xa0, 0xa0);
match &pkg.nodes[id].kind {
NodeKind::Archive(_) => ("📦", Color32::from_rgb(0xc5, 0x86, 0xc0)),
NodeKind::SourceRoot(_) => ("☕", CODE),
NodeKind::FilesRoot(_) => ("🗄", FOLDER),
NodeKind::Folder(_) if open => ("📂", FOLDER),
NodeKind::Folder(_) => ("📁", FOLDER),
NodeKind::JavaPackage(_) => ("🗀", Color32::from_rgb(0x56, 0x9c, 0xd6)),
NodeKind::Class(_) => ("©", CODE),
NodeKind::File(f) => {
let name = pkg.files[*f].path.to_ascii_lowercase();
let ext = name.rsplit('.').next().unwrap_or("");
match ext {
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" => {
("🖼", Color32::from_rgb(0x9c, 0xdc, 0xfe))
}
"dex" | "so" | "odex" | "vdex" | "oat" => {
("⚙", Color32::from_rgb(0xce, 0x91, 0x78))
}
"xml" => ("📝", Color32::from_rgb(0xdc, 0xdc, 0xaa)),
"apk" | "jar" | "zip" => ("📦", Color32::from_rgb(0xc5, 0x86, 0xc0)),
"txt" | "json" | "properties" | "mf" | "sf" | "version" | "kotlin_builtins" => {
("📃", GRAY)
}
_ => ("📄", GRAY),
}
}
}
}
/// Shows the tree. Returns the newly selected node, if the selection changed.
pub fn show(
ui: &mut egui::Ui,
pkg: &Package,
state: &mut TreeState,
selected: Option<NodeId>,
) -> Option<NodeId> {
let mut new_selection = None;
ui.horizontal(|ui| {
let edit = egui::TextEdit::singleline(&mut state.filter)
.id(egui::Id::new(FILTER_ID))
.hint_text("🔍 Filter (⌘F)")
.desired_width(ui.available_width() - 28.0);
ui.add(edit);
if ui
.add_enabled(
!state.filter.is_empty(),
egui::Button::new("✖").frame(false),
)
.on_hover_text("Clear filter")
.clicked()
{
state.filter.clear();
}
});
let expanded_snapshot = std::mem::take(&mut state.expanded);
let (rows, matches) = match state.visible_mask(pkg) {
Some((mask, matches)) => (flatten(pkg, &expanded_snapshot, Some(mask)), Some(matches)),
None => (flatten(pkg, &expanded_snapshot, None), None),
};
state.expanded = expanded_snapshot;
if let Some(matches) = matches {
ui.label(
egui::RichText::new(format!("{matches} matches"))
.small()
.color(ui.visuals().weak_text_color()),
);
}
ui.add_space(2.0);
let row_height = ui.spacing().interact_size.y;
let spacing = ui.spacing().item_spacing.y;
let selected_row = selected.and_then(|s| rows.iter().position(|(id, _)| *id == s));
// Keyboard navigation while no text field has focus.
let mut scroll_to: Option<usize> = None;
if !ui.ctx().wants_keyboard_input() {
let (up, down, left, right) = ui.input(|i| {
(
i.key_pressed(Key::ArrowUp),
i.key_pressed(Key::ArrowDown),
i.key_pressed(Key::ArrowLeft),
i.key_pressed(Key::ArrowRight),
)
});
if let Some(row) = selected_row {
let id = rows[row].0;
let node = &pkg.nodes[id];
if down && row + 1 < rows.len() {
new_selection = Some(rows[row + 1].0);
scroll_to = Some(row + 1);
} else if up && row > 0 {
new_selection = Some(rows[row - 1].0);
scroll_to = Some(row - 1);
} else if right && !node.children.is_empty() {
state.expanded.insert(id);
} else if left && !state.expanded.remove(&id) {
if let Some(parent) = node.parent {
new_selection = Some(parent);
scroll_to = rows.iter().position(|(r, _)| *r == parent);
}
}
} else if (down || up) && !rows.is_empty() {
new_selection = Some(rows[0].0);
scroll_to = Some(0);
}
}
let mut area = ScrollArea::both().id_salt("tree_scroll").auto_shrink(false);
if let Some(row) = scroll_to {
let top = row as f32 * (row_height + spacing);
let bottom = top + row_height;
if top < state.scroll_offset {
area = area.vertical_scroll_offset(top);
} else if bottom > state.scroll_offset + state.view_height {
area = area.vertical_scroll_offset(bottom - state.view_height);
}
}
let output = area.show_rows(ui, row_height, rows.len(), |ui, range| {
ui.style_mut().wrap_mode = Some(TextWrapMode::Extend);
for &(id, depth) in &rows[range] {
let node = &pkg.nodes[id];
let is_open = matches.is_some() || state.expanded.contains(&id);
ui.horizontal(|ui| {
ui.set_min_height(row_height);
ui.add_space(depth as f32 * INDENT);
let toggle_width = ui.spacing().icon_width;
if node.kind.is_container() && !node.children.is_empty() {
let (_, resp) = ui
.allocate_exact_size(egui::vec2(toggle_width, row_height), Sense::click());
paint_default_icon(ui, if is_open { 1.0 } else { 0.0 }, &resp);
if resp.clicked() && !state.expanded.remove(&id) {
state.expanded.insert(id);
}
} else {
ui.add_space(toggle_width);
}
let (glyph, color) = icon(pkg, id, is_open);
let font = egui::TextStyle::Body.resolve(ui.style());
let mut job = LayoutJob::default();
append(&mut job, glyph, color, &font);
append(&mut job, " ", color, &font);
append(&mut job, &node.label, ui.visuals().text_color(), &font);
let resp = ui.add(egui::Button::selectable(selected == Some(id), job));
if resp.clicked() {
new_selection = Some(id);
}
if resp.double_clicked() && node.kind.is_container() && !state.expanded.remove(&id)
{
state.expanded.insert(id);
}
});
}
});
state.scroll_offset = output.state.offset.y;
state.view_height = output.inner_rect.height();
new_selection.filter(|&n| Some(n) != selected)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn flatten_respects_expansion() {
let mut pkg_nodes = Vec::new();
let mk = |label: &str, parent: Option<usize>, kind: NodeKind| crate::package::Node {
label: label.into(),
kind,
parent,
children: Vec::new(),
};
pkg_nodes.push(mk("root", None, NodeKind::Folder("/".into())));
pkg_nodes.push(mk("a", Some(0), NodeKind::Folder("a/".into())));
pkg_nodes.push(mk("b", Some(1), NodeKind::File(0)));
pkg_nodes[0].children = vec![1];
pkg_nodes[1].children = vec![2];
let expanded = HashSet::from([0]);
let pkg = test_package(pkg_nodes);
assert_eq!(flatten(&pkg, &expanded, None), vec![(0, 0), (1, 1)]);
let mask = [true, true, true];
assert_eq!(flatten(&pkg, &expanded, Some(&mask)).len(), 3);
}
fn test_package(nodes: Vec<crate::package::Node>) -> Package {
Package {
path: Default::default(),
kind: crate::package::PackageKind::Apk,
archives: Vec::new(),
files: Vec::new(),
dex_files: Vec::new(),
nodes,
}
}
}