mirror of
https://github.com/jankammerath/BogusMips.git
synced 2026-11-07 10:15:59 -10:00
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:
1 parent
f1ac6a2704
commit
04018695b5
28 files changed
+6892
-40
No files matched your search
Generated
+306
-16
@@ -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]]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 §ion.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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
'\n' => out.push_str(" "),
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(¢ral);
|
||||
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
@@ -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 §ion.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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)))),
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(§ion.title).strong())
|
||||
.id_salt(("info_section", i, §ion.title))
|
||||
.default_open(true)
|
||||
.show(ui, |ui| {
|
||||
for (key, value) in §ion.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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1 +1,5 @@
|
||||
pub mod content_view;
|
||||
pub mod highlight;
|
||||
pub mod info_panel;
|
||||
pub mod theme;
|
||||
pub mod tree_view;
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user