13 Commits
42 changed files with 8662 additions and 819 deletions

No files matched your search

+1 -1
View File
@@ -28,7 +28,7 @@ The format crates and PE engine remain free of filesystem I/O. Native Android ex
## Hard Rules
- Outputs must be byte-identical to the available golden corpus.
- Outputs must be byte-identical to the available golden corpus. (Unless a change that's designed to break the golden corpus, in which case it must have a runtime test to check if it still runnable.)
- Layout heuristics must trial and validate every candidate before accepting it.
- Deterministic parallel and sequential paths must produce identical bytes.
- Folder scanning must not open bulk assets. Windows candidates are `.exe`, `.dll`, and `global-metadata.dat`; Android candidates are `.so` and `global-metadata.dat`. Matching `.exe._` and `.dll._` files are auxiliary payloads and are not counted as skipped targets.
Generated
+93 -70
View File
@@ -4,9 +4,9 @@ version = 4
[[package]]
name = "aes"
version = "0.9.3"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32"
checksum = "fc0fc36d7440d015b45468ba08a21e80e056ebbd014c611c6298bb7503e04bd1"
dependencies = [
"cipher",
"cpubits",
@@ -21,9 +21,9 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.1"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "block-buffer"
@@ -42,9 +42,15 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "cfg-if"
version = "1.0.4"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "cipher"
@@ -58,9 +64,9 @@ dependencies = [
[[package]]
name = "console"
version = "0.16.4"
version = "0.16.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c"
checksum = "e96a4956774c13c126a8b5af4daa79384f4d826534c95a02d76afb39e2ab64e3"
dependencies = [
"encode_unicode",
"libc",
@@ -91,9 +97,9 @@ dependencies = [
[[package]]
name = "crc32fast"
version = "1.5.1"
version = "1.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04"
dependencies = [
"cfg-if",
]
@@ -126,9 +132,9 @@ checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "equivalent"
version = "1.0.2"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "errno"
@@ -186,8 +192,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"wasm-bindgen",
]
[[package]]
@@ -209,18 +217,18 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "hybrid-array"
version = "0.4.14"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
dependencies = [
"typenum",
]
[[package]]
name = "indexmap"
version = "2.14.1"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
@@ -241,9 +249,9 @@ dependencies = [
[[package]]
name = "inout"
version = "0.2.2"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f"
dependencies = [
"hybrid-array",
]
@@ -256,9 +264,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.104"
version = "0.3.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a"
dependencies = [
"cfg-if",
"futures-util",
@@ -267,9 +275,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.189"
version = "0.2.191"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
[[package]]
name = "linux-raw-sys"
@@ -279,9 +287,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "log"
version = "0.4.34"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "memchr"
@@ -291,13 +299,25 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "memmap2"
version = "0.9.11"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
checksum = "6b0ca1155a9ecc25aa12e8313a119f32edf0255fea2f44839c4ac0a12b2ac157"
dependencies = [
"libc",
]
[[package]]
name = "nix"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -354,9 +374,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rustix"
version = "1.1.4"
version = "1.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
dependencies = [
"bitflags",
"errno",
@@ -397,12 +417,12 @@ checksum = "e1a36a382ed65dbcc0ab47fd5e9a94112417ccd34560a392ef3b7b0f0ec39148"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
"syn 3.0.9",
]
[[package]]
name = "senbei-cli"
version = "1.3.3"
version = "1.3.4"
dependencies = [
"senbei-engine",
"senbei-io",
@@ -413,7 +433,7 @@ dependencies = [
[[package]]
name = "senbei-crypto"
version = "1.3.3"
version = "1.3.4"
dependencies = [
"aes",
"thiserror",
@@ -421,7 +441,7 @@ dependencies = [
[[package]]
name = "senbei-elf"
version = "1.3.3"
version = "1.3.4"
dependencies = [
"goblin",
"thiserror",
@@ -429,7 +449,7 @@ dependencies = [
[[package]]
name = "senbei-engine"
version = "1.3.3"
version = "1.3.4"
dependencies = [
"memmap2",
"senbei-crypto",
@@ -444,7 +464,7 @@ dependencies = [
[[package]]
name = "senbei-io"
version = "1.3.3"
version = "1.3.4"
dependencies = [
"anyhow",
"indicatif",
@@ -465,7 +485,7 @@ dependencies = [
[[package]]
name = "senbei-metadata"
version = "1.3.3"
version = "1.3.4"
dependencies = [
"serde",
"thiserror",
@@ -473,7 +493,7 @@ dependencies = [
[[package]]
name = "senbei-pe"
version = "1.3.3"
version = "1.3.4"
dependencies = [
"thiserror",
]
@@ -505,14 +525,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
"syn 3.0.9",
]
[[package]]
name = "serde_json"
version = "1.0.151"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
@@ -523,9 +543,9 @@ dependencies = [
[[package]]
name = "sha2"
version = "0.11.0"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
dependencies = [
"cfg-if",
"cpufeatures",
@@ -557,9 +577,9 @@ dependencies = [
[[package]]
name = "syn"
version = "3.0.4"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
@@ -581,29 +601,29 @@ dependencies = [
[[package]]
name = "thiserror"
version = "2.0.20"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.20"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
"syn 3.0.9",
]
[[package]]
name = "typed-path"
version = "0.12.3"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
[[package]]
name = "typenum"
@@ -613,15 +633,15 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.24"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "unicode-width"
version = "0.2.2"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557"
[[package]]
name = "unit-prefix"
@@ -641,12 +661,11 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.127"
version = "0.2.130"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
@@ -654,9 +673,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.127"
version = "0.2.130"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -664,22 +683,22 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.127"
version = "0.2.130"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 3.0.9",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.127"
version = "0.2.130"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e"
dependencies = [
"unicode-ident",
]
@@ -824,23 +843,27 @@ dependencies = [
[[package]]
name = "zip"
version = "8.6.0"
version = "9.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
checksum = "eaeb7f16d2605b49aef586996ded29e65642fab7259a3077e537351df6d80426"
dependencies = [
"crc32fast",
"flate2",
"getrandom",
"indexmap",
"libc",
"memchr",
"nix",
"typed-path",
"windows-sys",
"zopfli",
]
[[package]]
name = "zlib-rs"
version = "0.6.7"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
[[package]]
name = "zmij"
@@ -850,9 +873,9 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zopfli"
version = "0.8.3"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
dependencies = [
"bumpalo",
"crc32fast",
+3 -3
View File
@@ -15,9 +15,9 @@ exclude = ["senbei-wasm"]
resolver = "2"
[workspace.package]
version = "1.3.3"
version = "1.3.4"
edition = "2024"
rust-version = "1.98.1"
rust-version = "1.99.1"
license = "AGPL-3.0-only"
[workspace.dependencies]
@@ -39,7 +39,7 @@ windows = { version = "0.62", features = [
"Win32_System_Console",
"Win32_System_SystemInformation",
] }
zip = { version = "8", default-features = false, features = ["deflate"] }
zip = { version = "9.3", default-features = false, features = ["deflate"] }
senbei-crypto = { path = "senbei-crypto" }
senbei-elf = { path = "senbei-elf" }
senbei-engine = { path = "senbei-engine" }
+1 -1
View File
@@ -1,5 +1,5 @@
[toolchain]
channel = "1.98.1"
channel = "1.99.1"
# CI invokes rustfmt/clippy through the rustup shim with no setup action, so
# the pinned toolchain must declare its components here — the runner images
# only preinstall them for their default toolchain.
+178 -5
View File
@@ -1,3 +1,4 @@
use senbei_engine::android::ResourceLimits;
use senbei_io::{job, pause, scan};
use std::path::Path;
@@ -10,6 +11,8 @@ fn main() -> std::process::ExitCode {
let mut no_log = false;
let mut verbose = false;
let mut scan_all = false;
let mut strict_integrity = false;
let mut limits = ResourceLimits::default();
while let Some(a) = args.next() {
match a.as_str() {
@@ -26,6 +29,14 @@ fn main() -> std::process::ExitCode {
"--no-pause" => no_pause = true,
"--no-log" => no_log = true,
"--scan-all" => scan_all = true,
"--strict-integrity" => strict_integrity = true,
flag @ ("--max-entry-mib" | "--max-total-mib" | "--max-entries" | "--max-depth") => {
if let Err(message) = set_resource_limit(&mut limits, flag, args.next().as_deref())
{
eprintln!("error: {message}");
return std::process::ExitCode::from(2);
}
}
"--out" => match args.next() {
Some(v) if !v.starts_with('-') => out = Some(v),
_ => {
@@ -60,16 +71,17 @@ fn main() -> std::process::ExitCode {
let p = Path::new(&p);
let out_path = out.as_deref().map(Path::new);
let result = if p.is_dir() {
job::run_folder_opts(
job::run_folder_with_limits(
p,
out_path,
quiet,
verbose,
no_log,
scan_all || scan::scan_all_env(),
&limits,
)
} else {
job::run_file_v(p, out_path, quiet, verbose, no_log)
job::run_file_with_limits(p, out_path, quiet, verbose, no_log, &limits)
};
match result {
Ok(summary) => {
@@ -77,7 +89,7 @@ fn main() -> std::process::ExitCode {
println!("{}", summary.line());
println!("done in {} ms", summary.duration_ms);
}
if summary.errors > 0 { 1 } else { 0 }
exit_code(&summary, strict_integrity)
}
Err(error) => {
if quiet < 2 {
@@ -90,12 +102,56 @@ fn main() -> std::process::ExitCode {
};
pause::maybe_pause(no_pause);
std::process::ExitCode::from(code as u8)
std::process::ExitCode::from(code)
}
/// Process status for a finished run.
///
/// Suspect outputs are still written. By default a suspect file is a warning
/// and the process stays successful; strict mode treats it like an error so a
/// caller can fail the run. An error fails the run either way.
fn exit_code(summary: &job::Summary, strict_integrity: bool) -> u8 {
if summary.errors > 0 || (strict_integrity && summary.suspect > 0) {
1
} else {
0
}
}
fn set_resource_limit(
limits: &mut ResourceLimits,
flag: &str,
value: Option<&str>,
) -> Result<(), String> {
let invalid = || format!("{flag} requires an unsigned integer argument");
let text = value
.filter(|text| !text.is_empty() && text.bytes().all(|byte| byte.is_ascii_digit()))
.ok_or_else(invalid)?;
let value: u64 = text.parse().map_err(|_| invalid())?;
if value == 0 && flag != "--max-depth" {
return Err(format!("{flag} must be greater than zero"));
}
match flag {
"--max-entry-mib" | "--max-total-mib" => {
let bytes = value
.checked_mul(1024 * 1024)
.ok_or_else(|| format!("{flag} exceeds the supported byte range"))?;
if flag == "--max-entry-mib" {
limits.max_entry_bytes = bytes;
} else {
limits.max_total_bytes = bytes;
}
}
"--max-entries" => limits.max_entries = usize::try_from(value).map_err(|_| invalid())?,
"--max-depth" => limits.max_depth = usize::try_from(value).map_err(|_| invalid())?,
_ => return Err(format!("unknown resource option '{flag}'")),
}
Ok(())
}
fn print_help() {
println!(
"senbei <file|folder> [--out DIR] [-v|--verbose] [-q|--quiet]... [--scan-all] [--no-log] [--no-pause] [-V|--version] [-h|--help]"
"senbei <file|folder> [--out DIR] [-v|--verbose] [-q|--quiet]... [--scan-all] [--strict-integrity] [--max-entry-mib N] [--max-total-mib N] [--max-entries N] [--max-depth N] [--no-log] [--no-pause] [-V|--version] [-h|--help]"
);
println!(
" input a Crackproof PE (.exe/.dll), an il2cpp global-metadata.dat,\n\
@@ -107,4 +163,121 @@ fn print_help() {
" --scan-all probe selected .exe/.dll/.so/metadata names below the\n\
\x20 size floor; other filenames remain excluded."
);
println!(
" --strict-integrity\n\
\x20 exit non-zero when any output fails the static integrity\n\
\x20 check (a suspect) or a file errors. Off by default: a\n\
\x20 suspect is still written and the process exits 0 unless\n\
\x20 a file errors. Quiet levels are unchanged."
);
let defaults = ResourceLimits::default();
println!(
" Android resource limits (MiB = 1048576 bytes):\n\
\x20 --max-entry-mib N single archive entry/decoded buffer/output (default {})\n\
\x20 --max-total-mib N cumulative archive bytes and per-engine task budget (default {})\n\
\x20 --max-entries N archive entries/stage-2 records (default {})\n\
\x20 --max-depth N nesting depth, root = 0 (default {})\n\
\x20 Limits are checked before extraction/allocation; they are not a peak-RSS cap.",
defaults.max_entry_bytes / (1024 * 1024),
defaults.max_total_bytes / (1024 * 1024),
defaults.max_entries,
defaults.max_depth,
);
}
#[cfg(test)]
mod strict_integrity {
use super::exit_code;
use senbei_io::job::Summary;
fn summary(errors: usize, suspect: usize) -> Summary {
Summary {
errors,
suspect,
..Summary::default()
}
}
#[test]
fn clean_run_exits_0_in_both_modes() {
let summary = summary(0, 0);
assert_eq!(exit_code(&summary, false), 0);
assert_eq!(exit_code(&summary, true), 0);
}
#[test]
fn errors_exit_1_whether_or_not_strict() {
let summary = summary(2, 0);
assert_eq!(exit_code(&summary, false), 1);
assert_eq!(exit_code(&summary, true), 1);
}
#[test]
fn suspect_exits_0_by_default_and_1_when_strict() {
let summary = summary(0, 1);
assert_eq!(exit_code(&summary, false), 0);
assert_eq!(exit_code(&summary, true), 1);
}
#[test]
fn errors_with_suspects_exit_1_in_both_modes() {
let summary = summary(1, 3);
assert_eq!(exit_code(&summary, false), 1);
assert_eq!(exit_code(&summary, true), 1);
}
}
#[cfg(test)]
mod resource_limits {
use super::*;
#[test]
fn flags_override_only_the_selected_limit() {
let mut limits = ResourceLimits::default();
set_resource_limit(&mut limits, "--max-entry-mib", Some("4096")).unwrap();
assert_eq!(limits.max_entry_bytes, 4096 * 1024 * 1024);
assert_eq!(
limits.max_total_bytes,
ResourceLimits::default().max_total_bytes
);
set_resource_limit(&mut limits, "--max-total-mib", Some("16384")).unwrap();
set_resource_limit(&mut limits, "--max-entries", Some("123")).unwrap();
set_resource_limit(&mut limits, "--max-depth", Some("0")).unwrap();
assert_eq!(limits.max_total_bytes, 16384 * 1024 * 1024);
assert_eq!(limits.max_entries, 123);
assert_eq!(limits.max_depth, 0);
}
#[test]
fn invalid_and_overflowing_limits_are_usage_errors() {
let mut limits = ResourceLimits::default();
for flag in [
"--max-entry-mib",
"--max-total-mib",
"--max-entries",
"--max-depth",
] {
for value in [
None,
Some(""),
Some("-1"),
Some("1.5"),
Some("--no-log"),
Some("18446744073709551616"),
] {
assert!(
set_resource_limit(&mut limits, flag, value).is_err(),
"{flag} {value:?}"
);
}
}
for flag in ["--max-entry-mib", "--max-total-mib", "--max-entries"] {
assert!(set_resource_limit(&mut limits, flag, Some("0")).is_err());
}
assert!(
set_resource_limit(&mut limits, "--max-entry-mib", Some("18446744073709551615"))
.is_err()
);
assert_eq!(limits, ResourceLimits::default());
}
}
+68
View File
@@ -0,0 +1,68 @@
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Barrier};
#[test]
fn folder_runs_preserve_the_host_panic_hook() {
const CHILD: &str = "SENBEI_PANIC_HOOK_TEST_CHILD";
if std::env::var_os(CHILD).is_none() {
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"folder_runs_preserve_the_host_panic_hook",
"--nocapture",
])
.env(CHILD, "1")
.output()
.unwrap();
assert!(
output.status.success(),
"child failed: {}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
return;
}
static CALLS: AtomicUsize = AtomicUsize::new(0);
std::panic::set_hook(Box::new(|_| {
CALLS.fetch_add(1, Ordering::SeqCst);
}));
let root = tempfile::tempdir().unwrap();
// Exercise repeated job lifetimes both before and after engine capture.
for _ in 0..2 {
senbei_io::job::run_folder_opts(root.path(), None, 2, false, true, false).unwrap();
assert!(senbei_engine::catch_unwind_quiet(|| panic!("captured")).is_err());
assert!(std::panic::catch_unwind(|| panic!("host")).is_err());
}
assert_eq!(CALLS.load(Ordering::SeqCst), 2);
let barrier = Arc::new(Barrier::new(2));
let worker_barrier = barrier.clone();
let worker = std::thread::spawn(move || {
senbei_engine::catch_unwind_quiet(|| {
worker_barrier.wait();
worker_barrier.wait();
panic!("captured in another thread");
})
.unwrap_err();
});
barrier.wait();
// A quiet capture in another thread must not silence unrelated panics.
assert!(std::panic::catch_unwind(|| panic!("unrelated host panic")).is_err());
barrier.wait();
worker.join().unwrap();
assert_eq!(CALLS.load(Ordering::SeqCst), 3);
std::thread::scope(|scope| {
for _ in 0..4 {
let path = root.path();
scope.spawn(move || {
senbei_io::job::run_folder_opts(path, None, 2, false, true, false).unwrap();
assert!(
senbei_engine::catch_unwind_quiet(|| panic!("after concurrent job")).is_err()
);
});
}
});
assert_eq!(CALLS.load(Ordering::SeqCst), 3);
}
+107
View File
@@ -0,0 +1,107 @@
use std::process::Command;
fn command() -> Command {
let mut command = Command::new(env!("CARGO_BIN_EXE_senbei"));
command.args(["--no-pause", "--no-log"]);
command
}
#[test]
fn help_lists_resource_limits_and_defaults() {
let output = command().arg("--help").output().unwrap();
assert!(output.status.success());
let text = String::from_utf8(output.stdout).unwrap();
for option in [
"--max-entry-mib",
"--max-total-mib",
"--max-entries",
"--max-depth",
] {
assert!(text.contains(option), "missing {option}");
}
for value in ["2048", "8192", "100000", "32", "not a peak-RSS cap"] {
assert!(text.contains(value), "missing {value}");
}
}
#[test]
fn invalid_limits_exit_with_usage_error() {
for option in [
"--max-entry-mib",
"--max-total-mib",
"--max-entries",
"--max-depth",
] {
for value in [
None,
Some("-1"),
Some("invalid"),
Some("18446744073709551616"),
] {
let mut command = command();
command.arg(option);
if let Some(value) = value {
command.arg(value);
}
let output = command.output().unwrap();
assert_eq!(output.status.code(), Some(2), "{option} {value:?}");
assert!(String::from_utf8_lossy(&output.stderr).contains(option));
}
}
}
#[test]
fn limits_compose_with_strict_and_quiet_modes() {
let input = tempfile::tempdir().unwrap();
let out = tempfile::tempdir().unwrap();
let output = command()
.arg(input.path())
.arg("--out")
.arg(out.path())
.args([
"--max-entry-mib",
"1",
"--max-total-mib",
"2",
"--max-entries",
"1",
"--max-depth",
"0",
"--strict-integrity",
"-q",
"-q",
])
.output()
.unwrap();
assert!(output.status.success(), "{output:?}");
assert!(output.stdout.is_empty());
assert!(output.stderr.is_empty());
}
#[test]
fn zip_entry_limit_rejects_before_central_directory_indexing() {
let temp = tempfile::tempdir().unwrap();
let package = temp.path().join("large.apk");
// A ZIP end record advertising two entries is enough to exercise the
// preflight. Its directory is intentionally absent: the count limit must
// reject it before the ZIP parser tries to read that directory.
let mut bytes = b"PK\x03\x04".to_vec();
let mut end = [0_u8; 22];
end[..4].copy_from_slice(b"PK\x05\x06");
end[8..10].copy_from_slice(&2_u16.to_le_bytes());
end[10..12].copy_from_slice(&2_u16.to_le_bytes());
bytes.extend_from_slice(&end);
std::fs::write(&package, bytes).unwrap();
let output = command()
.arg(&package)
.arg("--out")
.arg(temp.path().join("out"))
.args(["--max-entries", "1"])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(1));
assert!(
String::from_utf8_lossy(&output.stderr).contains("entry limit"),
"{output:?}"
);
}
+193
View File
@@ -0,0 +1,193 @@
//! CLI behavior of `--strict-integrity`.
//!
//! The flag is off by default and only changes the exit status (see the unit
//! tests in `src/main.rs` for the errors-versus-suspect matrix). These tests
//! drive the binary: help text, flag parsing, the error exit, and the quiet
//! contract. The suspect-only exit policy is covered separately by unit tests
//! so these process tests do not depend on the optional protected corpus.
use std::path::Path;
use std::process::Command;
fn senbei() -> Command {
Command::new(env!("CARGO_BIN_EXE_senbei"))
}
fn run(args: &[&str]) -> std::process::Output {
senbei().args(args).output().expect("spawn senbei")
}
fn assert_silent(output: &std::process::Output) {
assert!(
output.stdout.is_empty() && output.stderr.is_empty(),
"quiet>=2 must stay silent, stdout={:?} stderr={:?}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
fn write_plain(dir: &Path) -> std::path::PathBuf {
let path = dir.join("plain.bin");
std::fs::write(&path, b"not a protected image").unwrap();
path
}
fn list_logs(dir: &Path) -> Vec<std::path::PathBuf> {
std::fs::read_dir(dir)
.into_iter()
.flatten()
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("senbei-") && name.ends_with(".log"))
})
.collect()
}
#[test]
fn help_documents_strict_integrity_behavior() {
let output = run(&["--help"]);
assert!(output.status.success());
let text = String::from_utf8(output.stdout).unwrap();
assert!(text.contains("--strict-integrity"));
assert!(text.contains("--quiet"));
assert!(text.contains("exit non-zero"));
assert!(text.contains("suspect"));
assert!(text.contains("Off by default"));
assert!(text.contains("still written"));
assert!(text.contains("exits 0 unless"));
assert!(text.contains("a file errors"));
assert!(text.contains("Quiet levels are unchanged"));
}
#[test]
fn short_help_and_version_ignore_the_flag_position() {
let help = run(&["-h", "--strict-integrity"]);
assert!(help.status.success());
assert!(String::from_utf8_lossy(&help.stdout).contains("--strict-integrity"));
let version = run(&["--version"]);
assert!(version.status.success());
assert!(String::from_utf8_lossy(&version.stdout).starts_with("Senbei "));
}
#[test]
fn unknown_flag_still_exits_2() {
let output = run(&["--strict"]);
assert_eq!(output.status.code(), Some(2));
let err = String::from_utf8_lossy(&output.stderr);
assert!(err.contains("unknown option '--strict'"));
assert!(String::from_utf8_lossy(&output.stdout).contains("--strict-integrity"));
}
#[test]
fn missing_input_exits_2_with_the_flag() {
let output = run(&["--strict-integrity", "--no-pause"]);
assert_eq!(output.status.code(), Some(2));
assert!(String::from_utf8_lossy(&output.stdout).contains("--strict-integrity"));
}
#[test]
fn errors_exit_1_by_default_and_when_strict_and_write_nothing() {
let td = tempfile::tempdir().unwrap();
let input = write_plain(td.path());
for extra in [None, Some("--strict-integrity")] {
let out = td
.path()
.join(if extra.is_some() { "strict" } else { "default" });
let mut args = vec![
input.to_str().unwrap(),
"--out",
out.to_str().unwrap(),
"--no-log",
"--no-pause",
];
if let Some(flag) = extra {
args.push(flag);
}
let output = run(&args);
assert_eq!(output.status.code(), Some(1), "{output:?}");
assert!(
!out.join("plain.unpack.bin").exists(),
"an error must not leave an artifact"
);
let text = String::from_utf8_lossy(&output.stdout);
assert!(text.contains("0 unpacked"));
assert!(text.contains("1 errors"));
assert!(text.contains("0 suspect"));
}
}
#[test]
fn double_quiet_stays_silent_for_errors_in_both_modes() {
let td = tempfile::tempdir().unwrap();
let input = write_plain(td.path());
let out = td.path().join("out");
for extra in [None, Some("--strict-integrity")] {
let mut args = vec![
input.to_str().unwrap(),
"--out",
out.to_str().unwrap(),
"--no-log",
"--no-pause",
"-q",
"-q",
];
if let Some(flag) = extra {
args.push(flag);
}
let output = run(&args);
assert_eq!(output.status.code(), Some(1));
assert_silent(&output);
}
}
#[test]
fn single_quiet_keeps_the_summary_and_the_error() {
let td = tempfile::tempdir().unwrap();
let input = write_plain(td.path());
let out = td.path().join("out");
let output = run(&[
input.to_str().unwrap(),
"--out",
out.to_str().unwrap(),
"--no-log",
"--no-pause",
"-q",
"--strict-integrity",
]);
assert_eq!(output.status.code(), Some(1));
let stdout = String::from_utf8_lossy(&output.stdout);
// One -q only suppresses per-file lines. The banner and the summary still
// print; that is the existing quiet contract and strict mode must not
// change it.
assert!(stdout.contains("Senbei "), "{stdout}");
assert!(stdout.contains("1 errors"), "{stdout}");
assert!(stdout.contains("0 suspect"), "{stdout}");
}
#[test]
fn strict_does_not_change_what_the_log_records() {
let td = tempfile::tempdir().unwrap();
let input = write_plain(td.path());
let out = td.path().join("out");
let output = run(&[
input.to_str().unwrap(),
"--out",
out.to_str().unwrap(),
"--strict-integrity",
"--no-pause",
"-q",
"-q",
]);
assert_eq!(output.status.code(), Some(1));
assert_silent(&output);
let logs = list_logs(&out);
assert_eq!(logs.len(), 1);
let text = std::fs::read_to_string(&logs[0]).unwrap();
assert!(text.contains("summary:"));
assert!(text.contains("1 errors"));
assert!(text.contains("0 suspect"));
}
+3 -2
View File
@@ -3,6 +3,7 @@
mod protector;
pub use protector::{
ContainerHeader, EncodedSegment, Error, HuffmanLzDecoder, Module9bConfig, ProtectedDescriptor,
decode_container, gf32_mul_fixed, transform_segment,
ContainerHeader, EncodedSegment, Error, HuffmanLzDecoder, Module9bConfig, OutputLimit,
ProtectedDescriptor, decode_container, decode_container_with_limit, gf32_mul_fixed,
transform_segment,
};
+358 -11
View File
@@ -14,6 +14,30 @@ pub enum Error {
type Result<T> = std::result::Result<T, Error>;
/// Ceiling for one decoded container or compressed writer.
///
/// `decode_container` stays unlimited so existing callers keep their behavior.
/// Extraction and restoration pass the shared engine entry ceiling here and
/// reject the declared size before the output buffer is reserved.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OutputLimit {
pub max_output_bytes: u64,
}
impl OutputLimit {
fn check(self, bytes: usize, what: &str) -> Result<()> {
let requested = u64::try_from(bytes)
.map_err(|_| Error::Invalid(format!("{what} length exceeds u64")))?;
if requested > self.max_output_bytes {
return invalid(format!(
"{what} of {requested} bytes exceeds the {limit}-byte output ceiling",
limit = self.max_output_bytes
));
}
Ok(())
}
}
fn invalid<T>(message: impl Into<String>) -> Result<T> {
Err(Error::Invalid(message.into()))
}
@@ -417,7 +441,24 @@ impl HuffmanLzDecoder {
/// Decode one compressed writer payload to its exact expected size.
pub fn decode(&self, source: &[u8], output_size: usize) -> Result<Vec<u8>> {
let mut output = vec![0_u8; output_size];
self.decode_limited(source, output_size, None)
}
/// Decode one writer, rejecting `output_size` before its buffer is reserved.
pub fn decode_limited(
&self,
source: &[u8],
output_size: usize,
limit: Option<OutputLimit>,
) -> Result<Vec<u8>> {
if let Some(limit) = limit {
limit.check(output_size, "compressed writer output")?;
}
let mut output = Vec::new();
output.try_reserve_exact(output_size).map_err(|_| {
Error::Invalid("compressed writer output reservation failed".to_owned())
})?;
output.resize(output_size, 0);
let mut source_pos = 0_usize;
let mut bit_buffer = 0_u64;
let mut available = 0_u8;
@@ -548,7 +589,11 @@ pub fn transform_segment(
aes_key: &[u8; 32],
decrypt_aes: bool,
) -> Result<Vec<u8>> {
let mut transformed = data.to_vec();
let mut transformed = Vec::new();
transformed
.try_reserve_exact(data.len())
.map_err(|_| Error::Invalid("segment reservation failed".to_owned()))?;
transformed.extend_from_slice(data);
let mut state = seed;
let mut left = 0xe34e_ac63_u32;
let mut right = 0x07b4_8238_u32;
@@ -590,11 +635,31 @@ pub fn transform_segment(
/// Decode one complete protector container into its flat output buffer.
///
/// This is the static equivalent of the decoder entrypoint embedded in Stage
/// 2 and in each nested interpreter module.
/// 2 and in each nested interpreter module. The declared output size is not
/// capped; [`decode_container_with_limit`] applies the shared entry ceiling.
pub fn decode_container(
data: &[u8],
config: &Module9bConfig,
expected_size: usize,
) -> Result<Vec<u8>> {
decode_container_inner(data, config, expected_size, None)
}
/// [`decode_container`] with a ceiling checked before the output is reserved.
pub fn decode_container_with_limit(
data: &[u8],
config: &Module9bConfig,
expected_size: usize,
limit: OutputLimit,
) -> Result<Vec<u8>> {
decode_container_inner(data, config, expected_size, Some(limit))
}
fn decode_container_inner(
data: &[u8],
config: &Module9bConfig,
expected_size: usize,
limit: Option<OutputLimit>,
) -> Result<Vec<u8>> {
let header = ContainerHeader::parse(data, 0, config.container_seed)?;
let header_size = usize::try_from(header.output_size)
@@ -604,9 +669,20 @@ pub fn decode_container(
"container output size 0x{header_size:x} != expected 0x{expected_size:x}"
));
}
if let Some(limit) = limit {
// Both declared sizes are rejected before the flat output exists, so a
// hostile container header cannot force the allocation by disagreeing
// with the record that pointed at it.
limit.check(header_size, "container output")?;
limit.check(expected_size, "container output")?;
}
let decoder = HuffmanLzDecoder::new(&header.tree)?;
let decrypt_aes = !(config.skip_aes || header.skip_aes);
let mut output = vec![0_u8; expected_size];
let mut output = Vec::new();
output
.try_reserve_exact(expected_size)
.map_err(|_| Error::Invalid("container output reservation failed".to_owned()))?;
output.resize(expected_size, 0);
for (segment_index, encoded) in header.segments.iter().enumerate() {
let start = header
@@ -614,6 +690,9 @@ pub fn decode_container(
.checked_add(encoded.offset as usize)
.ok_or_else(|| Error::Invalid("encoded segment start overflow".to_owned()))?;
let encoded_data = range(data, start, encoded.size as usize)?;
if let Some(limit) = limit {
limit.check(encoded.size as usize, "encoded segment")?;
}
let transformed = transform_segment(
encoded_data,
config.container_seed,
@@ -662,18 +741,28 @@ pub fn decode_container(
));
}
let source = &transformed[data_cursor..encoded_end];
let decoded = if encoded_size == output_size {
None
} else {
Some(decoder.decode(source, output_size)?)
};
let decoded = decoded.as_deref().unwrap_or(source);
if let Some(limit) = limit {
limit.check(encoded_size, "writer source")?;
limit.check(output_size, "writer destination")?;
}
let target = base_offset
.checked_add(output_offset)
.ok_or_else(|| Error::Invalid("writer target offset overflow".to_owned()))?;
let target_end = target
.checked_add(decoded.len())
.checked_add(output_size)
.ok_or_else(|| Error::Invalid("writer target end overflow".to_owned()))?;
if target_end > output.len() {
return invalid(format!(
"segment {segment_index} writer {writer_index} target 0x{target:x}..0x{target_end:x} is outside the 0x{:x}-byte container",
output.len()
));
}
let decoded = if encoded_size == output_size {
None
} else {
Some(decoder.decode_limited(source, output_size, limit)?)
};
let decoded = decoded.as_deref().unwrap_or(source);
let destination = output.get_mut(target..target_end).ok_or_else(|| {
Error::Invalid(format!(
"segment {segment_index} writer {writer_index} target is out of range"
@@ -709,4 +798,262 @@ mod tests {
fn descriptor_rejects_truncated_input() {
assert!(ProtectedDescriptor::decrypt(&[0_u8; 16], 1).is_err());
}
/// A container whose declared output is past the ceiling.
///
/// Parsing succeeds. Decoding must fail on that declared size before the
/// flat output is reserved, so the failure mentions the output ceiling and
/// not a later segment or writer error. The exact ceiling is allowed
/// through that check.
#[test]
fn oversized_container_is_rejected_before_its_output_is_reserved() {
let config = empty_config();
let over = synthetic_container(&config, 5, None);
let error = decode_container_with_limit(
&over,
&config,
5,
OutputLimit {
max_output_bytes: 4,
},
)
.expect_err("five-byte output is over the four-byte ceiling");
assert!(
error.to_string().contains("container output"),
"unexpected error: {error}"
);
let boundary = synthetic_container(&config, 4, None);
let error = decode_container_with_limit(
&boundary,
&config,
4,
OutputLimit {
max_output_bytes: 4,
},
)
.expect_err("the boundary container has no usable segment body");
assert!(
!error.to_string().contains("output ceiling"),
"the boundary size must pass the ceiling: {error}"
);
}
/// The container itself is within the ceiling. Its writer declares a
/// decoded length past that ceiling and must be rejected before Huffman
/// decoding reserves a buffer of that length.
#[test]
fn oversized_writer_is_rejected_before_decompression() {
let config = empty_config();
let data = synthetic_container(&config, 8, Some((0, 128)));
let error = decode_container_with_limit(
&data,
&config,
8,
OutputLimit {
// The segment body is 36 bytes, under this ceiling. The writer
// declares 128 decoded bytes, which is over it.
max_output_bytes: 64,
},
)
.expect_err("writer destination exceeds the container ceiling");
assert!(
error.to_string().contains("writer destination"),
"unexpected error: {error}"
);
}
/// The declared writer length is within the entry ceiling and would be a
/// legal allocation on its own. It still starts past the flat container,
/// so the range check rejects it before Huffman decoding.
#[test]
fn writer_inside_the_entry_cap_but_outside_the_container_is_rejected_first() {
let config = empty_config();
let data = synthetic_container(&config, 8, Some((8, 4)));
let error = decode_container_with_limit(
&data,
&config,
8,
OutputLimit {
max_output_bytes: 64,
},
)
.expect_err("target 8..12 is outside an 8-byte container");
assert!(
error.to_string().contains("outside"),
"unexpected error: {error}"
);
}
/// A one-byte writer that ends exactly at the container length is in range.
/// It is uncompressed, so success does not depend on a Huffman payload.
#[test]
fn writer_ending_at_the_container_length_is_accepted() {
let config = empty_config();
let data = synthetic_container(&config, 8, Some((7, 1)));
let decoded = decode_container_with_limit(
&data,
&config,
8,
OutputLimit {
max_output_bytes: 64,
},
)
.expect("a writer ending at the container length is in range");
assert_eq!(decoded.len(), 8);
}
fn empty_config() -> Module9bConfig {
Module9bConfig {
header_seed: 0,
container_seed: 0,
aes_key: [0; 32],
skip_aes: true,
schedule_offset: 0,
}
}
/// A container with one segment.
///
/// `writer_output` replaces the segment body with a transformed writer
/// record declaring that decoded length. `None` leaves a four-byte body
/// that is only there so the header parses.
fn synthetic_container(
config: &Module9bConfig,
output_size: u32,
writer_output: Option<(u32, u32)>,
) -> Vec<u8> {
let seed = config.container_seed;
let seed_square = seed.wrapping_mul(seed);
let state = seed_square.wrapping_shr(17) ^ seed_square.wrapping_shl(11);
let raw0 = gf32_unmultiply(
output_size
.wrapping_sub(0xa21d_fb3a_u32.wrapping_shl(state & 7))
.wrapping_sub(state.wrapping_mul(0xf87b_337c)),
);
// One segment, skip_aes = 1.
let flag_word = 1_u32 | (1_u32 << 8);
let raw1 = gf32_unmultiply(
flag_word
^ state
.wrapping_add(0xbd19_c63c)
.wrapping_add(0x416e_2af2_u32.wrapping_shr(state & 0x0d)),
);
let mut tree = vec![0_u8; 256 * 3];
tree[1] = 0x80;
tree[2] = 1;
let tree_state = state.wrapping_add(0xf1cb_5b81).wrapping_mul(state);
let tree_delta = tree_state.wrapping_sub(0x23b3_2203_u32.wrapping_mul(state));
// Stored tree bytes are decrypted a word at a time and then adjusted a
// byte at a time, so the byte adjustment has to be undone first.
for (index, byte) in tree.iter_mut().enumerate() {
let shift = (index & 0x1b) as u32;
let left = gf32_mul_fixed(tree_state.wrapping_shl(shift));
let right = tree_delta.wrapping_shr((index & 0x17) as u32);
let adjustment = left.wrapping_sub(right).wrapping_shr((index & 0x1f) as u32);
*byte = byte.wrapping_sub(adjustment as u8);
}
let raw2 = gf32_unmultiply(
(tree.len() as u32)
.wrapping_add(state ^ 0x3b2b_f538)
.wrapping_sub(0x643a_3a3b_u32.wrapping_shl(state & 0x0b)),
);
let mut header = Vec::new();
header.extend_from_slice(&raw0.to_le_bytes());
header.extend_from_slice(&raw1.to_le_bytes());
header.extend_from_slice(&raw2.to_le_bytes());
let mut stored_tree = tree;
for offset in (0..stored_tree.len() & !3).step_by(4) {
let value = u32::from_le_bytes(stored_tree[offset..offset + 4].try_into().unwrap());
stored_tree[offset..offset + 4].copy_from_slice(&gf32_unmultiply(value).to_le_bytes());
}
header.extend_from_slice(&stored_tree);
let body = match writer_output {
Some((writer_offset, writer_output)) => {
writer_body(config, writer_offset, writer_output)
}
None => vec![0_u8; 4],
};
let segment_offset = (header.len() + 8) as u32;
let table_state = state.wrapping_add(0xb31f_451c).wrapping_mul(state);
let table_xor = table_state.wrapping_shl(3);
let table_add = table_state.wrapping_sub(0x822f_e82d_u32.wrapping_mul(state));
for (index, plain) in [segment_offset, body.len() as u32].into_iter().enumerate() {
let adjustment = table_add.wrapping_shr((((index * 4) & 7) + 5) as u32);
// decode is `gf(encoded ^ xor) + adjustment`, so the stored word
// is `ungf(plain - adjustment) ^ xor`.
let encoded = gf32_unmultiply(plain.wrapping_sub(adjustment)) ^ table_xor;
header.extend_from_slice(&encoded.to_le_bytes());
}
header.extend_from_slice(&body);
header
}
/// Segment body that `transform_segment` turns into one writer record.
fn writer_body(config: &Module9bConfig, writer_offset: u32, writer_output: u32) -> Vec<u8> {
let mut plain = vec![0_u8; 36];
plain[..4].copy_from_slice(&0_u32.to_le_bytes());
plain[4..8].copy_from_slice(&1_u32.to_le_bytes());
plain[8..12].copy_from_slice(&16_u32.to_le_bytes());
plain[12..16].copy_from_slice(&32_u32.to_le_bytes());
plain[16..20].copy_from_slice(&writer_offset.to_le_bytes());
plain[20..24].copy_from_slice(&writer_output.to_le_bytes());
plain[24..28].copy_from_slice(&1_u32.to_le_bytes());
inverse_transform_segment(&plain, config.container_seed)
}
/// Invert [`transform_segment`] for `decrypt_aes = false`.
///
/// The forward routine adds and xors a state that does not depend on the
/// ciphertext, so replaying that state and undoing the arithmetic recovers
/// the input that decodes to `plain`.
fn inverse_transform_segment(plain: &[u8], seed: u32) -> Vec<u8> {
let mut encoded = plain.to_vec();
let mut state = seed;
let mut left = 0xe34e_ac63_u32;
let mut right = 0x07b4_8238_u32;
for (index, chunk) in encoded.as_chunks_mut::<4>().0.iter_mut().enumerate() {
let index32 = index as u32;
left = state
.wrapping_add(0x72f6_fcbe)
.wrapping_add(left.wrapping_add(0x4f8b_1bca).wrapping_mul(left))
.wrapping_shr(index32.wrapping_mul(index32) & 0x0f);
right = state
.wrapping_sub(0x71b6_a98d)
.wrapping_add(right.wrapping_sub(0x1605_a81c).wrapping_mul(right))
.wrapping_shl(index32 & 7);
state = left ^ right;
let mut value = u32::from_le_bytes(*chunk);
value ^= state;
value = value.wrapping_add(state);
value ^= 0xaf57_f7fb_u32.wrapping_mul(index32 & 3);
value = value.wrapping_sub(0xb43b_9baf_u32.wrapping_mul(index32 & 0x0d));
chunk.copy_from_slice(&value.to_le_bytes());
}
encoded
}
fn gf32_unmultiply(encoded: u32) -> u32 {
// Multiply by the inverse of the fixed element 0x94511dd2.
let mut multiplier = GF32_INVERSE;
let mut value = encoded;
let mut result = 0_u32;
while multiplier != 0 {
if multiplier & 1 != 0 {
result ^= value;
}
let carry = value >> 31;
value = value.wrapping_shl(1);
if carry != 0 {
value ^= 0x5793_57eb;
}
multiplier >>= 1;
}
result
}
/// `gf32_mul_fixed(GF32_INVERSE) == 1`, so this undoes that multiply.
const GF32_INVERSE: u32 = 0xdc49_cd06;
}
@@ -44,6 +44,8 @@ pub enum Error {
},
#[error("{0}")]
Invalid(String),
#[error(transparent)]
Limit(#[from] crate::android::LimitExceeded),
}
impl Error {
+2 -2
View File
@@ -6,7 +6,7 @@ mod stage1;
mod stream;
pub use error::Error;
pub use pipeline::{ExtractOptions, extract_stage2};
pub use probe::is_protected_libil2cpp;
pub use pipeline::{ExtractOptions, extract_stage2, extract_stage2_with_limits};
pub use probe::{is_protected_libil2cpp, probe_libil2cpp_with_limits};
pub use report::ExtractionReport;
pub use stage1::{DEFAULT_CIPHER_CONSTANT, DEFAULT_OUTER_SIZE};
+261 -9
View File
@@ -3,7 +3,9 @@ use std::fs::File;
use std::path::{Path, PathBuf};
use memmap2::MmapOptions;
use senbei_crypto::android::{Module9bConfig, decode_container};
use senbei_crypto::android::{Module9bConfig, decode_container_with_limit};
use crate::android::{ByteBudget, EntryBudget, ResourceLimits};
use serde_json::to_vec_pretty;
use super::super::common;
@@ -61,6 +63,13 @@ struct ArtifactSpec<'a> {
classification: &'a str,
}
struct DecodeBudget {
limits: ResourceLimits,
bytes: ByteBudget,
records: EntryBudget,
config: Module9bConfig,
}
struct Extractor {
output_dir: PathBuf,
streams: Vec<StreamReport>,
@@ -70,6 +79,20 @@ struct Extractor {
}
pub fn extract_stage2(options: &ExtractOptions) -> Result<ExtractionReport> {
extract_stage2_with_limits(options, &ResourceLimits::default())
}
/// [`extract_stage2`] with explicit ceilings for decoded records and nesting.
///
/// `limits.max_entries` counts decoded stage-2 records. `limits.max_depth`
/// counts nested interpreter streams, with the bootstrap stream at depth 0.
/// `limits.max_entry_bytes` bounds each decoded container and its writers, and
/// `limits.max_total_bytes` sums those decoded payloads for this call. Neither
/// budget is a peak resident-memory measurement.
pub fn extract_stage2_with_limits(
options: &ExtractOptions,
limits: &ResourceLimits,
) -> Result<ExtractionReport> {
let input_path = absolute(&options.input)?;
let output_dir = absolute(&options.output_dir)?;
if !input_path.is_file() {
@@ -98,6 +121,10 @@ pub fn extract_stage2(options: &ExtractOptions) -> Result<ExtractionReport> {
&input_path,
options.outer_size,
options.cipher_constant,
// Stage 1 plaintext is the first thing this task retains. Its copy
// is capped by both the single-entry ceiling and whatever remains of
// the task total, which is the whole total at this point.
Some(limits.max_entry_bytes.min(limits.max_total_bytes)),
)?;
if let Some(stage2_output) = &options.stage2_output {
write_atomic(&absolute(stage2_output)?, &stage1.plaintext)?;
@@ -119,13 +146,22 @@ pub fn extract_stage2(options: &ExtractOptions) -> Result<ExtractionReport> {
registry: BTreeMap::new(),
seen_streams: HashSet::new(),
};
let mut budget = DecodeBudget {
limits: *limits,
bytes: ByteBudget::new(limits),
records: EntryBudget::new(limits),
config: core_config,
};
budget
.bytes
.charge(u64::try_from(stage1.plaintext.len()).unwrap_or(u64::MAX))?;
extractor.extract_stream(
bootstrap,
0xe2,
0,
None,
Some(stage1.remaining_file_offset),
core_config,
&mut budget,
)?;
let module_registry = extractor
@@ -170,8 +206,9 @@ impl Extractor {
depth: usize,
parent: Option<StreamParent>,
source_file_offset: Option<usize>,
config: Module9bConfig,
budget: &mut DecodeBudget,
) -> Result<()> {
budget.limits.check_depth(depth)?;
let digest = sha256(stream);
if !self.seen_streams.insert((stream_id, digest.clone())) {
return Ok(());
@@ -200,10 +237,15 @@ impl Extractor {
"decoded_interpreter"
},
(depth != 0).then_some(stream_id),
&config,
&budget.config,
),
records: Vec::with_capacity(records.len()),
records: Vec::new(),
};
budget.records.charge(records.len())?;
stream_report
.records
.try_reserve_exact(records.len())
.map_err(|_| Error::Invalid("stage-2 record report reservation failed".to_owned()))?;
let mut direct_records = Vec::new();
let mut modules_at_level = BTreeSet::new();
@@ -213,8 +255,16 @@ impl Extractor {
let mut metadata_data = None;
if !record.direct() && record.image_size != 0 {
// Charge the declared size before the decoder reserves it.
// A metadata-only record is charged below, on the same rule.
budget.bytes.charge(u64::from(record.image_size))?;
let image_source = record_tail(stream, record.image_offset)?;
let image = decode_container(image_source, &config, record.image_size as usize)
let image = decode_container_with_limit(
image_source,
&budget.config,
record.image_size as usize,
budget.limits.output_limit(),
)
.map_err(|source| Error::RecordDecode {
depth,
stream_id,
@@ -243,9 +293,14 @@ impl Extractor {
image_data = Some((image, artifact));
}
if record.metadata_size != 0 {
budget.bytes.charge(u64::from(record.metadata_size))?;
let metadata_source = record_tail(stream, record.metadata_offset)?;
let metadata =
decode_container(metadata_source, &config, record.metadata_size as usize)
let metadata = decode_container_with_limit(
metadata_source,
&budget.config,
record.metadata_size as usize,
budget.limits.output_limit(),
)
.map_err(|source| Error::RecordDecode {
depth,
stream_id,
@@ -339,14 +394,17 @@ impl Extractor {
source,
}
})?;
budget.limits.check_depth(depth + 1)?;
let parent_config = std::mem::replace(&mut budget.config, interpreter_config);
self.extract_stream(
child_data,
next_stream_id,
depth + 1,
Some(parent),
None,
interpreter_config,
budget,
)?;
budget.config = parent_config;
}
Ok(())
}
@@ -506,3 +564,197 @@ fn absolute(path: &Path) -> Result<PathBuf> {
fn sha256(data: &[u8]) -> String {
common::sha256(data)
}
#[cfg(test)]
mod limit_tests {
use super::*;
use crate::android::LimitExceeded;
fn config() -> Module9bConfig {
Module9bConfig {
header_seed: 0,
container_seed: 0,
aes_key: [0; 32],
skip_aes: true,
schedule_offset: 0,
}
}
fn tight(entries: usize, depth: usize) -> ResourceLimits {
ResourceLimits {
max_entry_bytes: 1024,
max_total_bytes: 1024,
max_entries: entries,
max_depth: depth,
}
}
/// One direct record whose payload is `child`.
///
/// The command is `stream_id + 0x10`, which is the id a nested stream uses
/// for its interpreter. The payload begins immediately after the single
/// descriptor, so parsing stops at that boundary.
fn direct_stream(stream_id: u32, child: &[u8]) -> Vec<u8> {
let key = stream_id.wrapping_mul(0x9d32_3cd7);
let shift = stream_id & 7;
let base = (key >> shift)
.wrapping_add(0x5e72_7d74)
.wrapping_add(key.wrapping_shl(stream_id & 0xb))
.wrapping_add(0xf71e_3005);
// record_state = plain1 + base, and plain1 is chosen so that state is
// zero. That keeps the record cipher's index mask zero as well.
let plain1 = 0_u32.wrapping_sub(base);
let cipher0 =
unmultiply(1_u32 ^ 0xebe8_1dba_u32.wrapping_add(base)).wrapping_sub(0xcbf0_c1d8);
let cipher1 = unmultiply(plain1 ^ 0xebe8_1dba_u32.wrapping_mul(5).wrapping_add(base))
.wrapping_sub(cipher0);
let mut stream = Vec::new();
stream.extend_from_slice(&cipher0.to_le_bytes());
stream.extend_from_slice(&cipher1.to_le_bytes());
// `command - 0x10` is the interpreter id a nested stream looks up.
// Using `stream_id + 0x10` makes a lone record direct, because that
// interpreter was not decoded from this same stream.
let command = stream_id.wrapping_add(0x10);
let words = [
command,
super::super::stream::DIRECT_FLAG,
(8 + super::super::stream::RECORD_SIZE) as u32,
child.len() as u32,
0,
0,
command,
0,
0,
];
stream.extend_from_slice(&encrypt_record(&words));
stream.extend_from_slice(child);
stream
}
fn encrypt_record(words: &[u32]) -> Vec<u8> {
// Inverse of `decrypt_record` at record state zero: replay its
// feedback from each ciphertext word, then undo the field multiply.
let mut feedback = 0xf02f_7685_u32;
let mut accumulator = 0x7993_4cf6_u32;
let mut raw = vec![0_u8; super::super::stream::RECORD_SIZE];
for (word_index, plain) in words.iter().copied().enumerate() {
feedback = feedback.wrapping_mul(feedback);
let mixed = plain
.wrapping_sub(accumulator)
.wrapping_add(0 >> ((word_index * 4 + 3) & 5));
let cipher = unmultiply(mixed) ^ (feedback >> 3);
raw[word_index * 4..word_index * 4 + 4].copy_from_slice(&cipher.to_le_bytes());
accumulator = accumulator.wrapping_add(0xe64d_33d8);
feedback = cipher;
}
raw
}
fn unmultiply(encoded: u32) -> u32 {
let mut multiplier = 0xdc49_cd06_u32;
let mut value = encoded;
let mut result = 0_u32;
while multiplier != 0 {
if multiplier & 1 != 0 {
result ^= value;
}
let carry = value >> 31;
value = value.wrapping_shl(1);
if carry != 0 {
value ^= 0x5793_57eb;
}
multiplier >>= 1;
}
result
}
fn extractor() -> Extractor {
Extractor {
output_dir: PathBuf::from("."),
streams: Vec::new(),
artifacts: Vec::new(),
registry: BTreeMap::new(),
seen_streams: HashSet::new(),
}
}
fn budget(entries: usize, depth: usize) -> DecodeBudget {
let limits = tight(entries, depth);
DecodeBudget {
limits,
bytes: ByteBudget::new(&limits),
records: EntryBudget::new(&limits),
config: config(),
}
}
#[test]
fn record_budget_rejects_one_past_the_ceiling_and_allows_the_boundary() {
let stream = direct_stream(0xe2, &[0]);
let error = extractor()
.extract_stream(&stream, 0xe2, 0, None, None, &mut budget(0, 32))
.expect_err("one record is already past a zero-record ceiling");
assert!(
matches!(
error,
Error::Limit(LimitExceeded::Entries {
requested: 1,
limit: 0
})
),
"unexpected error: {error}"
);
let directory = tempfile::tempdir().expect("temporary output directory");
let mut at_ceiling = extractor();
at_ceiling.output_dir = directory.path().to_path_buf();
at_ceiling
.extract_stream(&stream, 0xe2, 0, None, None, &mut budget(1, 0))
.expect("one record at depth zero is the boundary");
}
#[test]
fn nesting_one_past_the_ceiling_is_rejected_before_the_child_is_decoded() {
let stream = direct_stream(0xe2, &[0]);
let directory = tempfile::tempdir().expect("temporary output directory");
let mut at_ceiling = extractor();
at_ceiling.output_dir = directory.path().to_path_buf();
at_ceiling
.extract_stream(&stream, 0xe2, 32, None, None, &mut budget(1, 32))
.expect("depth 32 is the nesting boundary");
let error = extractor()
.extract_stream(&stream, 0xe2, 33, None, None, &mut budget(1, 32))
.expect_err("depth 33 is rejected before the stream is parsed");
match &error {
Error::Limit(limit) => assert_eq!(
limit,
&LimitExceeded::Depth {
requested: 33,
limit: 32
}
),
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn decoded_payloads_share_one_task_budget() {
let stream = direct_stream(0xe2, &[0]);
let directory = tempfile::tempdir().expect("temporary output directory");
let mut budget = budget(1, 0);
budget.limits.max_total_bytes = 1;
budget.bytes = ByteBudget::new(&budget.limits);
// A direct record has no decoded container, so it does not charge the
// byte budget. Seed the only allowed byte to prove the budget is the
// one the caller passed in and is not reset per stream.
budget.bytes.charge(1).unwrap();
let mut extractor = extractor();
extractor.output_dir = directory.path().to_path_buf();
extractor
.extract_stream(&stream, 0xe2, 0, None, None, &mut budget)
.unwrap();
assert_eq!(budget.bytes.used(), 1);
assert!(budget.bytes.charge(1024).is_err());
}
}
+118 -4
View File
@@ -2,18 +2,132 @@ use std::path::Path;
use senbei_crypto::android::Module9bConfig;
use super::error::Error;
use super::stage1::{self, DEFAULT_CIPHER_CONSTANT, DEFAULT_OUTER_SIZE};
use crate::android::ResourceLimits;
/// Return whether `data` has a supported protected AArch64 IL2CPP layout.
///
/// This uses the default ceilings. A size past those ceilings is reported as
/// not protected, matching a scan that has not opted into a custom limit.
/// [`probe_libil2cpp_with_limits`] returns that limit error instead.
#[must_use]
pub fn is_protected_libil2cpp(data: &[u8]) -> bool {
let Ok(stage1) = stage1::inspect(
probe_libil2cpp_with_limits(data, &ResourceLimits::default()).unwrap_or(false)
}
/// Probe one image under explicit ceilings.
///
/// A parse or configuration mismatch is `Ok(false)`: the image is not a
/// supported target. [`Error::Limit`] is returned as-is, because the declared
/// Stage 1 payload was large enough that classifying it would require the
/// allocation the ceiling exists to prevent. The payload copy is capped by
/// `min(max_entry_bytes, max_total_bytes)` before it is reserved.
pub fn probe_libil2cpp_with_limits(data: &[u8], limits: &ResourceLimits) -> Result<bool, Error> {
let stage1 = match stage1::inspect(
data,
Path::new("<probe>"),
DEFAULT_OUTER_SIZE,
DEFAULT_CIPHER_CONSTANT,
) else {
return false;
Some(limits.max_entry_bytes.min(limits.max_total_bytes)),
) {
Ok(stage1) => stage1,
Err(Error::Limit(limit)) => return Err(Error::Limit(limit)),
Err(_) => return Ok(false),
};
Module9bConfig::parse_embedded(&stage1.plaintext).is_ok()
Ok(Module9bConfig::parse_embedded(&stage1.plaintext).is_ok())
}
#[cfg(test)]
mod tests {
use super::*;
/// Stage 1 header words, encrypted with the default cipher constant.
fn encrypt_header(words: [u32; 8]) -> [u8; 0x20] {
let key = words[0];
let mut raw = [0_u8; 0x20];
raw[..4].copy_from_slice(&key.to_le_bytes());
for (index, value) in words.into_iter().enumerate().skip(1) {
let index = index as u32;
let word = (value ^ DEFAULT_CIPHER_CONSTANT.wrapping_mul(index + 1))
.wrapping_sub((index + 3).wrapping_mul(key));
raw[index as usize * 4..index as usize * 4 + 4].copy_from_slice(&word.to_le_bytes());
}
raw
}
fn put(data: &mut [u8], offset: usize, bytes: &[u8]) {
data[offset..offset + bytes.len()].copy_from_slice(bytes);
}
/// A minimal AArch64 ELF whose SHT_LOUSER payload declares `payload_size`.
///
/// The declared payload is not present in the file. Inspection rejects that
/// size before copying it when the ceiling is smaller.
fn elf_with_payload_size(payload_size: u32) -> Vec<u8> {
let outer = DEFAULT_OUTER_SIZE;
let section_file_size = outer + 0x1000;
let section_offset = 64_usize;
let shoff = section_offset + section_file_size;
// Offsets into `names`: dynsym 1, dynstr 9, gnu.version 17,
// gnu.version_r 30, hash 44, private 50, shstrtab 59.
let names =
b"\0.dynsym\0.dynstr\0.gnu.version\0.gnu.version_r\0.hash\0.private\0.shstrtab\0";
let strtab = shoff + 64 * 8;
let mut data = vec![0_u8; strtab + names.len()];
put(&mut data, 0, b"\x7fELF\x02\x01\x01");
put(&mut data, 16, &3_u16.to_le_bytes());
put(&mut data, 18, &183_u16.to_le_bytes());
put(&mut data, 20, &1_u32.to_le_bytes());
put(&mut data, 40, &(shoff as u64).to_le_bytes());
put(&mut data, 52, &64_u16.to_le_bytes());
put(&mut data, 58, &64_u16.to_le_bytes());
put(&mut data, 60, &8_u16.to_le_bytes());
put(&mut data, 62, &7_u16.to_le_bytes());
let header = encrypt_header([1, 0, 0x20, payload_size, 0, 0, payload_size, payload_size]);
put(&mut data, section_offset + outer, &header);
put(&mut data, strtab, names);
let sections: [(u32, u32, u64, u64); 8] = [
(0, 0, 0, 0),
(1, 11, 0, 0),
(9, 3, 0, 0),
(17, 0x6fff_fff0, 0, 0),
(30, 0x6fff_fffe, 0, 0),
(45, 5, 0, 0),
(
51,
0x8000_0000,
section_offset as u64,
(outer + 0x1000 + payload_size as usize) as u64,
),
(60, 3, strtab as u64, names.len() as u64),
];
for (index, (name, kind, offset, size)) in sections.into_iter().enumerate() {
let at = shoff + index * 64;
put(&mut data, at, &name.to_le_bytes());
put(&mut data, at + 4, &kind.to_le_bytes());
put(&mut data, at + 24, &offset.to_le_bytes());
put(&mut data, at + 32, &size.to_le_bytes());
}
data
}
#[test]
fn oversized_stage1_payload_is_a_limit_error_not_a_clean_miss() {
let image = elf_with_payload_size(8);
let limits = ResourceLimits {
max_entry_bytes: 4,
max_total_bytes: 4,
..ResourceLimits::default()
};
let error = probe_libil2cpp_with_limits(&image, &limits);
assert!(
matches!(error, Err(Error::Limit(_))),
"unexpected probe result: {error:?}"
);
assert!(
!probe_libil2cpp_with_limits(&[], &limits).expect("an empty image is not protected")
);
assert!(!is_protected_libil2cpp(&[]));
}
}
+15 -1
View File
@@ -38,6 +38,7 @@ pub(crate) fn inspect(
path: &Path,
outer_size: usize,
cipher_constant: u32,
max_payload_bytes: Option<u64>,
) -> Result<Stage1Result> {
let elf = parse(data).map_err(|source: ElfError| Error::Elf {
path: path.to_path_buf(),
@@ -133,6 +134,15 @@ pub(crate) fn inspect(
"Stage 1 payload size must be nonzero and word aligned: 0x{payload_size:x}"
));
}
if let Some(limit) = max_payload_bytes {
let declared = u64::try_from(payload_size)
.map_err(|_| Error::Invalid("Stage 1 payload size exceeds u64".to_owned()))?;
crate::android::ResourceLimits {
max_entry_bytes: limit,
..crate::android::ResourceLimits::default()
}
.check_entry_bytes(declared)?;
}
let entry_offset = usize::try_from(header.entry_offset)
.map_err(|_| Error::Invalid("Stage 1 entry offset exceeds usize".to_owned()))?;
if entry_offset >= payload_size {
@@ -190,7 +200,11 @@ fn decrypt_words(ciphertext: &[u8], key: u32, constant: u32) -> Result<Vec<u8>>
if !ciphertext.len().is_multiple_of(4) {
return invalid("Stage 1 word cipher input is not 4-byte aligned");
}
let mut plaintext = ciphertext.to_vec();
let mut plaintext = Vec::new();
plaintext
.try_reserve_exact(ciphertext.len())
.map_err(|_| Error::Invalid("Stage 1 plaintext reservation failed".to_owned()))?;
plaintext.extend_from_slice(ciphertext);
for (index, chunk) in plaintext.as_chunks_mut::<4>().0.iter_mut().enumerate() {
let index = u32::try_from(index)
.map_err(|_| Error::Invalid("Stage 1 word index exceeds u32".to_owned()))?;
+273
View File
@@ -0,0 +1,273 @@
//! Shared Android resource ceilings for extraction, restoration, and archives.
//!
//! These bounds reject an attacker-controlled size before the corresponding
//! allocation or file growth. They are per-allocation and per-task accounting
//! caps, not a measurement of peak resident memory: decoder scratch, Huffman
//! tables, and mapped inputs can exist at the same time.
/// Default ceiling for one ZIP entry, one decoder output, one writer source or
/// destination, one artifact read, and the restored output image.
pub const DEFAULT_MAX_ENTRY_BYTES: u64 = 2 << 30;
/// Default ceiling for the sum of retained or temporary bytes charged by one
/// extraction, restoration, or archive task.
pub const DEFAULT_MAX_TOTAL_BYTES: u64 = 8 << 30;
/// Default ceiling for ZIP entries and decoded stage-2 records in one task.
pub const DEFAULT_MAX_ENTRIES: usize = 100_000;
/// Default nesting ceiling. Depth `0` is the outermost container or stream, and
/// a depth equal to this value is still allowed.
pub const DEFAULT_MAX_DEPTH: usize = 32;
/// Overridable ceilings shared by Android extraction, restoration, and I/O.
///
/// Archive code uses the same type: `max_entry_bytes` bounds one streamed ZIP
/// entry, `max_total_bytes` bounds the sum of those entries, `max_entries`
/// bounds the entry count including nested archives, and `max_depth` bounds
/// archive nesting.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ResourceLimits {
pub max_entry_bytes: u64,
pub max_total_bytes: u64,
pub max_entries: usize,
pub max_depth: usize,
}
impl Default for ResourceLimits {
fn default() -> Self {
Self {
max_entry_bytes: DEFAULT_MAX_ENTRY_BYTES,
max_total_bytes: DEFAULT_MAX_TOTAL_BYTES,
max_entries: DEFAULT_MAX_ENTRIES,
max_depth: DEFAULT_MAX_DEPTH,
}
}
}
impl ResourceLimits {
/// Numeric output ceiling passed into the crypto crate.
#[must_use]
pub fn output_limit(self) -> senbei_crypto::android::OutputLimit {
senbei_crypto::android::OutputLimit {
max_output_bytes: self.max_entry_bytes,
}
}
/// Reject one allocation, entry, or output before it is reserved.
pub fn check_entry_bytes(self, bytes: u64) -> std::result::Result<(), LimitExceeded> {
if bytes > self.max_entry_bytes {
return Err(LimitExceeded::Entry {
requested: bytes,
limit: self.max_entry_bytes,
});
}
Ok(())
}
/// Reject a declared entry or record count before those records are stored.
pub fn check_entries(self, count: usize) -> std::result::Result<(), LimitExceeded> {
if count > self.max_entries {
return Err(LimitExceeded::Entries {
requested: count,
limit: self.max_entries,
});
}
Ok(())
}
/// Reject a nested container or stream whose depth exceeds the ceiling.
///
/// `depth` is zero for the outermost item. `max_depth` itself is accepted.
pub fn check_depth(self, depth: usize) -> std::result::Result<(), LimitExceeded> {
if depth > self.max_depth {
return Err(LimitExceeded::Depth {
requested: depth,
limit: self.max_depth,
});
}
Ok(())
}
}
/// A size or count that would exceed a [`ResourceLimits`] ceiling.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum LimitExceeded {
/// One entry or allocation is larger than `max_entry_bytes`.
#[error("resource limit: entry of {requested} bytes exceeds the {limit}-byte ceiling")]
Entry { requested: u64, limit: u64 },
/// Charging more bytes would pass `max_total_bytes`.
#[error(
"resource limit: task total of {requested} bytes exceeds the {limit}-byte ceiling (adding {adding} bytes)"
)]
Total {
requested: u64,
adding: u64,
limit: u64,
},
/// An entry or record count passed `max_entries`.
#[error("resource limit: {requested} entries exceed the ceiling of {limit}")]
Entries { requested: usize, limit: usize },
/// Nesting passed `max_depth`.
#[error("resource limit: nesting depth {requested} exceeds the ceiling of {limit}")]
Depth { requested: usize, limit: usize },
}
/// Running sum of retained or temporary bytes for one task.
///
/// Each call starts its own budget at zero. Charging checks the single-entry
/// ceiling and the task total, but it does not account for buffers that were
/// never charged, so it is not a peak-RSS budget.
#[derive(Debug, Clone)]
pub struct ByteBudget {
limits: ResourceLimits,
used: u64,
}
impl ByteBudget {
#[must_use]
pub fn new(limits: &ResourceLimits) -> Self {
Self {
limits: *limits,
used: 0,
}
}
#[must_use]
pub fn used(&self) -> u64 {
self.used
}
/// Reject `bytes` against the single-entry ceiling without charging it.
pub fn check_entry(&self, bytes: u64) -> std::result::Result<(), LimitExceeded> {
self.limits.check_entry_bytes(bytes)
}
/// Charge `bytes` against both the single-entry ceiling and the task total.
pub fn charge(&mut self, bytes: u64) -> std::result::Result<(), LimitExceeded> {
self.check_entry(bytes)?;
self.charge_total(bytes)
}
/// Charge `bytes` against the task total only.
///
/// Use this for a sum of pieces that were each already checked against the
/// single-entry ceiling, such as several decoded records in one stream.
pub fn charge_total(&mut self, bytes: u64) -> std::result::Result<(), LimitExceeded> {
let requested = self
.used
.checked_add(bytes)
.filter(|total| *total <= self.limits.max_total_bytes);
match requested {
Some(total) => {
self.used = total;
Ok(())
}
None => Err(LimitExceeded::Total {
requested: self.used.saturating_add(bytes),
adding: bytes,
limit: self.limits.max_total_bytes,
}),
}
}
}
/// Running count of entries or decoded records for one task.
#[derive(Debug, Clone)]
pub struct EntryBudget {
limits: ResourceLimits,
used: usize,
}
impl EntryBudget {
#[must_use]
pub fn new(limits: &ResourceLimits) -> Self {
Self {
limits: *limits,
used: 0,
}
}
#[must_use]
pub fn used(&self) -> usize {
self.used
}
/// Charge `count` additional entries against `max_entries`.
pub fn charge(&mut self, count: usize) -> std::result::Result<(), LimitExceeded> {
let requested = self
.used
.checked_add(count)
.filter(|total| *total <= self.limits.max_entries);
match requested {
Some(total) => {
self.used = total;
Ok(())
}
None => Err(LimitExceeded::Entries {
requested: self.used.saturating_add(count),
limit: self.limits.max_entries,
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_match_the_documented_ceilings() {
let limits = ResourceLimits::default();
assert_eq!(limits.max_entry_bytes, 2 << 30);
assert_eq!(limits.max_total_bytes, 8 << 30);
assert_eq!(limits.max_entries, 100_000);
assert_eq!(limits.max_depth, 32);
assert!(limits.check_entry_bytes(limits.max_entry_bytes).is_ok());
assert!(limits.check_entries(limits.max_entries).is_ok());
assert!(limits.check_depth(limits.max_depth).is_ok());
assert!(
limits
.check_entry_bytes(limits.max_entry_bytes + 1)
.is_err()
);
assert!(limits.check_depth(limits.max_depth + 1).is_err());
}
#[test]
fn byte_budget_accepts_the_boundary_and_rejects_the_next_byte() {
let limits = ResourceLimits {
max_entry_bytes: 4,
max_total_bytes: 8,
max_entries: 2,
max_depth: 1,
};
let mut budget = ByteBudget::new(&limits);
budget.charge(4).unwrap();
budget.charge_total(4).unwrap();
assert_eq!(budget.used(), 8);
assert!(matches!(
budget.charge(5),
Err(LimitExceeded::Entry { requested: 5, .. })
));
assert!(matches!(
budget.charge_total(1),
Err(LimitExceeded::Total { .. })
));
assert_eq!(budget.used(), 8);
}
#[test]
fn entry_budget_rejects_the_count_past_the_ceiling() {
let limits = ResourceLimits {
max_entries: 2,
..ResourceLimits::default()
};
let mut budget = EntryBudget::new(&limits);
budget.charge(2).unwrap();
assert!(matches!(
budget.charge(1),
Err(LimitExceeded::Entries {
requested: 3,
limit: 2
})
));
}
}
+11 -2
View File
@@ -2,10 +2,19 @@
mod common;
mod extract;
mod limits;
mod restore;
pub use extract::{
DEFAULT_CIPHER_CONSTANT, DEFAULT_OUTER_SIZE, Error as ExtractionError, ExtractOptions,
ExtractionReport, extract_stage2, is_protected_libil2cpp,
ExtractionReport, extract_stage2, extract_stage2_with_limits, is_protected_libil2cpp,
probe_libil2cpp_with_limits,
};
pub use limits::{
ByteBudget, DEFAULT_MAX_DEPTH, DEFAULT_MAX_ENTRIES, DEFAULT_MAX_ENTRY_BYTES,
DEFAULT_MAX_TOTAL_BYTES, EntryBudget, LimitExceeded, ResourceLimits,
};
pub use restore::{
Error as RestoreError, RestoreOptions, RestoreReport, restore_libil2cpp,
restore_libil2cpp_with_limits,
};
pub use restore::{Error as RestoreError, RestoreOptions, RestoreReport, restore_libil2cpp};
@@ -16,6 +16,7 @@ fn wanted_id(command_id: u32) -> bool {
pub(crate) struct Artifact {
pub path: PathBuf,
pub size: u64,
pub entry_offset: Option<u32>,
}
pub(crate) fn load_artifacts(index_path: &Path) -> Result<BTreeMap<u32, Artifact>> {
@@ -47,6 +48,10 @@ pub(crate) fn load_artifacts(index_path: &Path) -> Result<BTreeMap<u32, Artifact
Artifact {
path: root.join(path),
size,
entry_offset: item
.get("entry_offset")
.and_then(Value::as_u64)
.and_then(|value| u32::try_from(value).ok()),
},
);
}
@@ -79,6 +84,10 @@ pub(crate) fn load_artifacts(index_path: &Path) -> Result<BTreeMap<u32, Artifact
Artifact {
path: root.join(path),
size,
entry_offset: record
.get("entry_offset")
.and_then(Value::as_u64)
.and_then(|value| u32::try_from(value).ok()),
},
);
}
@@ -0,0 +1,418 @@
//! Evaluate the callback selector reached from the handoff module entry.
//!
//! This is a bounded interpreter for the selector's integer/control-flow
//! instructions, not a native module loader. Only stack writes are permitted;
//! image calls are recorded, never executed. Unknown instructions or memory
//! accesses reject a candidate. Table offsets, ordering, flags and the group
//! fallback decision consequently come from the module's own code.
use std::collections::{BTreeSet, HashSet};
use senbei_elf::{ElfLayout, read_u32, slice_u64};
use super::error::{Result, invalid};
use super::pipeline::{aarch64_adrp_target, virtual_address_to_file_offset};
const RUNTIME: u64 = 1 << 32;
const IMAGE: u64 = 2 << 32;
const STACK: u64 = 3 << 32;
const STACK_SIZE: usize = 0x10000;
const RETURN: u64 = u64::MAX - 3;
#[cfg(test)]
#[path = "constructors_tests.rs"]
mod tests;
fn signed(value: u32, bits: u32) -> i64 {
i64::from((value << (32 - bits)) as i32 >> (32 - bits))
}
fn branch_target(pc: u64, instruction: u32, bits: u32, shift: u32) -> Option<u64> {
pc.checked_add_signed(signed((instruction >> shift) & ((1 << bits) - 1), bits) * 4)
}
/// Follow direct control flow from the descriptor entry, without inspecting
/// unrelated code or searching the data image for plausible tables.
fn selector_functions(module: &[u8], entry: u32) -> Result<BTreeSet<u64>> {
let mut functions = vec![u64::from(entry)];
let mut visited_functions = HashSet::new();
let mut selectors = BTreeSet::new();
let mut budget = 100_000_usize;
while let Some(function) = functions.pop() {
if !visited_functions.insert(function) {
continue;
}
let mut pending = vec![function];
let mut visited = HashSet::new();
while let Some(pc) = pending.pop() {
if !visited.insert(pc) {
continue;
}
if budget == 0 {
return invalid("constructor module control flow exceeds analysis budget");
}
budget -= 1;
let Some(instruction) = usize::try_from(pc)
.ok()
.filter(|offset| offset.is_multiple_of(4))
.and_then(|offset| read_u32(module, offset).ok())
else {
return invalid("constructor module branches outside its image");
};
if instruction & 0x7c00_0000 == 0x1400_0000 {
let Some(target) = branch_target(pc, instruction, 26, 0) else {
return invalid("constructor module branch address overflows");
};
if instruction & 0x8000_0000 != 0 {
functions.push(target);
pending.push(pc + 4);
} else {
pending.push(target);
}
continue;
}
let conditional_bits = if instruction & 0xff00_0010 == 0x5400_0000
|| instruction & 0x7e00_0000 == 0x3400_0000
{
Some(19)
} else if instruction & 0x7e00_0000 == 0x3600_0000 {
Some(14)
} else {
None
};
if let Some(bits) = conditional_bits {
let Some(target) = branch_target(pc, instruction, bits, 5) else {
return invalid("constructor module conditional branch overflows");
};
pending.push(target);
}
if instruction & 0xffff_fc1f == 0xd63f_0000 {
selectors.insert(function);
} else if instruction & 0xffff_fc1f == 0xd61f_0000
|| instruction & 0xffff_fc1f == 0xd65f_0000
|| instruction == 0
{
continue;
}
pending.push(pc + 4);
}
}
Ok(selectors)
}
fn mask(bits: u32) -> u64 {
u64::MAX >> (64 - bits)
}
fn logical_immediate(instruction: u32, width: u32) -> Option<u64> {
let n = (instruction >> 22) & 1;
let imms = (instruction >> 10) & 63;
let immr = (instruction >> 16) & 63;
let encoded = (n << 6) | (!imms & 63);
let len = 31_u32.checked_sub(encoded.leading_zeros())?;
if len < 1 || (width == 32 && n != 0) {
return None;
}
let size = 1 << len;
let ones = (imms & (size - 1)) + 1;
if ones == size || size > width {
return None;
}
let rotate = immr & (size - 1);
let value = mask(ones);
let element = if rotate == 0 {
value
} else {
((value >> rotate) | (value << (size - rotate))) & mask(size)
};
let mut result = 0;
for position in (0..width).step_by(size as usize) {
result |= element << position;
}
Some(result)
}
struct Selector<'a> {
module: &'a [u8],
image: &'a [u8],
layout: &'a ElfLayout,
registers: [Option<u64>; 31],
sp: u64,
stack: Vec<Option<u8>>,
flags: Option<(bool, bool, bool, bool)>,
read_image_base: bool,
calls: Vec<u64>,
}
impl Selector<'_> {
fn reg(&self, index: u32, sp: bool) -> Option<u64> {
if index == 31 {
Some(if sp { self.sp } else { 0 })
} else {
self.registers[index as usize]
}
}
fn set(&mut self, index: u32, value: u64, width: u32, sp: bool) {
let value = value & mask(width);
if index != 31 {
self.registers[index as usize] = Some(value);
} else if sp {
self.sp = value;
}
}
fn read(&mut self, address: u64, size: usize) -> Option<u64> {
if address == RUNTIME + 8 && size == 8 {
self.read_image_base = true;
return Some(IMAGE);
}
let bytes = if address >= STACK {
let start = usize::try_from(address - STACK).ok()?;
self.stack
.get(start..start.checked_add(size)?)?
.iter()
.copied()
.collect::<Option<Vec<_>>>()?
} else if address >= IMAGE {
let offset = virtual_address_to_file_offset(self.layout, address - IMAGE, size as u64)?;
self.image.get(offset..offset.checked_add(size)?)?.to_vec()
} else {
slice_u64(self.module, address, size as u64).ok()?.to_vec()
};
Some(bytes.iter().enumerate().fold(0, |value, (index, byte)| {
value | (u64::from(*byte) << (index * 8))
}))
}
fn write(&mut self, address: u64, size: usize, value: u64) -> Option<()> {
let offset = usize::try_from(address.checked_sub(STACK)?).ok()?;
for (index, byte) in self
.stack
.get_mut(offset..offset.checked_add(size)?)?
.iter_mut()
.enumerate()
{
*byte = Some((value >> (index * 8)) as u8);
}
Some(())
}
fn condition(&self, condition: u32) -> Option<bool> {
let (negative, zero, carry, overflow) = self.flags?;
let matched = match condition >> 1 {
0 => zero,
1 => carry,
2 => negative,
3 => overflow,
4 => carry && !zero,
5 => negative == overflow,
6 => negative == overflow && !zero,
7 => return Some(true),
_ => return None,
};
Some(matched ^ (condition & 1 != 0))
}
fn run(mut self, start: u64, max_calls: usize) -> Option<Vec<u64>> {
let mut pc = start;
let budget = max_calls
.checked_add(1)?
.checked_mul(256)?
.checked_add(1024)?
.min(1_000_000);
for _ in 0..budget {
if !pc.is_multiple_of(4) {
return None;
}
let instruction = read_u32(self.module, usize::try_from(pc).ok()?).ok()?;
let mut next = pc.checked_add(4)?;
let rd = instruction & 31;
let rn = (instruction >> 5) & 31;
let width = if instruction >> 31 == 0 { 32 } else { 64 };
if instruction & 0xffff_fc1f == 0xd65f_0000 {
return (self.reg(rn, false)? == RETURN
&& self.sp == STACK + STACK_SIZE as u64
&& self.read_image_base
&& !self.calls.is_empty()
&& self.reg(0, false)? == 1)
.then_some(self.calls);
} else if instruction & 0xffff_fc1f == 0xd63f_0000 {
let address = self.reg(rn, false)?.checked_sub(IMAGE)?;
if self.calls.len() >= max_calls || !executable_address(self.layout, address) {
return None;
}
self.calls.push(address);
self.registers[..19].fill(None);
self.registers[30] = Some(next);
self.flags = None;
} else if instruction & 0xfc00_0000 == 0x1400_0000 {
next = branch_target(pc, instruction, 26, 0)?;
} else if instruction & 0xff00_0010 == 0x5400_0000 {
if self.condition(instruction & 15)? {
next = branch_target(pc, instruction, 19, 5)?;
}
} else if instruction & 0x7e00_0000 == 0x3400_0000 {
let nonzero = self.reg(rd, false)? & mask(width) != 0;
if nonzero == (instruction & (1 << 24) != 0) {
next = branch_target(pc, instruction, 19, 5)?;
}
} else if instruction & 0x9f00_0000 == 0x9000_0000 {
self.set(rd, aarch64_adrp_target(instruction, pc)?, 64, false);
} else if instruction & 0x1f80_0000 == 0x1280_0000 {
let shift = ((instruction >> 21) & 3) * 16;
if shift >= width {
return None;
}
let immediate = u64::from((instruction >> 5) & 0xffff) << shift;
let value = match (instruction >> 29) & 3 {
0 => !immediate,
2 => immediate,
3 => (self.reg(rd, false)? & !(0xffff_u64 << shift)) | immediate,
_ => return None,
};
self.set(rd, value, width, false);
} else if instruction & 0x1f00_0000 == 0x1100_0000
|| instruction & 0x1f20_0000 == 0x0b00_0000
{
let immediate = instruction & 0x1f00_0000 == 0x1100_0000;
let left = self.reg(rn, immediate)? & mask(width);
let right = if immediate {
u64::from((instruction >> 10) & 0xfff)
<< (if instruction & (1 << 22) != 0 { 12 } else { 0 })
} else {
if (instruction >> 22) & 3 != 0 {
return None;
}
let shift = (instruction >> 10) & 63;
if shift >= width {
return None;
}
(self.reg((instruction >> 16) & 31, false)? << shift) & mask(width)
};
let subtract = instruction & (1 << 30) != 0;
let set_flags = instruction & (1 << 29) != 0;
let value = if subtract {
left.wrapping_sub(right)
} else {
left.wrapping_add(right)
} & mask(width);
if set_flags {
let sign = 1_u64 << (width - 1);
let carry = if subtract {
left >= right
} else {
u128::from(left) + u128::from(right) > u128::from(mask(width))
};
let overflow = if subtract {
(left ^ right) & (left ^ value) & sign != 0
} else {
!(left ^ right) & (left ^ value) & sign != 0
};
self.flags = Some((value & sign != 0, value == 0, carry, overflow));
}
self.set(rd, value, width, immediate && !set_flags);
} else if instruction & 0x1f80_0000 == 0x1200_0000
|| instruction & 0x1f20_0000 == 0x0a00_0000
{
let left = self.reg(rn, false)?;
let right = if instruction & 0x1f80_0000 == 0x1200_0000 {
logical_immediate(instruction, width)?
} else {
if (instruction >> 22) & 3 != 0 {
return None;
}
let shift = (instruction >> 10) & 63;
if shift >= width {
return None;
}
self.reg((instruction >> 16) & 31, false)? << shift
};
let value = match (instruction >> 29) & 3 {
0 => left & right,
1 => left | right,
2 => left ^ right,
_ => return None,
};
self.set(rd, value, width, false);
} else if instruction & 0x7f80_0000 == 0x5300_0000 {
let rotate = (instruction >> 16) & 63;
let end = (instruction >> 10) & 63;
if rotate >= width || end >= width || (instruction >> 22) & 1 != width / 64 {
return None;
}
let source = self.reg(rn, false)? & mask(width);
let value = if rotate <= end {
(source >> rotate) & mask(end - rotate + 1)
} else {
(source & mask(end + 1)) << (width - rotate)
};
self.set(rd, value, width, false);
} else if instruction & 0x3b00_0000 == 0x3900_0000 && instruction & (1 << 26) == 0 {
let size = 1_usize << (instruction >> 30);
let address = self
.reg(rn, true)?
.checked_add(u64::from((instruction >> 10) & 0xfff) * size as u64)?;
match (instruction >> 22) & 3 {
0 => self.write(address, size, self.reg(rd, false)?)?,
1 => {
let value = self.read(address, size)?;
self.set(rd, value, if size == 8 { 64 } else { 32 }, false);
}
_ => return None,
}
} else {
return None;
}
pc = next;
}
None
}
}
pub(super) fn executable_address(layout: &ElfLayout, address: u64) -> bool {
address.is_multiple_of(4)
&& layout.program_headers.iter().any(|segment| {
segment.flags & 1 != 0
&& address >= segment.virtual_address
&& address
.checked_add(4)
.zip(segment.virtual_address.checked_add(segment.file_size))
.is_some_and(|(end, limit)| end <= limit)
})
}
pub(super) fn callbacks(
module: &[u8],
entry: u32,
image: &[u8],
layout: &ElfLayout,
max_calls: usize,
) -> Result<Vec<u64>> {
let mut matches = Vec::new();
for function in selector_functions(module, entry)? {
let mut registers = [None; 31];
registers[0] = Some(RUNTIME);
registers[30] = Some(RETURN);
let selector = Selector {
module,
image,
layout,
registers,
sp: STACK + STACK_SIZE as u64,
stack: vec![None; STACK_SIZE],
flags: None,
read_image_base: false,
calls: Vec::new(),
};
if let Some(calls) = selector.run(function, max_calls) {
matches.push(calls);
}
}
match matches.len() {
1 => Ok(matches.pop().unwrap()),
count => invalid(format!(
"constructor module entry reaches {count} validated callback selectors"
)),
}
}
@@ -0,0 +1,180 @@
use super::*;
use senbei_elf::LoadSegment;
fn fixture() -> (Vec<u8>, Vec<u8>, ElfLayout) {
let mut module = vec![0; 0x2000];
// Synthetic selector: check every primary, then call either all primary
// or all fallback offsets. Bit 1 disables a record's instruction check;
// null primaries are skipped. The entry reaches it through a direct call.
let code: [u32; 41] = [
0xd10043ff, // sub sp, sp, #16
0xf90003fe, // str x30, [sp]
0xf9400413, // ldr x19, [x0, #8]
0xb0000014, // adrp x20, table page
0x91200294, // add x20, x20, #0x800
0xb9400295, // ldr w21, [x20] (count)
0x91002294, // add x20, x20, #8 (records)
0x52800016, // mov w22, #0 (fallback)
0x52800017, // mov w23, #0 (index)
0x6b1502ff, // cmp w23, w21
0x540001e2, // b.hs checked
0x8b171298, // add x24, x20, x23, lsl #4
0xb9400300, // ldr w0, [x24]
0x34000140, // cbz w0, check_next
0xb9400f01, // ldr w1, [x24, #12]
0x121f0021, // and w1, w1, #2
0x350000e1, // cbnz w1, check_next
0x8b000260, // add x0, x19, x0
0xb9400000, // ldr w0, [x0]
0xb9400b01, // ldr w1, [x24, #8]
0x6b01001f, // cmp w0, w1
0x54000040, // b.eq check_next
0x52800036, // mov w22, #1
0x110006f7, // add w23, w23, #1
0x17fffff1, // b check
0x52800017, // mov w23, #0
0x6b1502ff, // cmp w23, w21
0x54000142, // b.hs done
0x8b171298, // add x24, x20, x23, lsl #4
0xb9400300, // ldr w0, [x24]
0x340000a0, // cbz w0, invoke_next
0x34000056, // cbz w22, primary
0xb9400700, // ldr w0, [x24, #4]
0x8b000260, // add x0, x19, x0
0xd63f0000, // blr x0
0x110006f7, // add w23, w23, #1
0x17fffff6, // b invoke
0x52800020, // mov w0, #1
0xf94003fe, // ldr x30, [sp]
0x910043ff, // add sp, sp, #16
0xd65f03c0, // ret
];
put(&mut module, 0x40, 0x9400_0030);
put(&mut module, 0x44, 0xd65f_03c0);
for (index, instruction) in code.into_iter().enumerate() {
put(&mut module, 0x100 + index * 4, instruction);
}
put(&mut module, 0x1800, 2);
for (index, record) in [[0x4400, 0x4500, 0x1234, 0], [0x4200, 0x4520, 0x5678, 0]]
.into_iter()
.enumerate()
{
for (field, word) in record.into_iter().enumerate() {
put(&mut module, 0x1808 + index * 16 + field * 4, word);
}
}
let mut image = vec![0; 0x1000];
put(&mut image, 0x400, 0x1234);
put(&mut image, 0x200, 0x5678);
let layout = ElfLayout {
entrypoint: 0,
program_header_offset: 0,
program_header_size: 0x38,
program_header_count: 1,
program_headers: vec![LoadSegment {
offset: 0,
virtual_address: 0x4000,
file_size: 0x1000,
memory_size: 0x1000,
flags: 5,
alignment: 0x1000,
}],
section_headers: Vec::new(),
section_name_index: 0,
private_section_index: 0,
};
(module, image, layout)
}
fn put(data: &mut [u8], offset: usize, word: u32) {
data[offset..offset + 4].copy_from_slice(&word.to_le_bytes());
}
#[test]
fn callback_order_comes_from_execution_even_after_module_relocation() {
let (module, image, layout) = fixture();
assert_eq!(
callbacks(&module, 0x40, &image, &layout, 2).unwrap(),
[0x4400, 0x4200]
);
let mut relocated = vec![0; 0x5000];
relocated.extend_from_slice(&module);
// Unreachable data and code cannot become another table candidate.
relocated[..module.len()].copy_from_slice(&module);
assert_eq!(
callbacks(&relocated, 0x5040, &image, &layout, 2).unwrap(),
[0x4400, 0x4200]
);
}
#[test]
fn one_instruction_mismatch_selects_the_entire_fallback_group() {
let (module, mut image, layout) = fixture();
put(&mut image, 0x200, 0);
assert_eq!(
callbacks(&module, 0x40, &image, &layout, 2).unwrap(),
[0x4500, 0x4520]
);
}
#[test]
fn callback_flags_null_records_and_single_entries_follow_the_code() {
let (mut module, mut image, layout) = fixture();
put(&mut image, 0x200, 0);
put(&mut module, 0x1824, 2);
assert_eq!(
callbacks(&module, 0x40, &image, &layout, 2).unwrap(),
[0x4400, 0x4200]
);
put(&mut module, 0x1818, 0);
assert_eq!(
callbacks(&module, 0x40, &image, &layout, 2).unwrap(),
[0x4400]
);
put(&mut module, 0x1800, 1);
assert_eq!(
callbacks(&module, 0x40, &image, &layout, 1).unwrap(),
[0x4400]
);
}
#[test]
fn unsupported_code_invalid_memory_and_excess_calls_are_rejected() {
let (module, image, layout) = fixture();
assert!(callbacks(&module, 0x40, &image, &layout, 1).is_err());
for (offset, instruction) in [
(0x100, 0xd400_0001), // system call
(0x108, 0xf900_0413), // attempted write outside the stack
(0x150, 0x1400_0000), // nonterminating comparison loop
(0x1808, 0x9000), // primary is outside the image
(0x181c, 0x9000), // fallback is outside the image
(0x1800, u32::MAX), // count exceeds the module/table bounds
] {
let mut broken = module.clone();
put(&mut broken, offset, instruction);
let mut mismatched = image.clone();
if offset == 0x181c {
put(&mut mismatched, 0x200, 0);
}
assert!(
callbacks(&broken, 0x40, &mismatched, &layout, 2).is_err(),
"offset {offset:x}"
);
}
assert!(callbacks(&module[..0x1820], 0x40, &image, &layout, 2).is_err());
assert!(callbacks(&module, 0xffff_ffff, &image, &layout, 2).is_err());
}
#[test]
fn multiple_reachable_selectors_are_not_silently_chosen() {
let (mut module, image, layout) = fixture();
module.copy_within(0x100..0x1a4, 0x300);
put(&mut module, 0x44, 0x9400_00af); // BL 0x300
put(&mut module, 0x48, 0xd65f_03c0);
assert!(
callbacks(&module, 0x40, &image, &layout, 2)
.unwrap_err()
.to_string()
.contains("2 validated")
);
}
@@ -18,6 +18,8 @@ pub enum Error {
Elf(#[from] senbei_elf::Error),
#[error("{0}")]
Invalid(String),
#[error(transparent)]
Limit(#[from] crate::android::LimitExceeded),
}
impl Error {
+4 -1
View File
@@ -1,6 +1,9 @@
mod artifact;
mod constructors;
mod error;
mod pipeline;
pub use error::Error;
pub use pipeline::{RestoreOptions, RestoreReport, restore_libil2cpp};
pub use pipeline::{
RestoreOptions, RestoreReport, restore_libil2cpp, restore_libil2cpp_with_limits,
};
File diff suppressed because it is too large. Load diff
+3
View File
@@ -1,8 +1,11 @@
//! Platform-specific unpacking engines.
pub mod android;
mod panic_capture;
pub mod windows;
pub use panic_capture::catch_unwind_quiet;
pub use windows::{
Detected, IntegrityReport, Kind, UnpackError, check_integrity, detect, unpack_auto,
unpack_auto_v, unpack_dll, unpack_dll_v, unpack_exe, unpack_exe_v,
+182
View File
@@ -0,0 +1,182 @@
//! Thread-scoped panic capture shared by the engines and native orchestration.
use std::cell::RefCell;
use std::sync::{Arc, Mutex};
#[derive(Clone)]
pub(crate) struct PanicCapture(Arc<Mutex<Option<PanicDetails>>>);
#[derive(Clone)]
pub(crate) struct PanicDetails {
pub message: String,
pub file: String,
pub line: u32,
pub column: u32,
}
thread_local! {
static ACTIVE_PANIC_CAPTURE: RefCell<Option<PanicCapture>> = const { RefCell::new(None) };
}
struct PanicCaptureGuard(Option<PanicCapture>);
impl Drop for PanicCaptureGuard {
fn drop(&mut self) {
ACTIVE_PANIC_CAPTURE.with(|slot| {
slot.replace(self.0.take());
});
}
}
impl PanicCapture {
pub(crate) fn new() -> Self {
Self(Arc::new(Mutex::new(None)))
}
fn record(&self, info: &std::panic::PanicHookInfo<'_>) {
let location = info.location();
let details = PanicDetails {
message: panic_message(info.payload()),
file: location
.map(|value| value.file().to_owned())
.unwrap_or_else(|| "<unknown>".to_owned()),
line: location.map_or(0, std::panic::Location::line),
column: location.map_or(0, std::panic::Location::column),
};
let mut captured = self
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if captured.is_none() {
*captured = Some(details);
}
}
pub(crate) fn details(&self, payload: &(dyn std::any::Any + Send)) -> PanicDetails {
self.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
.unwrap_or_else(|| PanicDetails {
message: panic_message(payload),
file: "<unknown>".to_owned(),
line: 0,
column: 0,
})
}
pub(crate) fn merge_from(&self, other: &Self) {
let details = other
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
let Some(details) = details else { return };
let mut captured = self
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if captured.is_none() {
*captured = Some(details);
}
}
}
fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
if let Some(message) = payload.downcast_ref::<&str>() {
(*message).to_owned()
} else if let Some(message) = payload.downcast_ref::<String>() {
message.clone()
} else {
"non-string panic payload".to_owned()
}
}
pub(crate) fn install_hook() {
static INSTALL: std::sync::Once = std::sync::Once::new();
INSTALL.call_once(|| {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let capture = ACTIVE_PANIC_CAPTURE
.try_with(|slot| slot.try_borrow().ok().and_then(|slot| slot.clone()))
.ok()
.flatten();
if let Some(capture) = capture {
capture.record(info);
// WebAssembly unwinding is unavailable: retain the browser's
// diagnostic hook before the unrecoverable trap.
#[cfg(target_arch = "wasm32")]
previous(info);
} else {
previous(info);
}
}));
});
}
pub(crate) fn current_panic_capture() -> Option<PanicCapture> {
ACTIVE_PANIC_CAPTURE.with(|slot| slot.borrow().clone())
}
pub(crate) fn with_panic_capture<R>(capture: Option<PanicCapture>, f: impl FnOnce() -> R) -> R {
let previous = ACTIVE_PANIC_CAPTURE.with(|slot| slot.replace(capture));
let _guard = PanicCaptureGuard(previous);
f()
}
/// Catch a native per-file panic without printing the process panic hook.
///
/// Suppression applies only to this thread and scope. The process hook is
/// installed once, chains to the host hook outside captures, and is never
/// replaced or restored by individual jobs. Nested engine captures and their
/// worker-thread diagnostics continue to work independently.
///
/// Allocation aborts and WebAssembly traps cannot be caught.
pub fn catch_unwind_quiet<R>(f: impl FnOnce() -> R) -> std::thread::Result<R> {
install_hook();
with_panic_capture(Some(PanicCapture::new()), || {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nested_captures_restore_the_outer_scope() {
install_hook();
let outer = PanicCapture::new();
with_panic_capture(Some(outer.clone()), || {
assert!(catch_unwind_quiet(|| panic!("inner panic")).is_err());
let payload = std::panic::catch_unwind(|| panic!("outer panic")).unwrap_err();
assert_eq!(outer.details(payload.as_ref()).message, "outer panic");
});
assert!(current_panic_capture().is_none());
}
#[test]
fn captures_are_thread_local_and_unwind_safe() {
install_hook();
let workers: Vec<_> = (0..8)
.map(|index| {
std::thread::spawn(move || {
let capture = PanicCapture::new();
let payload = with_panic_capture(Some(capture.clone()), || {
std::panic::catch_unwind(|| panic!("worker {index}"))
})
.unwrap_err();
assert!(current_panic_capture().is_none());
let details = capture.details(payload.as_ref());
assert_eq!(details.message, format!("worker {index}"));
assert!(details.file.ends_with("panic_capture.rs"));
assert!(details.line > 0);
assert!(details.column > 0);
})
})
.collect();
for worker in workers {
worker.join().unwrap();
}
}
}
+13
View File
@@ -91,6 +91,16 @@ impl std::fmt::Display for DescriptorTable {
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum UnpackError {
#[error(
"Companion missing? This loader expects a matching <input>._ payload. Keep it beside the input file (or add both files in the browser) and try again."
)]
CompanionMissing,
#[error(
"Companion doesn't match: the supplied ._ payload is too short or its header differs from the loader's. Use the companion from the same build; either file may be damaged."
)]
CompanionMismatch,
#[error("input too short (need at least {required} bytes, got {actual})")]
InputTooShort { actual: usize, required: usize },
@@ -214,6 +224,9 @@ pub enum UnpackError {
exe: Box<UnpackError>,
},
#[error("structurally invalid layout candidate: {}", issues.join("; "))]
InvalidLayoutCandidate { issues: Vec<String> },
#[error(
"PE32 second-stage range is invalid (offset {offset}, size {size}, image length {image_len})"
)]
+156 -51
View File
@@ -13,11 +13,44 @@
//!
//! All reads are bounds-checked; the check never panics on any input.
/// How serious one [`IntegrityReport`] defect is for choosing a DLL layout.
///
/// This does not change what [`check`] puts in [`IntegrityReport::issues`].
/// Callers that surface suspect output still read that list. Layout selection
/// uses [`IntegrityFinding::severity`] so a warning (the image may still be the
/// right layout) is not treated as "try the other pipeline".
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IntegritySeverity {
/// The loader or runtime rejects the image before executing it, or a
/// directory the header claims to own does not resolve inside the file.
/// A DLL layout that reports one of these is the wrong layout.
Fatal,
/// The image is a PE, but a recoverable region looks unrestored. Real
/// outputs can carry these and still be the layout that must be kept
/// (a native DLL with no base relocations, an entry stub left as zeros
/// or `int3`).
Warning,
}
/// One defect from [`check`], with the severity layout selection needs.
///
/// `message` is the same string stored in [`IntegrityReport::issues`]. Severity
/// is decided while the check still knows which rule fired, not by reading the
/// message back.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IntegrityFinding {
pub severity: IntegritySeverity,
pub message: String,
}
/// Result of a static integrity check over an unpacked image.
#[derive(Debug, Clone, Default)]
pub struct IntegrityReport {
/// Each entry describes one detected defect. Empty means no defect found.
/// Order and wording match [`IntegrityReport::findings`].
pub issues: Vec<String>,
/// The same defects as `issues`, each tagged fatal or warning.
pub findings: Vec<IntegrityFinding>,
}
impl IntegrityReport {
@@ -25,6 +58,23 @@ impl IntegrityReport {
pub fn ok(&self) -> bool {
self.issues.is_empty()
}
/// True when every defect is a [`IntegritySeverity::Warning`].
///
/// An empty report is not fatal. Layout selection uses this to keep a DLL
/// image whose only defects are warnings, and to reject one the loader
/// cannot map.
pub fn has_fatal(&self) -> bool {
self.findings
.iter()
.any(|finding| finding.severity == IntegritySeverity::Fatal)
}
fn push(&mut self, severity: IntegritySeverity, message: impl Into<String>) {
let message = message.into();
self.issues.push(message.clone());
self.findings.push(IntegrityFinding { severity, message });
}
}
// `checked_add`, not `+`: `usize` is 32-bit on wasm32, so a header-derived
@@ -92,9 +142,12 @@ fn check_common_entry_branches(
.ok()
.is_some_and(|rva| is_executable_rva(secs, rva));
if !valid {
report.issues.push(format!(
report.push(
IntegritySeverity::Fatal,
format!(
"entry point {name} target 0x{target:X} is outside executable sections (DD8 selection is likely wrong)"
));
),
);
}
}
}
@@ -107,26 +160,31 @@ pub fn check(out: &[u8]) -> IntegrityReport {
// --- DOS + PE headers ---------------------------------------------------
if rd_u16(out, 0) != Some(0x5A4D) {
r.issues.push("missing 'MZ' DOS signature".into());
r.push(IntegritySeverity::Fatal, "missing 'MZ' DOS signature");
return r; // nothing else is meaningful
}
let pe_off = match rd_u32(out, 0x3C) {
Some(v) => v,
None => {
r.issues.push("truncated DOS header (no e_lfanew)".into());
r.push(
IntegritySeverity::Fatal,
"truncated DOS header (no e_lfanew)",
);
return r;
}
};
if rd_u32(out, pe_off) != Some(0x0000_4550) {
r.issues
.push(format!("missing 'PE\\0\\0' signature at 0x{pe_off:X}"));
r.push(
IntegritySeverity::Fatal,
format!("missing 'PE\\0\\0' signature at 0x{pe_off:X}"),
);
return r;
}
let num_sections = match rd_u16(out, pe_off.wrapping_add(6)) {
Some(v) => v as u32,
None => {
r.issues.push("truncated COFF header".into());
r.push(IntegritySeverity::Fatal, "truncated COFF header");
return r;
}
};
@@ -134,7 +192,7 @@ pub fn check(out: &[u8]) -> IntegrityReport {
let magic = match rd_u16(out, opt) {
Some(v) => v,
None => {
r.issues.push("truncated optional header".into());
r.push(IntegritySeverity::Fatal, "truncated optional header");
return r;
}
};
@@ -142,36 +200,44 @@ pub fn check(out: &[u8]) -> IntegrityReport {
0x20B => true,
0x10B => false,
other => {
r.issues
.push(format!("bad optional-header magic 0x{other:X}"));
r.push(
IntegritySeverity::Fatal,
format!("bad optional-header magic 0x{other:X}"),
);
return r;
}
};
if num_sections == 0 || num_sections > 96 {
r.issues
.push(format!("implausible section count {num_sections}"));
r.push(
IntegritySeverity::Fatal,
format!("implausible section count {num_sections}"),
);
}
let size_of_image = rd_u32(out, pe_off.wrapping_add(80)).unwrap_or(0);
if size_of_image == 0 {
r.issues.push("SizeOfImage is zero".into());
r.push(IntegritySeverity::Fatal, "SizeOfImage is zero");
}
// --- Section table ------------------------------------------------------
let headers = match senbei_pe::parse(out) {
Ok(headers) => headers,
Err(_) => {
r.issues
.push("section table extends past end of file".into());
r.push(
IntegritySeverity::Fatal,
"section table extends past end of file",
);
return r;
}
};
let parsed_sections = match senbei_pe::sections(out, headers) {
Ok(sections) => sections,
Err(_) => {
r.issues
.push("section table extends past end of file".into());
r.push(
IntegritySeverity::Fatal,
"section table extends past end of file",
);
return r;
}
};
@@ -182,10 +248,13 @@ pub fn check(out: &[u8]) -> IntegrityReport {
if section.raw_size != 0 {
let end = section.raw_offset.wrapping_add(section.raw_size) as usize;
if end > file_len {
r.issues.push(format!(
r.push(
IntegritySeverity::Fatal,
format!(
"section #{i} raw data [0x{:X}..0x{end:X}] exceeds file size 0x{file_len:X}",
section.raw_offset
));
),
);
}
}
section
@@ -222,9 +291,13 @@ pub fn check(out: &[u8]) -> IntegrityReport {
if is_dll && !is_managed {
let reloc_rva = rd_u32(out, dd_base.wrapping_add(5 * 8)).unwrap_or(0);
if reloc_rva == 0 {
r.issues.push(
"native DLL has no base relocation table (DD[5] is zero) — will crash when loaded at a non-preferred base"
.into(),
// Warning, not fatal: a native DLL can ship without base relocations
// (`IMAGE_FILE_RELOCS_STRIPPED`, or a module that is only ever loaded
// at its preferred base). Rejecting the layout here would fall back
// to the other pipeline and change bytes that previously unpacked.
r.push(
IntegritySeverity::Warning,
"native DLL has no base relocation table (DD[5] is zero) — will crash when loaded at a non-preferred base",
);
}
}
@@ -237,28 +310,38 @@ pub fn check(out: &[u8]) -> IntegrityReport {
if ep == 0 {
// A DLL may legitimately have no entry point; an EXE never does.
if !is_dll {
r.issues.push("entry point RVA is zero".into());
r.push(IntegritySeverity::Fatal, "entry point RVA is zero");
}
} else if !is_managed {
match rva_to_off(out, headers, ep, 16) {
None => {
r.issues.push(format!(
"entry point RVA 0x{ep:X} does not map into any section"
));
r.push(
IntegritySeverity::Fatal,
format!("entry point RVA 0x{ep:X} does not map into any section"),
);
}
Some(off) => {
let stub = &out[off as usize..off as usize + 16];
if stub.iter().all(|&b| b == 0) {
r.issues.push(format!(
"entry point at RVA 0x{ep:X} is all zeros (stub not recovered)"
));
// Warning: the entry maps and is executable, but its bytes
// were not recovered. That is the suspect signal for an
// already-chosen layout, not proof another layout is the
// right one.
r.push(
IntegritySeverity::Warning,
format!("entry point at RVA 0x{ep:X} is all zeros (stub not recovered)"),
);
} else if stub.iter().all(|&b| b == 0xCC) {
// 16 bytes of int3 padding where the entry stub should be:
// the stub region was never recovered, the loader walks
// straight into a debug-break wall.
r.issues.push(format!(
// straight into a debug-break wall. Same severity as a
// zeroed stub: the image is mapped, the bytes look unrestored.
r.push(
IntegritySeverity::Warning,
format!(
"entry point at RVA 0x{ep:X} is all int3 padding (stub not recovered)"
));
),
);
}
// The entry must live in an executable section.
let exec = secs.iter().any(|s| {
@@ -268,9 +351,10 @@ pub fn check(out: &[u8]) -> IntegrityReport {
&& (s.characteristics & 0x2000_0000) != 0
});
if !exec {
r.issues.push(format!(
"entry point RVA 0x{ep:X} is not in an executable section"
));
r.push(
IntegritySeverity::Fatal,
format!("entry point RVA 0x{ep:X} is not in an executable section"),
);
}
if let Some(entry_stub) = out.get(off as usize..off as usize + 18) {
check_common_entry_branches(entry_stub, ep, &secs, &mut r);
@@ -293,9 +377,10 @@ pub fn check(out: &[u8]) -> IntegrityReport {
let imp_rva = rd_u32(out, dd_base.wrapping_add(8)).unwrap_or(0);
if imp_rva != 0 {
match rva_to_off(out, headers, imp_rva, 20) {
None => r.issues.push(format!(
"import directory RVA 0x{imp_rva:X} does not map into any section"
)),
None => r.push(
IntegritySeverity::Fatal,
format!("import directory RVA 0x{imp_rva:X} does not map into any section"),
),
Some(desc_off) => {
// 256 descriptors is far beyond any real import table; the
// cap keeps a corrupt, never-null table from walking on.
@@ -308,14 +393,24 @@ pub fn check(out: &[u8]) -> IntegrityReport {
break;
}
match rva_to_off(out, headers, name_rva, 1) {
None => r.issues.push(format!(
None => r.push(
IntegritySeverity::Fatal,
format!(
"import descriptor {i} DLL name RVA 0x{name_rva:X} does not map into any section"
)),
),
),
Some(noff) => {
if !looks_like_dll_name(out, noff) {
r.issues.push(format!(
// Warning: the name is inside the image, so
// the directory is structurally present.
// Unreadable bytes are the "left encrypted"
// suspect signal, not an unmapped PE.
r.push(
IntegritySeverity::Warning,
format!(
"import descriptor {i} DLL name at RVA 0x{name_rva:X} is not readable ASCII (imports left encrypted?)"
));
),
);
}
}
}
@@ -336,29 +431,39 @@ pub fn check(out: &[u8]) -> IntegrityReport {
// begins with the "BSJB" signature.
if is_managed {
match rva_to_off(out, headers, clr_rva, 0x48) {
None => r.issues.push(format!(
"CLR (COR20) directory RVA 0x{clr_rva:X} does not map into any section"
)),
None => r.push(
IntegritySeverity::Fatal,
format!("CLR (COR20) directory RVA 0x{clr_rva:X} does not map into any section"),
),
Some(coff) => {
let cb = rd_u32(out, coff).unwrap_or(0);
if cb != 0x48 {
r.issues.push(format!(
r.push(
IntegritySeverity::Fatal,
format!(
"COR20 header at RVA 0x{clr_rva:X} has cb 0x{cb:X} (expected 0x48) — CLR header corrupt"
));
),
);
} else {
// MetaData RVA/size live at COR20 + 0x08 / + 0x0C.
let md_rva = rd_u32(out, coff.wrapping_add(8)).unwrap_or(0);
if md_rva != 0 {
match rva_to_off(out, headers, md_rva, 4) {
None => r.issues.push(format!(
None => r.push(
IntegritySeverity::Fatal,
format!(
"CLR MetaData RVA 0x{md_rva:X} does not map into any section"
)),
),
),
Some(moff) => {
let sig = out.get(moff as usize..moff as usize + 4);
if sig != Some(b"BSJB") {
r.issues.push(format!(
r.push(
IntegritySeverity::Fatal,
format!(
"CLR MetaData at RVA 0x{md_rva:X} lacks 'BSJB' signature (metadata corrupt — managed image will not load)"
));
),
);
}
}
}
+378 -151
View File
@@ -8,14 +8,16 @@ mod layout;
pub(crate) mod parallel;
use senbei_crypto::primitives;
use std::cell::RefCell;
use std::sync::{Arc, Mutex};
pub(crate) use crate::panic_capture::{PanicCapture, current_panic_capture, with_panic_capture};
pub use crate::thread_cap;
pub use dll::{unpack_dll, unpack_dll_v};
pub use error::*;
pub use exe::{unpack as unpack_exe, unpack_v as unpack_exe_v};
pub use integrity::{IntegrityReport, check as check_integrity};
pub use integrity::{
IntegrityFinding, IntegrityReport, IntegritySeverity, check as check_integrity,
};
/// Maximum plausible PE `SizeOfImage` we are willing to allocate a zero buffer
/// for. Guards against a corrupt/crafted header requesting a multi-gigabyte
@@ -24,130 +26,6 @@ pub use integrity::{IntegrityReport, check as check_integrity};
/// Real protected binaries are far below this.
pub(crate) const MAX_IMAGE_SIZE: u64 = senbei_crypto::MAX_IMAGE_SIZE;
#[derive(Clone)]
pub(crate) struct PanicCapture(Arc<Mutex<Option<PanicDetails>>>);
#[derive(Clone)]
struct PanicDetails {
message: String,
file: String,
line: u32,
column: u32,
}
thread_local! {
static ACTIVE_PANIC_CAPTURE: RefCell<Option<PanicCapture>> = const { RefCell::new(None) };
}
struct PanicCaptureGuard(Option<PanicCapture>);
impl Drop for PanicCaptureGuard {
fn drop(&mut self) {
ACTIVE_PANIC_CAPTURE.with(|slot| {
slot.replace(self.0.take());
});
}
}
impl PanicCapture {
fn new() -> Self {
Self(Arc::new(Mutex::new(None)))
}
fn record(&self, info: &std::panic::PanicHookInfo<'_>) {
let location = info.location();
let details = PanicDetails {
message: panic_message(info.payload()),
file: location
.map(|value| value.file().to_owned())
.unwrap_or_else(|| "<unknown>".to_owned()),
line: location.map_or(0, std::panic::Location::line),
column: location.map_or(0, std::panic::Location::column),
};
let mut captured = self
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if captured.is_none() {
*captured = Some(details);
}
}
fn into_error(self, payload: &(dyn std::any::Any + Send)) -> UnpackError {
let details = self
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
.unwrap_or_else(|| PanicDetails {
message: panic_message(payload),
file: "<unknown>".to_owned(),
line: 0,
column: 0,
});
UnpackError::InternalPanic {
message: details.message,
file: details.file,
line: details.line,
column: details.column,
}
}
fn merge_from(&self, other: &Self) {
let details = other
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
let Some(details) = details else { return };
let mut captured = self
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if captured.is_none() {
*captured = Some(details);
}
}
}
fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
if let Some(message) = payload.downcast_ref::<&str>() {
(*message).to_owned()
} else if let Some(message) = payload.downcast_ref::<String>() {
message.clone()
} else {
"non-string panic payload".to_owned()
}
}
fn install_panic_capture_hook() {
static INSTALL: std::sync::Once = std::sync::Once::new();
INSTALL.call_once(|| {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let capture = ACTIVE_PANIC_CAPTURE
.try_with(|slot| slot.borrow().clone())
.ok()
.flatten();
if let Some(capture) = capture {
capture.record(info);
} else {
previous(info);
}
}));
});
}
pub(crate) fn current_panic_capture() -> Option<PanicCapture> {
ACTIVE_PANIC_CAPTURE.with(|slot| slot.borrow().clone())
}
pub(crate) fn with_panic_capture<R>(capture: Option<PanicCapture>, f: impl FnOnce() -> R) -> R {
let previous = ACTIVE_PANIC_CAPTURE.with(|slot| slot.replace(capture));
let _guard = PanicCaptureGuard(previous);
f()
}
/// Run an unpack pipeline, converting any internal panic into a clean
/// [`UnpackError::InternalPanic`] so the public API stays panic-free on any input
/// (truncated/garbled files chase offsets out of bounds). The panic location and
@@ -159,14 +37,22 @@ pub(crate) fn catch_unpack<F>(f: F) -> Result<Vec<u8>, UnpackError>
where
F: FnOnce() -> Result<Vec<u8>, UnpackError>,
{
install_panic_capture_hook();
crate::panic_capture::install_hook();
let capture = PanicCapture::new();
let r = with_panic_capture(Some(capture.clone()), || {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f))
});
match r {
Ok(result) => result,
Err(payload) => Err(capture.into_error(payload.as_ref())),
Err(payload) => {
let details = capture.details(payload.as_ref());
Err(UnpackError::InternalPanic {
message: details.message,
file: details.file,
line: details.line,
column: details.column,
})
}
}
}
@@ -303,32 +189,73 @@ pub fn unpack_auto_v(input: &[u8], verbose: bool) -> Result<(Kind, Vec<u8>), Unp
let detected = detect(input).ok_or(UnpackError::NotCrackproof)?;
let out = match detected.kind {
Kind::NativeExe | Kind::ManagedExe => unpack_exe_v(input, verbose)?,
Kind::NativeDll | Kind::ManagedDll => {
// Two Crackproof DLL layouts exist. The older one (the byte-identical
// DLL goldens) follows the pipeline in `dll.rs`. Newer builds protect
// DLLs with the EXE-style shell layout instead — `dll::unpack_dll`
// cannot parse them and errors. Try the DLL pipeline first; on
// failure, fall back to the EXE pipeline, which handles the new
// layout (including managed-DLL CLR metadata restore). The DLL-first
// order keeps the old-layout goldens byte-identical (the EXE
// pipeline "succeeds" on them but with different bytes).
match dll::unpack_dll_v(input, verbose) {
Ok(out) => out,
Err(dll_err) => match exe::unpack_v(input, verbose) {
Ok(out) => out,
Err(exe_err) => {
return Err(UnpackError::PipelineFallbackFailed {
dll: Box::new(dll_err),
exe: Box::new(exe_err),
});
}
},
}
}
Kind::NativeDll | Kind::ManagedDll => select_dll_layout(
|| dll::unpack_dll_v(input, verbose),
|| exe::unpack_v(input, verbose),
)?,
};
Ok((detected.kind, out))
}
/// Pick between the older DLL layout and the EXE-style shell layout.
///
/// `Ok` from a pipeline is not proof it chose the right layout: a wrong layout
/// can emit a PE-shaped image the loader would reject. Each successful image is
/// judged with [`dll_layout_invalid`]. The older DLL layout is kept whenever it
/// is structurally acceptable, including when its only defects are ordinary
/// warnings (a missing base-relocation directory is one of those). The EXE-style
/// layout runs only when the DLL layout errors or is structurally invalid.
///
/// The two closures exist so tests can inject synthetic images without running
/// either real pipeline. Production passes `dll::unpack_dll_v` and `exe::unpack_v`.
fn select_dll_layout<D, E>(dll_layout: D, exe_layout: E) -> Result<Vec<u8>, UnpackError>
where
D: FnOnce() -> Result<Vec<u8>, UnpackError>,
E: FnOnce() -> Result<Vec<u8>, UnpackError>,
{
match validate_dll_layout(dll_layout()) {
Ok(output) => Ok(output),
Err(dll) => {
validate_dll_layout(exe_layout()).map_err(|exe| UnpackError::PipelineFallbackFailed {
dll: Box::new(dll),
exe: Box::new(exe),
})
}
}
}
fn validate_dll_layout(output: Result<Vec<u8>, UnpackError>) -> Result<Vec<u8>, UnpackError> {
let output = output?;
if let Some(report) = dll_layout_invalid(&output) {
return Err(UnpackError::InvalidLayoutCandidate {
issues: report
.findings
.into_iter()
.filter(|finding| finding.severity == integrity::IntegritySeverity::Fatal)
.map(|finding| finding.message)
.collect(),
});
}
Ok(output)
}
/// Structural defects that mean a DLL layout candidate must not be accepted.
///
/// Returns `None` when [`integrity::check`] reports nothing fatal. Warning
/// findings stay on the image: a native DLL with no base-relocation directory,
/// a zeroed or `int3` entry stub, or an import name that is mapped but not
/// printable ASCII. Those are the existing suspect conditions. A fatal finding
/// is one the loader or CLR rejects before that — the image is not a PE, a
/// claimed directory or entry point does not map, a section's raw range runs
/// past the file, or managed metadata has no COR20/BSJB signature.
///
/// Severity comes from [`IntegrityReport::has_fatal`], which the checker sets
/// when it records each finding. This does not re-read issue text.
fn dll_layout_invalid(image: &[u8]) -> Option<IntegrityReport> {
let report = integrity::check(image);
report.has_fatal().then_some(report)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -389,3 +316,303 @@ mod tests {
assert!(column > 0);
}
}
#[cfg(test)]
mod dll_layout_selection {
use super::*;
fn pipeline_err(stage: &'static str) -> UnpackError {
UnpackError::InputTooShort {
actual: stage.len(),
required: 1,
}
}
/// Minimal PE32+ image large enough for `integrity::check` to walk headers,
/// one section, and (when asked) an import name or a COR20 header.
struct SyntheticPe {
dll: bool,
size_of_image: u32,
entry_rva: u32,
entry_bytes: Vec<u8>,
executable: bool,
reloc: bool,
import_name: Option<&'static str>,
import_unmapped: bool,
managed: bool,
cor20_cb: u32,
metadata_sig: &'static [u8],
section_count: u16,
raw_past_eof: bool,
}
impl Default for SyntheticPe {
fn default() -> Self {
Self {
dll: true,
size_of_image: 0x2000,
entry_rva: 0x1000,
entry_bytes: vec![0xC3],
executable: true,
reloc: true,
import_name: None,
import_unmapped: false,
managed: false,
cor20_cb: 0x48,
metadata_sig: b"BSJB",
section_count: 1,
raw_past_eof: false,
}
}
}
impl SyntheticPe {
fn build(&self) -> Vec<u8> {
let optional_size: u16 = 0xF0;
let pe = 0x80usize;
let section = pe + 24 + optional_size as usize;
let file_size = if self.raw_past_eof { 0x200 } else { 0x500 };
let mut data = vec![0u8; file_size];
data[..2].copy_from_slice(b"MZ");
data[0x3C..0x40].copy_from_slice(&(pe as u32).to_le_bytes());
data[pe..pe + 4].copy_from_slice(b"PE\0\0");
data[pe + 6..pe + 8].copy_from_slice(&self.section_count.to_le_bytes());
let chars: u16 = if self.dll { 0x2000 } else { 0 };
data[pe + 22..pe + 24].copy_from_slice(&chars.to_le_bytes());
data[pe + 20..pe + 22].copy_from_slice(&optional_size.to_le_bytes());
data[pe + 24..pe + 26].copy_from_slice(&0x20Bu16.to_le_bytes());
data[pe + 24 + 16..pe + 24 + 20].copy_from_slice(&self.entry_rva.to_le_bytes());
data[pe + 24 + 56..pe + 24 + 60].copy_from_slice(&self.size_of_image.to_le_bytes());
// NumberOfRvaAndSizes must cover the directories we publish.
data[pe + 24 + 108..pe + 24 + 112].copy_from_slice(&16u32.to_le_bytes());
let dd = pe + 24 + 112;
if self.reloc && !self.managed {
data[dd + 5 * 8..dd + 5 * 8 + 4].copy_from_slice(&0x1800u32.to_le_bytes());
data[dd + 5 * 8 + 4..dd + 5 * 8 + 8].copy_from_slice(&8u32.to_le_bytes());
}
if self.import_name.is_some() || self.import_unmapped {
data[dd + 8..dd + 12].copy_from_slice(&0x1100u32.to_le_bytes());
data[dd + 12..dd + 16].copy_from_slice(&40u32.to_le_bytes());
}
if self.managed {
data[dd + 14 * 8..dd + 14 * 8 + 4].copy_from_slice(&0x1400u32.to_le_bytes());
data[dd + 14 * 8 + 4..dd + 14 * 8 + 8].copy_from_slice(&0x48u32.to_le_bytes());
}
if self.section_count > 0 && section + 40 <= data.len() {
let characteristics: u32 = if self.executable {
0x6000_0020
} else {
0x4000_0040
};
let raw_size: u32 = if self.raw_past_eof { 0x1000 } else { 0x300 };
for (offset, value) in [
(8usize, 0x1000u32),
(12, 0x1000),
(16, raw_size),
(20, 0x200),
(36, characteristics),
] {
data[section + offset..section + offset + 4]
.copy_from_slice(&value.to_le_bytes());
}
}
if self.entry_rva != 0 && !self.entry_bytes.is_empty() {
let at = 0x200 + (self.entry_rva.saturating_sub(0x1000)) as usize;
if let Some(slot) = data.get_mut(at..at + self.entry_bytes.len()) {
slot.copy_from_slice(&self.entry_bytes);
}
}
if self.import_name.is_some() || self.import_unmapped {
// Descriptor at RVA 0x1100 -> file 0x300. The name has to sit
// *before* that descriptor: bytes after it are the next
// descriptor, and a name like "kernel32.dll" would be walked
// as one. Mapped name RVA 0x1010 -> file 0x210, which is past
// the 16-byte entry stub and still inside the section.
let name_rva: u32 = if self.import_unmapped { 0x9000 } else { 0x1010 };
if data.len() >= 0x300 + 20 {
data[0x30C..0x310].copy_from_slice(&name_rva.to_le_bytes());
}
if let Some(name) = self.import_name.filter(|_| !self.import_unmapped) {
let name_at = 0x210;
let bytes = name.as_bytes();
if let Some(slot) = data.get_mut(name_at..name_at + bytes.len()) {
slot.copy_from_slice(bytes);
}
}
}
if self.managed {
let cor = 0x200 + (0x1400 - 0x1000);
if data.len() >= cor + 0x48 {
data[cor..cor + 4].copy_from_slice(&self.cor20_cb.to_le_bytes());
let md_rva = 0x1500u32;
data[cor + 8..cor + 12].copy_from_slice(&md_rva.to_le_bytes());
data[cor + 12..cor + 16].copy_from_slice(&4u32.to_le_bytes());
let md = 0x200 + (0x1500 - 0x1000);
let sig = self.metadata_sig;
if let Some(slot) = data.get_mut(md..md + sig.len()) {
slot.copy_from_slice(sig);
}
}
}
data
}
}
fn valid_native_dll() -> Vec<u8> {
SyntheticPe::default().build()
}
#[test]
fn invalid_dll_layout_falls_back_to_valid_exe_layout() {
let dll_image = SyntheticPe {
entry_rva: 0x9000,
..SyntheticPe::default()
}
.build();
let exe_image = valid_native_dll();
let selected = select_dll_layout(|| Ok(dll_image.clone()), || Ok(exe_image.clone()))
.expect("valid EXE-layout image must be accepted");
assert_eq!(selected, exe_image);
assert!(dll_layout_invalid(&dll_image).is_some());
assert!(dll_layout_invalid(&exe_image).is_none());
}
#[test]
fn structurally_valid_dll_layout_is_kept_without_calling_exe() {
let dll_image = valid_native_dll();
let selected = select_dll_layout(
|| Ok(dll_image.clone()),
|| panic!("EXE layout must not run when the DLL layout is valid"),
)
.expect("DLL layout");
assert_eq!(selected, dll_image);
}
#[test]
fn dll_warning_is_not_fatal_and_blocks_exe_fallback() {
let warned = SyntheticPe {
reloc: false,
..SyntheticPe::default()
}
.build();
let report = integrity::check(&warned);
assert!(!report.ok(), "fixture must actually warn, got {report:?}");
assert!(
dll_layout_invalid(&warned).is_none(),
"a missing relocation directory is a warning, not a rejected layout"
);
let selected = select_dll_layout(
|| Ok(warned.clone()),
|| panic!("warnings must not trigger the EXE layout"),
)
.expect("warned DLL layout");
assert_eq!(selected, warned);
}
#[test]
fn zeroed_entry_stub_is_a_warning_not_a_rejected_layout() {
let warned = SyntheticPe {
entry_bytes: vec![0; 16],
..SyntheticPe::default()
}
.build();
let report = integrity::check(&warned);
assert!(
report
.issues
.iter()
.any(|issue| issue.contains("all zeros"))
);
assert!(dll_layout_invalid(&warned).is_none());
}
#[test]
fn both_layouts_structurally_invalid_reports_both_candidates() {
let invalid = SyntheticPe {
entry_rva: 0x9000,
..SyntheticPe::default()
}
.build();
let error = select_dll_layout(|| Ok(invalid.clone()), || Ok(invalid.clone()))
.expect_err("neither layout is acceptable");
match error {
UnpackError::PipelineFallbackFailed { dll, exe } => {
let dll = dll.to_string();
let exe = exe.to_string();
assert!(dll.contains("structurally invalid"), "{dll}");
assert!(exe.contains("structurally invalid"), "{exe}");
}
other => panic!("unexpected error: {other}"),
}
}
#[test]
fn dll_error_then_exe_error_keeps_pipeline_diagnostics() {
let error = select_dll_layout(
|| Err(pipeline_err("dll")),
|| Err(pipeline_err("exe-layout")),
)
.expect_err("both pipelines failed");
match error {
UnpackError::PipelineFallbackFailed { dll, exe } => {
let dll = dll.to_string();
let exe = exe.to_string();
assert!(dll.contains("got 3"), "{dll}");
assert!(exe.contains("got 10"), "{exe}");
}
other => panic!("unexpected error: {other}"),
}
}
#[test]
fn dll_error_then_invalid_exe_image_names_both_failures() {
let invalid = SyntheticPe {
raw_past_eof: true,
..SyntheticPe::default()
}
.build();
assert!(dll_layout_invalid(&invalid).is_some());
let error = select_dll_layout(|| Err(pipeline_err("dll")), || Ok(invalid))
.expect_err("EXE image is not a usable fallback");
match error {
UnpackError::PipelineFallbackFailed { dll, exe } => {
let dll = dll.to_string();
let exe = exe.to_string();
assert!(dll.contains("need at least 1"), "{dll}");
assert!(exe.contains("structurally invalid"), "{exe}");
}
other => panic!("unexpected error: {other}"),
}
}
#[test]
fn unmapped_import_and_bad_metadata_are_fatal() {
let imports = SyntheticPe {
import_unmapped: true,
import_name: Some("unused"),
..SyntheticPe::default()
}
.build();
assert!(dll_layout_invalid(&imports).is_some());
let metadata = SyntheticPe {
managed: true,
reloc: false,
metadata_sig: b"XXXX",
..SyntheticPe::default()
}
.build();
assert!(dll_layout_invalid(&metadata).is_some());
let readable = SyntheticPe {
import_name: Some("kernel32.dll"),
..SyntheticPe::default()
}
.build();
assert!(
dll_layout_invalid(&readable).is_none(),
"{:?}",
integrity::check(&readable).issues
);
}
}
+575
View File
@@ -0,0 +1,575 @@
use std::collections::HashMap;
use std::fs::File;
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use senbei_engine::android::ResourceLimits;
use zip::ZipArchive;
use anyhow::{Context, Result, bail};
#[derive(Clone, Copy)]
struct ArchiveInfo {
entries: usize,
directory_start: u64,
directory_bytes: u64,
end_record: u64,
zip64_record: Option<u64>,
}
/// The ZIP library may retry an older end record when metadata is malformed.
/// During index construction, permit only the end records we preflighted and
/// keep central-header reads inside their declared region. Once indexing ends,
/// normal entry reads regain unrestricted seeking on the same file handle.
pub(super) struct ArchiveReader {
file: File,
info: ArchiveInfo,
indexing: Arc<AtomicBool>,
directory_started: bool,
}
fn invalid_metadata(message: &'static str) -> std::io::Error {
std::io::Error::new(std::io::ErrorKind::InvalidData, message)
}
impl Seek for ArchiveReader {
fn seek(&mut self, from: SeekFrom) -> std::io::Result<u64> {
let position = self.file.seek(from)?;
if !self.indexing.load(Ordering::Relaxed) {
return Ok(position);
}
if matches!(from, SeekFrom::Start(_)) {
let mut signature = [0_u8; 4];
let read = self.file.read(&mut signature)?;
self.file.seek(SeekFrom::Start(position))?;
if read == 4
&& ((signature == *b"PK\x05\x06" && position != self.info.end_record)
|| (signature == *b"PK\x06\x06" && Some(position) != self.info.zip64_record))
{
return Err(invalid_metadata(
"ZIP parser attempted an unverified end record",
));
}
}
if position == self.info.directory_start {
self.directory_started = true;
}
Ok(position)
}
}
impl Read for ArchiveReader {
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
if buffer.is_empty() {
return Ok(0);
}
if !self.indexing.load(Ordering::Relaxed) || !self.directory_started {
return self.file.read(buffer);
}
let position = self.file.stream_position()?;
let directory_end = self.info.directory_start + self.info.directory_bytes;
if position >= self.info.directory_start && position < directory_end {
let count = (directory_end - position).min(buffer.len() as u64) as usize;
return self.file.read(&mut buffer[..count]);
}
let trailer = self.info.zip64_record.unwrap_or(self.info.end_record);
if position >= trailer {
return self.file.read(buffer);
}
Err(invalid_metadata(
"ZIP indexing crossed the verified metadata region",
))
}
}
/// Cumulative extraction work for a run, including nested APKs. The budget is
/// charged for actual bytes read, even if a later write or restore fails.
#[derive(Debug)]
pub(super) struct ArchiveBudget {
max_entry_bytes: u64,
max_total_bytes: u64,
max_entries: usize,
bytes: u64,
entries: usize,
archives: HashMap<PathBuf, usize>,
}
impl Default for ArchiveBudget {
fn default() -> Self {
Self::from_limits(&ResourceLimits::default())
}
}
impl ArchiveBudget {
pub(super) fn from_limits(limits: &ResourceLimits) -> Self {
Self::new(
limits.max_entry_bytes,
limits.max_total_bytes,
limits.max_entries,
)
}
fn new(max_entry_bytes: u64, max_total_bytes: u64, max_entries: usize) -> Self {
Self {
max_entry_bytes,
max_total_bytes,
max_entries,
bytes: 0,
entries: 0,
archives: HashMap::new(),
}
}
pub(super) fn open_archive(&mut self, path: &Path) -> Result<ZipArchive<ArchiveReader>> {
let mut file = File::open(path).with_context(|| format!("open `{}`", path.display()))?;
let info = inspect_archive(
&mut file,
self.max_entries,
self.max_entry_bytes
.min(self.max_total_bytes.saturating_sub(self.bytes)),
)
.with_context(|| format!("check package `{}`", path.display()))?;
let count = info.entries;
let trailer = info.zip64_record.unwrap_or(info.end_record);
if info
.directory_start
.checked_add(info.directory_bytes)
.is_none_or(|end| end > trailer)
|| (count as u64)
.checked_mul(46)
.is_none_or(|minimum| minimum > info.directory_bytes)
{
bail!("ZIP central directory range is inconsistent with its end record");
}
let previous = self.archives.get(path).copied().unwrap_or(0);
if count > previous {
self.register_entries(count - previous)?;
}
self.archives
.insert(path.to_path_buf(), count.max(previous));
let indexing = Arc::new(AtomicBool::new(true));
let reader = ArchiveReader {
file,
info,
indexing: indexing.clone(),
directory_started: false,
};
let archive = ZipArchive::with_config(
zip::read::Config {
archive_offset: zip::read::ArchiveOffset::Known(0),
},
reader,
)
.with_context(|| format!("read package `{}`", path.display()))?;
indexing.store(false, Ordering::Relaxed);
if archive.len() > count {
bail!("package central directory contains more entries than its end record");
}
Ok(archive)
}
pub(super) fn read_probe(
&mut self,
input: &mut impl Read,
size: u64,
maximum: usize,
label: &str,
) -> Result<Vec<u8>> {
if size > self.max_entry_bytes {
bail!(
"entry `{label}` exceeds extraction limit {}",
self.max_entry_bytes
);
}
let length = size.min(maximum as u64);
if length > self.max_total_bytes.saturating_sub(self.bytes) {
bail!("manifest `{label}` exceeds the remaining extraction byte limit");
}
let mut bytes = Vec::new();
bytes
.try_reserve_exact(length as usize)
.context("reserve manifest probe")?;
self.copy_entry(&mut input.take(length), &mut bytes, length, label)?;
Ok(bytes)
}
pub(super) fn register_entries(&mut self, entries: usize) -> Result<()> {
let total = self
.entries
.checked_add(entries)
.context("archive entry count overflow")?;
if total > self.max_entries {
bail!(
"archive entry limit exceeded: {total} > {}",
self.max_entries
);
}
self.entries = total;
Ok(())
}
pub(super) fn copy_entry(
&mut self,
input: &mut impl Read,
output: &mut impl Write,
declared_size: u64,
label: &str,
) -> Result<u64> {
let remaining = self.max_total_bytes.saturating_sub(self.bytes);
let allowance = self.max_entry_bytes.min(remaining);
if declared_size > allowance {
bail!(
"entry `{label}` declares {declared_size} bytes, exceeding extraction limit {allowance}"
);
}
// The advertised size is a stricter ceiling when it is smaller than
// the budget. A dishonest header must not buy extra decompression work.
let limit = declared_size;
let mut buffer = [0_u8; 64 * 1024];
let mut written = 0_u64;
loop {
// Read at most one byte beyond the allowance to detect a dishonest
// size header without decompressing the rest of the entry.
let read_limit = (limit - written).saturating_add(1).min(buffer.len() as u64) as usize;
let read = input.read(&mut buffer[..read_limit])?;
if read == 0 {
break;
}
let next = written
.checked_add(read as u64)
.context("decompressed entry size overflow")?;
if next > limit {
bail!("entry `{label}` exceeds extraction limit {limit} while decompressing");
}
self.bytes = self
.bytes
.checked_add(read as u64)
.context("extraction byte count overflow")?;
output.write_all(&buffer[..read])?;
written = next;
}
if written != declared_size {
bail!("entry `{label}` decompressed to {written} bytes, expected {declared_size}");
}
Ok(written)
}
}
fn word(bytes: &[u8], offset: usize) -> u16 {
u16::from_le_bytes(bytes[offset..offset + 2].try_into().unwrap())
}
fn dword(bytes: &[u8], offset: usize) -> u32 {
u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap())
}
fn qword(bytes: &[u8], offset: usize) -> u64 {
u64::from_le_bytes(bytes[offset..offset + 8].try_into().unwrap())
}
/// Read only the bounded end records before asking the ZIP library to allocate
/// its central-directory index. Both ordinary ZIP and ZIP64 are supported.
#[cfg(test)]
fn preflight_archive(
file: &mut File,
max_entries: usize,
max_directory_bytes: u64,
) -> Result<usize> {
Ok(inspect_archive(file, max_entries, max_directory_bytes)?.entries)
}
fn inspect_archive(
file: &mut File,
max_entries: usize,
max_directory_bytes: u64,
) -> Result<ArchiveInfo> {
let length = file.seek(SeekFrom::End(0))?;
let tail_length = length.min(22 + u16::MAX as u64) as usize;
let mut tail = vec![0_u8; tail_length];
file.seek(SeekFrom::End(-(tail_length as i64)))?;
file.read_exact(&mut tail)?;
let end = (0..=tail.len().saturating_sub(22))
.rev()
.find(|&offset| {
tail.get(offset..offset + 4) == Some(b"PK\x05\x06")
&& offset + 22 <= tail.len()
&& offset + 22 + word(&tail, offset + 20) as usize == tail.len()
})
.context("ZIP end record is missing or truncated")?;
let record = &tail[end..end + 22];
if word(record, 4) != 0 || word(record, 6) != 0 {
bail!("multi-disk ZIP packages are unsupported");
}
let end_record = length - tail_length as u64 + end as u64;
let mut zip64_record = None;
let mut directory_start = u64::from(dword(record, 16));
let mut entries = u64::from(word(record, 10));
let mut directory_bytes = u64::from(dword(record, 12));
if entries == u16::MAX as u64
|| directory_bytes == u32::MAX as u64
|| dword(record, 16) == u32::MAX
{
let absolute_end = length - tail_length as u64 + end as u64;
let locator_offset = absolute_end
.checked_sub(20)
.context("ZIP64 locator is missing")?;
file.seek(SeekFrom::Start(locator_offset))?;
let mut locator = [0_u8; 20];
file.read_exact(&mut locator)?;
if &locator[..4] != b"PK\x06\x07" || dword(&locator, 4) != 0 || dword(&locator, 16) != 1 {
bail!("invalid ZIP64 locator");
}
let record_offset = qword(&locator, 8);
if record_offset
.checked_add(56)
.is_none_or(|end| end > locator_offset)
{
bail!("ZIP64 end record is outside the package");
}
file.seek(SeekFrom::Start(record_offset))?;
let mut record = [0_u8; 56];
file.read_exact(&mut record)?;
if &record[..4] != b"PK\x06\x06"
|| qword(&record, 4) < 44
|| record_offset
.checked_add(12)
.and_then(|start| start.checked_add(qword(&record, 4)))
.is_none_or(|end| end > locator_offset)
|| dword(&record, 16) != 0
|| dword(&record, 20) != 0
|| qword(&record, 24) != qword(&record, 32)
{
bail!("invalid ZIP64 end record");
}
if qword(&record, 4) > max_directory_bytes {
bail!("ZIP64 metadata exceeds the directory memory limit");
}
zip64_record = Some(record_offset);
directory_start = qword(&record, 48);
entries = qword(&record, 32);
directory_bytes = qword(&record, 40);
} else if word(record, 8) != word(record, 10) {
bail!("inconsistent ZIP entry counts");
}
if entries > max_entries as u64 {
bail!("archive entry limit exceeded: {entries} > {max_entries}");
}
if directory_bytes > max_directory_bytes {
bail!("ZIP directory size {directory_bytes} exceeds memory limit {max_directory_bytes}");
}
file.seek(SeekFrom::Start(0))?;
Ok(ArchiveInfo {
entries: usize::try_from(entries).context("ZIP entry count exceeds address space")?,
directory_start,
directory_bytes,
end_record,
zip64_record,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
#[test]
fn entry_and_aggregate_limits_allow_exact_boundaries() {
let mut budget = ArchiveBudget::new(4, 6, 3);
budget.register_entries(3).unwrap();
assert!(budget.register_entries(1).is_err());
let mut output = Vec::new();
budget
.copy_entry(&mut Cursor::new(b"abcd"), &mut output, 4, "first")
.unwrap();
budget
.copy_entry(&mut Cursor::new(b"ef"), &mut output, 2, "second")
.unwrap();
assert_eq!(output, b"abcdef");
assert!(
budget
.copy_entry(&mut Cursor::new(b"g"), &mut output, 1, "third")
.is_err()
);
assert_eq!(output, b"abcdef");
}
#[test]
fn declared_and_actual_sizes_are_both_limited() {
let mut budget = ArchiveBudget::new(4, 10, 10);
let mut output = Vec::new();
assert!(
budget
.copy_entry(&mut Cursor::new(b"abcde"), &mut output, 5, "large header")
.is_err()
);
assert!(output.is_empty());
assert!(
budget
.copy_entry(&mut Cursor::new(b"abcde"), &mut output, 4, "lying header")
.is_err()
);
assert!(output.is_empty());
assert!(
budget
.copy_entry(&mut Cursor::new(b"abc"), &mut output, 4, "short entry")
.is_err()
);
}
#[test]
fn failed_writes_still_consume_the_read_budget() {
struct FailingWriter;
impl Write for FailingWriter {
fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
Err(std::io::Error::other("blocked"))
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let mut budget = ArchiveBudget::new(4, 4, 10);
assert!(
budget
.copy_entry(&mut Cursor::new(b"abcd"), &mut FailingWriter, 4, "first")
.is_err()
);
assert!(
budget
.copy_entry(&mut Cursor::new(b"x"), &mut Vec::new(), 1, "retry")
.is_err()
);
}
fn end_record(entries: u16, directory_bytes: u32) -> [u8; 22] {
let mut record = [0_u8; 22];
record[..4].copy_from_slice(b"PK\x05\x06");
record[8..10].copy_from_slice(&entries.to_le_bytes());
record[10..12].copy_from_slice(&entries.to_le_bytes());
record[12..16].copy_from_slice(&directory_bytes.to_le_bytes());
record
}
#[test]
fn preflight_rejects_large_counts_and_directory_before_indexing() {
let mut file = tempfile::tempfile().unwrap();
file.write_all(&end_record(50, 1000)).unwrap();
assert!(preflight_archive(&mut file, 49, 1000).is_err());
assert!(preflight_archive(&mut file, 50, 999).is_err());
assert_eq!(preflight_archive(&mut file, 50, 1000).unwrap(), 50);
}
#[test]
fn preflight_bounds_zip64_counts() {
let mut bytes = [0_u8; 76];
bytes[..4].copy_from_slice(b"PK\x06\x06");
bytes[4..12].copy_from_slice(&44_u64.to_le_bytes());
bytes[24..32].copy_from_slice(&100_001_u64.to_le_bytes());
bytes[32..40].copy_from_slice(&100_001_u64.to_le_bytes());
bytes[56..60].copy_from_slice(b"PK\x06\x07");
bytes[72..76].copy_from_slice(&1_u32.to_le_bytes());
let mut file = tempfile::tempfile().unwrap();
file.write_all(&bytes).unwrap();
file.write_all(&end_record(u16::MAX, u32::MAX)).unwrap();
assert!(preflight_archive(&mut file, 100_000, 1024).is_err());
assert_eq!(
preflight_archive(&mut file, 100_001, 1024).unwrap(),
100_001
);
}
#[test]
fn reopening_an_archive_does_not_charge_its_entries_twice() {
let temp = tempfile::tempdir().unwrap();
let first = temp.path().join("first.apk");
let second = temp.path().join("second.apk");
for path in [&first, &second] {
let mut writer = zip::ZipWriter::new(File::create(path).unwrap());
writer
.start_file("lib/test.so", zip::write::SimpleFileOptions::default())
.unwrap();
writer.write_all(b"payload").unwrap();
writer.finish().unwrap();
}
let mut budget = ArchiveBudget::new(1024, 4096, 1);
assert_eq!(budget.open_archive(&first).unwrap().len(), 1);
assert_eq!(budget.open_archive(&first).unwrap().len(), 1);
assert!(budget.open_archive(&second).is_err());
}
#[test]
fn parser_cannot_retry_an_unverified_older_end_record() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("misleading.apk");
let mut writer = zip::ZipWriter::new(File::create(&path).unwrap());
for name in ["one.so", "two.so"] {
writer
.start_file(name, zip::write::SimpleFileOptions::default())
.unwrap();
writer.write_all(b"payload").unwrap();
}
let mut file = writer.finish().unwrap();
// An invalid last record advertises a small archive. Without guarding
// fallback, the ZIP parser retries the older, larger directory.
file.seek(SeekFrom::End(0)).unwrap();
file.write_all(&end_record(1, 46)).unwrap();
drop(file);
let mut budget = ArchiveBudget::new(1024, 4096, 1);
assert!(budget.open_archive(&path).is_err());
}
#[test]
fn directory_indexing_respects_the_remaining_shared_budget() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("app.apk");
let mut writer = zip::ZipWriter::new(File::create(&path).unwrap());
writer
.start_file("lib/test.so", zip::write::SimpleFileOptions::default())
.unwrap();
writer.write_all(b"payload").unwrap();
writer.finish().unwrap();
let mut budget = ArchiveBudget::new(1024, 1024, 10);
budget
.copy_entry(
&mut Cursor::new(vec![0; 1024]),
&mut std::io::sink(),
1024,
"earlier",
)
.unwrap();
assert!(budget.open_archive(&path).is_err());
}
#[test]
fn manifest_probe_shares_the_extraction_budget() {
let mut budget = ArchiveBudget::new(10, 6, 10);
assert_eq!(
budget
.read_probe(&mut Cursor::new(b"abcdefghi"), 9, 4, "manifest")
.unwrap(),
b"abcd"
);
assert!(
budget
.copy_entry(&mut Cursor::new(b"xyz"), &mut Vec::new(), 3, "library")
.is_err()
);
assert!(
budget
.read_probe(&mut Cursor::new(b"abc"), 3, 3, "next manifest")
.is_err()
);
}
#[test]
fn preflight_rejects_truncated_records() {
for bytes in [&b""[..], &b"PK\x05\x06"[..], &b"not an archive"[..]] {
let mut file = tempfile::tempfile().unwrap();
file.write_all(bytes).unwrap();
assert!(preflight_archive(&mut file, 100, 1024).is_err());
}
}
}
+517
View File
@@ -0,0 +1,517 @@
//! Application-scoped module `0x0C` context for Android metadata recovery.
//!
//! One decoded module belongs to one application. Split APKs of that application
//! share it; unrelated games under the same scan do not. A metadata blob is
//! restored only after its application context is unambiguous, and the identity
//! of a successful restore includes that context: the same encrypted bytes can
//! decode differently under two module profiles.
use std::collections::{BTreeMap, HashMap};
use std::path::{Component, Path, PathBuf};
use anyhow::Result;
/// Where a decoded module `0x0C` image was read from.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ModuleSource {
/// Protected library this module was recovered from (package label or path).
pub label: String,
/// Stage-2 image name inside that library, when the restorer reported one.
pub image: Option<String>,
}
/// One application's collected module `0x0C` images.
///
/// Distinct byte profiles are kept. Duplicate libraries populate the same
/// context instead of replacing it, so a later game cannot win by being last.
#[derive(Debug, Default, Clone)]
pub(crate) struct ApplicationModules {
profiles: BTreeMap<String, Vec<u8>>,
sources: Vec<ModuleSource>,
}
impl ApplicationModules {
pub(crate) fn record(&mut self, module: &[u8], source: ModuleSource) {
let identity = crate::android::content_identity(module);
self.profiles
.entry(identity)
.or_insert_with(|| module.to_vec());
self.sources.push(source);
}
pub(crate) fn is_empty(&self) -> bool {
self.profiles.is_empty()
}
/// The single module image, or `None` when this application recovered none
/// or more than one distinct profile.
pub(crate) fn unambiguous_module(&self) -> Option<&[u8]> {
let mut values = self.profiles.values();
let first = values.next()?;
values.next().is_none().then_some(first.as_slice())
}
pub(crate) fn profile_count(&self) -> usize {
self.profiles.len()
}
pub(crate) fn sources(&self) -> &[ModuleSource] {
&self.sources
}
}
/// Why a metadata blob cannot be paired with exactly one module `0x0C`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ContextAmbiguity {
/// No library in this application yielded a module.
Missing,
/// Libraries in this application yielded more than one distinct module.
Conflicting { profiles: usize },
}
/// Run-scoped cache of restored libraries and application module contexts.
///
/// The public package API still takes a content-identity set. This cache is
/// internal: a library hit reuses only its recovered module context, while a
/// metadata hit is keyed by recovery context as well as the encrypted bytes.
#[derive(Debug, Default)]
pub(crate) struct AndroidRunCache {
pub(super) limits: senbei_engine::android::ResourceLimits,
pub(super) archive: super::archive_limits::ArchiveBudget,
apps: HashMap<AppKey, ApplicationModules>,
/// Successfully restored protected libraries, keyed by encrypted content.
libraries: HashMap<String, CachedLibrary>,
/// Successfully handled metadata, keyed by encrypted content and context.
metadata: HashMap<MetadataCacheKey, CachedMetadata>,
/// Content identities already restored before this cache existed (the public
/// `HashSet` contract). They skip a repeat of the same bytes but carry no
/// module image, so they never become another application's profile.
prior_success: std::collections::HashSet<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct MetadataCacheKey {
content: String,
context: String,
}
#[derive(Debug, Clone)]
pub(crate) struct CachedLibrary {
/// Module `0x0C` from a library whose restore, including any embedded
/// metadata write, succeeded. The restored image is not kept: a duplicate
/// writes nothing.
pub method_index_module: Option<Vec<u8>>,
pub module_image: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) enum CachedMetadata {
/// Successfully written package metadata. A duplicate does not write again.
Restored { remapped: usize },
/// The blob was already canonical under this context.
Unchanged,
}
impl AndroidRunCache {
pub(super) fn with_limits(limits: &senbei_engine::android::ResourceLimits) -> Self {
Self {
limits: *limits,
archive: super::archive_limits::ArchiveBudget::from_limits(limits),
..Self::default()
}
}
pub(crate) fn modules_mut(&mut self, key: &AppKey) -> &mut ApplicationModules {
self.apps.entry(key.clone()).or_default()
}
pub(crate) fn modules(&self, key: &AppKey) -> Option<&ApplicationModules> {
self.apps.get(key)
}
pub(crate) fn library(&self, content: &str) -> Option<&CachedLibrary> {
self.libraries.get(content)
}
pub(crate) fn insert_prior_success(&mut self, content: String) {
self.prior_success.insert(content);
}
pub(crate) fn is_prior_success(&self, content: &str) -> bool {
self.prior_success.contains(content)
}
/// Content identities safe to publish back to the public successful-only set:
/// prior successes plus libraries and metadata handled successfully here.
/// Metadata identities are included because the public set still dedups a
/// repeated blob inside one package call; cross-application recovery uses
/// the context-keyed cache instead of this set.
pub(crate) fn successful_content_identities(&self) -> Vec<String> {
let mut identities: Vec<String> = self.prior_success.iter().cloned().collect();
identities.extend(self.libraries.keys().cloned());
identities.extend(self.metadata.keys().map(|key| key.content.clone()));
identities.sort();
identities.dedup();
identities
}
pub(crate) fn insert_library(&mut self, content: String, cached: CachedLibrary) {
self.libraries.entry(content).or_insert(cached);
}
pub(crate) fn metadata(&self, content: &str, context: &str) -> Option<&CachedMetadata> {
self.metadata.get(&MetadataCacheKey {
content: content.to_owned(),
context: context.to_owned(),
})
}
pub(crate) fn insert_metadata(
&mut self,
content: String,
context: String,
cached: CachedMetadata,
) {
self.metadata
.entry(MetadataCacheKey { content, context })
.or_insert(cached);
}
}
/// Identity of one Android application inside a folder run or one package set.
///
/// Constructed only from manifest package name **and** version, or the
/// conservative extracted `lib/<abi>/` + `assets/` boundary. The same package
/// name in a different version, or in a different bundle / loose tree, is a
/// different application. Scan-root names and ZIP entry names are never used
/// as a guess.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) enum AppKey {
/// Manifest `package` plus `versionCode` (or `versionName` when the code is
/// absent). `package_set` is the directory of the package file, or the
/// bundle file itself, so the same name and version elsewhere do not merge.
Manifest {
package: String,
version: String,
package_set: PathBuf,
},
/// Parent of a real `lib/<abi>/` or `assets/` boundary.
Extracted(PathBuf),
/// No manifest and no extracted boundary. Isolated so it cannot see another
/// application's modules. Not shared with any other unidentifiable input.
Unidentifiable { source: String },
}
/// A metadata or library input assigned to one application context.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ScopedInput {
pub path: PathBuf,
pub key: AppKey,
}
/// Group loose Android targets by application without crossing unrelated games.
///
/// A `lib/<abi>/` library and an `assets/` metadata blob share a context only
/// when both sit directly under the same application root. Anything else is
/// isolated. Order of `libraries` and `metadata` does not matter.
pub(crate) fn group_loose_inputs(libraries: &[PathBuf], metadata: &[PathBuf]) -> Vec<ScopedInput> {
let mut scoped = Vec::with_capacity(libraries.len() + metadata.len());
for path in libraries {
scoped.push(ScopedInput {
key: loose_library_key(path),
path: path.clone(),
});
}
for path in metadata {
scoped.push(ScopedInput {
key: loose_metadata_key(path),
path: path.clone(),
});
}
scoped.sort_by(|left, right| left.path.cmp(&right.path));
scoped
}
fn loose_library_key(path: &Path) -> AppKey {
extracted_app_root(path).map_or_else(
|| AppKey::Unidentifiable {
source: path.to_string_lossy().into_owned(),
},
AppKey::Extracted,
)
}
fn loose_metadata_key(path: &Path) -> AppKey {
// `assets/bin/Data/Managed/Metadata/global-metadata.dat` and the shorter
// `assets/bin/Data/Managed/global-metadata.dat` both end at `assets/`.
assets_app_root(path).map_or_else(
|| AppKey::Unidentifiable {
source: path.to_string_lossy().into_owned(),
},
AppKey::Extracted,
)
}
/// Application root when `path` is `<root>/lib/<abi>/<library>.so`.
fn extracted_app_root(path: &Path) -> Option<PathBuf> {
let mut components: Vec<Component<'_>> = path.components().collect();
let file = components.pop()?;
let abi = components.pop()?;
let lib = components.pop()?;
if !component_is(file, path.file_name()?.to_str()?) {
return None;
}
// Compare components, not `Path::parent` display. A path joined from mixed
// separators still yields `lib` and `<abi>` as two normal components.
if !component_is(lib, "lib") || !matches!(abi, Component::Normal(_)) {
return None;
}
let root: PathBuf = components.into_iter().collect();
Some(if root.as_os_str().is_empty() {
PathBuf::from(".")
} else {
root
})
}
/// Application root when `path` is `<root>/assets/**/global-metadata.dat`.
fn assets_app_root(path: &Path) -> Option<PathBuf> {
let mut components: Vec<Component<'_>> = path.components().collect();
let name = components.pop()?;
if !component_is(name, crate::METADATA_FILE_NAME) {
return None;
}
let assets_at = components
.iter()
.rposition(|component| component_is(*component, "assets"))?;
// `assets/global-metadata.dat` and `assets/**/global-metadata.dat` both
// belong to the directory that contains `assets`.
let root: PathBuf = components[..assets_at].iter().collect();
Some(if root.as_os_str().is_empty() {
PathBuf::from(".")
} else {
root
})
}
fn component_eq(path: &Path, expected: &str) -> bool {
// The final component must be exactly `expected`. `file_name` on a directory
// path is that component; requiring the whole path to equal it would reject
// every application root that is not a single path segment.
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.eq_ignore_ascii_case(expected))
}
fn component_is(component: Component<'_>, expected: &str) -> bool {
match component {
Component::Normal(name) => name
.to_str()
.is_some_and(|name| name.eq_ignore_ascii_case(expected)),
_ => false,
}
}
/// One ZIP whose central directory was listed while naming an application.
///
/// `reopen_key` is the on-disk package (or extracted nested APK) to open again
/// when entries are restored. The resource budget charges this archive's
/// physical entry count on the first inventory pass only; reopening it for
/// entry extraction is not a second package.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct InventoriedArchive {
pub reopen_key: PathBuf,
/// Central-directory index of `AndroidManifest.xml`, when one was present.
/// `None` means the manifest entry was absent and nothing was read.
pub manifest_index: Option<usize>,
/// Central-directory entry count observed by this inventory pass.
pub entry_count: usize,
}
/// Inventory of one package (or nested split) that can name its application.
#[derive(Debug, Clone)]
pub(crate) struct PackageInventory {
pub key: AppKey,
/// True when `key` came from a parsed manifest `package` and version.
/// Distinguishes a named package from an isolated one for a later report.
#[allow(dead_code)]
pub manifest_named: bool,
/// Reopen handle for this archive. Entry extraction uses the already-open
/// handle; this records the central directory that must be charged once.
pub archive: InventoriedArchive,
}
/// Manifest-first application identity for one ZIP package.
///
/// Reads only a bounded root `AndroidManifest.xml` entry, supporting plain and
/// compiled XML. A missing, truncated, or unrecognized manifest isolates the
/// package instead of guessing from the ZIP filename. `package_set` is the
/// bundle or directory that owns this set of
/// splits — the same package name outside that boundary is a different app.
///
/// The returned [`InventoriedArchive`] is the reopen handle. Callers restore
/// entries from `archive.reopen_key` and must not treat that second open as a
/// newly discovered package.
pub(crate) fn inventory_manifest_package<R: std::io::Read + std::io::Seek>(
archive: &mut zip::ZipArchive<R>,
reopen_key: &Path,
package_set: &Path,
isolate_as: &str,
budget: &mut super::archive_limits::ArchiveBudget,
) -> Result<PackageInventory> {
let entry_count = archive.len();
let mut manifest_index = None;
for index in 0..entry_count {
let entry = archive.by_index(index)?;
if entry.is_dir() {
continue;
}
let Some(name) = entry.enclosed_name() else {
continue;
};
// Root manifest only. Nested paths and every other entry stay unread.
if name.components().count() == 1 && component_eq(&name, "AndroidManifest.xml") {
manifest_index = Some(index);
break;
}
}
let Some(index) = manifest_index else {
return Ok(PackageInventory {
key: AppKey::Unidentifiable {
source: isolate_as.to_owned(),
},
manifest_named: false,
archive: InventoriedArchive {
reopen_key: reopen_key.to_path_buf(),
manifest_index: None,
entry_count,
},
});
};
let mut entry = archive.by_index(index)?;
// The package and version attributes are in the root `<manifest` start tag.
// Bound and charge this probe against the shared extraction budget. It
// never opens assets or other entries. A manifest larger than the bound is
// truncated and produces no identity rather than a partial guess.
const MANIFEST_PROBE: usize = 4 * 1024 * 1024;
let size = entry.size();
let buffer = budget.read_probe(&mut entry, size, MANIFEST_PROBE, "AndroidManifest.xml")?;
let truncated = size > MANIFEST_PROBE as u64;
let named = super::manifest::identity(&buffer, truncated);
Ok(PackageInventory {
key: match &named {
Some((package, version)) => AppKey::Manifest {
package: package.clone(),
version: version.clone(),
package_set: package_set.to_path_buf(),
},
None => AppKey::Unidentifiable {
source: isolate_as.to_owned(),
},
},
manifest_named: named.is_some(),
archive: InventoriedArchive {
reopen_key: reopen_key.to_path_buf(),
manifest_index: Some(index),
entry_count,
},
})
}
/// Recovery-context identity mixed into metadata dedup.
///
/// Same encrypted bytes under two modules (or under "no module" versus a module)
/// must not collapse to one cached output.
pub(crate) fn metadata_context_identity(module: Option<&[u8]>) -> String {
match module {
Some(module) => format!("module:{}", crate::android::content_identity(module)),
None => "module:none".to_owned(),
}
}
/// Resolve the module a metadata blob may use, refusing an ambiguous context
/// before any output is written.
pub(crate) fn binding_module(
modules: Option<&ApplicationModules>,
) -> std::result::Result<Option<&[u8]>, ContextAmbiguity> {
let Some(modules) = modules else {
return Ok(None);
};
if modules.is_empty() {
return Err(ContextAmbiguity::Missing);
}
match modules.unambiguous_module() {
Some(module) => Ok(Some(module)),
None => Err(ContextAmbiguity::Conflicting {
profiles: modules.profile_count(),
}),
}
}
/// Provenance recorded on a metadata outcome: every library that contributed
/// the module actually used, or the reason no module was bound.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct MetadataProvenance {
pub sources: Vec<ModuleSource>,
pub note: Option<String>,
}
pub(crate) fn provenance_for(
modules: Option<&ApplicationModules>,
ambiguity: Option<&ContextAmbiguity>,
) -> MetadataProvenance {
match ambiguity {
Some(ContextAmbiguity::Missing) => MetadataProvenance {
sources: modules
.map(|modules| modules.sources().to_vec())
.unwrap_or_default(),
note: Some("no module 0x0C in this application".to_owned()),
},
Some(ContextAmbiguity::Conflicting { profiles }) => MetadataProvenance {
sources: modules
.map(|modules| modules.sources().to_vec())
.unwrap_or_default(),
note: Some(format!(
"ambiguous module 0x0C binding ({profiles} distinct profiles)"
)),
},
None => MetadataProvenance {
sources: modules
.map(|modules| modules.sources().to_vec())
.unwrap_or_default(),
note: None,
},
}
}
/// What a library restore contributes to an application context.
#[derive(Debug, Clone)]
pub(crate) struct RestoredLibrary {
pub embedded_metadata: Option<Vec<u8>>,
pub method_index_module: Option<Vec<u8>>,
pub module_image: Option<String>,
}
/// Injectable library restore. Tests supply a fake so application-context
/// behaviour does not need a protected ELF fixture. Production calls the
/// stage-2 extractor.
pub(crate) trait LibraryRestorer {
fn restore(&mut self, input: &Path, dest: &Path, verbose: bool) -> Result<RestoredLibrary>;
/// Bytes that should be treated as a protected library without the ELF
/// probe. Production returns `None`; tests return the synthetic payload so
/// a package entry can reach the restorer.
fn protected_override(&mut self, _input: &Path) -> Option<Vec<u8>> {
None
}
}
/// Production restorer: stage-2 extract, ELF rebuild, embedded metadata unwrap.
#[derive(Default)]
pub(crate) struct EngineRestorer(pub(super) senbei_engine::android::ResourceLimits);
impl LibraryRestorer for EngineRestorer {
fn restore(&mut self, input: &Path, dest: &Path, verbose: bool) -> Result<RestoredLibrary> {
crate::android::restore_so_with_engine_limits(input, dest, verbose, &self.0)
}
}
+713
View File
@@ -0,0 +1,713 @@
//! Bounded identity of one Android manifest.
//!
//! The only result is `(package, version)`. Version is `android:versionCode`,
//! with `android:versionCodeMajor` prefixed when it is present and non-zero.
//! `android:versionName` is not an identity. A truncated or corrupt manifest
//! returns `None` rather than a partial guess.
//!
//! Chunk layout follows `androidfw/ResourceTypes.h`: a start element is a
//! `ResXMLTree_node` (`headerSize` is normally 16) followed by `ResXMLTree_attrExt`
//! at that header size. Attribute records begin at `headerSize + attributeStart`.
//! `Res_value::dataType` is the byte immediately before the data word.
/// `(package, version)` when the root element is `manifest` and carries a
/// package plus a version code. Truncated probes never supply a binding.
pub(super) fn identity(bytes: &[u8], truncated: bool) -> Option<(String, String)> {
if truncated {
return None;
}
if bytes.len() >= 8 && u16::from_le_bytes([bytes[0], bytes[1]]) == 0x0003 {
return binary_identity(bytes);
}
plain_identity(bytes, truncated)
}
const TYPE_STRING: u8 = 0x03;
const TYPE_INT_DEC: u8 = 0x10;
const TYPE_INT_HEX: u8 = 0x11;
const VERSION_CODE: u32 = 0x0101_021b;
const VERSION_CODE_MAJOR: u32 = 0x0101_0576;
const NO_STRING: u32 = 0xffff_ffff;
fn binary_identity(bytes: &[u8]) -> Option<(String, String)> {
if bytes.len() < 8 {
return None;
}
let file_size = u32::from_le_bytes(bytes.get(4..8)?.try_into().ok()?) as usize;
if file_size < 8 || file_size > bytes.len() {
return None;
}
let bytes = bytes.get(..file_size)?;
let mut offset = read_u16(bytes, 2)? as usize;
if offset < 8 || offset > bytes.len() {
return None;
}
let mut pool: Option<StringPool<'_>> = None;
let mut resource_map = Vec::new();
while offset + 8 <= bytes.len() {
let kind = read_u16(bytes, offset)?;
let header = read_u16(bytes, offset + 2)? as usize;
let size = read_u32(bytes, offset + 4)? as usize;
if header < 8 || size < header || offset.checked_add(size)? > bytes.len() {
return None;
}
let chunk = bytes.get(offset..offset + size)?;
match kind {
0x0001 => pool = Some(StringPool::parse(chunk)?),
0x0180 => resource_map = resource_map_of(chunk)?,
0x0102 => return start_element_identity(chunk, pool.as_ref()?, &resource_map),
_ => {}
}
offset += size;
}
None
}
fn start_element_identity(
chunk: &[u8],
pool: &StringPool<'_>,
resource_map: &[u32],
) -> Option<(String, String)> {
let header = read_u16(chunk, 2)? as usize;
// ResXMLTree_attrExt sits headerSize bytes after the chunk start.
if header < 16 || chunk.len() < header.checked_add(20)? {
return None;
}
let ext = header;
let name = read_u32(chunk, ext + 4)?;
if pool.get(name)?.as_str() != "manifest" {
return None;
}
let attribute_start = read_u16(chunk, ext + 8)? as usize;
let attribute_size = read_u16(chunk, ext + 10)? as usize;
let attribute_count = read_u16(chunk, ext + 12)? as usize;
if attribute_size < 20 || attribute_start < 20 {
return None;
}
let base = header.checked_add(attribute_start)?;
let bytes = attribute_count.checked_mul(attribute_size)?;
if base.checked_add(bytes)? > chunk.len() {
return None;
}
let mut package = None;
let mut version_code = None;
let mut version_major = None;
for index in 0..attribute_count {
let at = base + index * attribute_size;
let record = chunk.get(at..at + 20)?;
let namespace = read_u32(record, 0)?;
let name = read_u32(record, 4)?;
let raw = read_u32(record, 8)?;
// Res_value: size u16, res0 u8, dataType u8, data u32.
let data_type = *record.get(15)?;
let data = read_u32(record, 16)?;
let name_text = pool.get(name)?;
let resource = resource_map.get(name as usize).copied();
// `package` is the un-namespaced attribute. A resource id is never
// used for it: `0x01010003` is `android:name`, not the package.
if namespace == NO_STRING && name_text == "package" {
package = Some(attribute_text(pool, raw, data_type, data)?);
} else if name_text == "versionCode" || resource == Some(VERSION_CODE) {
version_code = Some(version_number(pool, raw, data_type, data)?);
} else if name_text == "versionCodeMajor" || resource == Some(VERSION_CODE_MAJOR) {
version_major = Some(version_number(pool, raw, data_type, data)?);
}
}
finish_identity(package?, version_code, version_major)
}
fn attribute_text(pool: &StringPool<'_>, raw: u32, data_type: u8, data: u32) -> Option<String> {
if data_type == TYPE_STRING {
return pool.get(data);
}
if raw != NO_STRING {
return pool.get(raw);
}
None
}
fn version_number(pool: &StringPool<'_>, raw: u32, data_type: u8, data: u32) -> Option<String> {
match data_type {
TYPE_INT_DEC | TYPE_INT_HEX => Some(data.to_string()),
TYPE_STRING => normalize_version(&pool.get(data)?),
_ if raw != NO_STRING => normalize_version(&pool.get(raw)?),
_ => None,
}
}
fn finish_identity(
package: String,
version_code: Option<String>,
version_major: Option<String>,
) -> Option<(String, String)> {
if !is_package_token(&package) {
return None;
}
let code = version_code?;
let version = match version_major {
Some(major) if major != "0" => format!("{major}.{code}"),
_ => code,
};
if !is_version_token(&version) {
return None;
}
Some((package, version))
}
struct StringPool<'a> {
bytes: &'a [u8],
offsets: Vec<u32>,
strings_start: usize,
utf8: bool,
}
impl<'a> StringPool<'a> {
fn parse(chunk: &'a [u8]) -> Option<Self> {
if chunk.len() < 28 {
return None;
}
let count = read_u32(chunk, 8)? as usize;
let flags = read_u32(chunk, 16)?;
let strings_start = read_u32(chunk, 20)? as usize;
// Offsets begin after the 28-byte pool header. A count whose offset
// table does not fit is corrupt. There is no fixed cap on the count:
// the bound is that the table fits and later decoding stays within
// twice the chunk.
let table = 28_usize.checked_add(count.checked_mul(4)?)?;
if table > chunk.len() || strings_start < table || strings_start > chunk.len() {
return None;
}
let mut offsets = Vec::new();
offsets.try_reserve_exact(count).ok()?;
for index in 0..count {
offsets.push(read_u32(chunk, 28 + index * 4)?);
}
Some(Self {
bytes: chunk,
offsets,
strings_start,
utf8: flags & (1 << 8) != 0,
})
}
fn get(&self, index: u32) -> Option<String> {
if index == NO_STRING {
return None;
}
let start = self
.strings_start
.checked_add(*self.offsets.get(index as usize)? as usize)?;
let text = if self.utf8 {
utf8_at(self.bytes, start)?.to_owned()
} else {
utf16_at(self.bytes, start)?
};
// One looked-up string cannot be larger than the pool that holds it.
if text.len() > self.bytes.len().saturating_mul(2) {
return None;
}
Some(text)
}
}
fn utf8_at(bytes: &[u8], mut offset: usize) -> Option<&str> {
let (_, next) = utf8_len(bytes, offset)?;
offset = next;
let (byte_len, next) = utf8_len(bytes, offset)?;
offset = next;
let end = offset.checked_add(byte_len)?;
let text = bytes.get(offset..end)?;
// The character after the bytes is a NUL terminator.
if *bytes.get(end)? != 0 {
return None;
}
std::str::from_utf8(text).ok()
}
fn utf8_len(bytes: &[u8], offset: usize) -> Option<(usize, usize)> {
let first = *bytes.get(offset)?;
if first & 0x80 == 0 {
Some((first as usize, offset + 1))
} else {
let second = *bytes.get(offset + 1)? as usize;
Some((((first as usize & 0x7f) << 8) | second, offset + 2))
}
}
fn utf16_at(bytes: &[u8], offset: usize) -> Option<String> {
let first = read_u16(bytes, offset)?;
let (len, data_at) = if first & 0x8000 == 0 {
(first as usize, offset + 2)
} else {
let second = read_u16(bytes, offset + 2)? as usize;
((((first as usize) & 0x7fff) << 16) | second, offset + 4)
};
let end = data_at.checked_add(len.checked_mul(2)?)?;
if read_u16(bytes, end)? != 0 {
return None;
}
let raw = bytes.get(data_at..end)?;
let units = raw.as_chunks::<2>().0;
// Avoid allocating a UTF-16 copy larger than the chunk that holds it.
if units.len().saturating_mul(2) > bytes.len().saturating_mul(2) {
return None;
}
let owned: Vec<u16> = units.iter().map(|pair| u16::from_le_bytes(*pair)).collect();
String::from_utf16(&owned).ok()
}
fn resource_map_of(chunk: &[u8]) -> Option<Vec<u32>> {
let body = chunk.get(8..)?;
let mut ids = Vec::new();
ids.try_reserve_exact(body.len() / 4).ok()?;
for item in body.as_chunks::<4>().0 {
ids.push(u32::from_le_bytes(*item));
}
Some(ids)
}
fn plain_identity(bytes: &[u8], truncated: bool) -> Option<(String, String)> {
let text = std::str::from_utf8(bytes).ok()?;
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
let rest = skip_preamble(text)?;
if !rest.starts_with("<manifest") {
return None;
}
let mut cursor = &rest["<manifest".len()..];
if cursor.starts_with(|ch: char| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | ':')) {
return None;
}
let mut package = None;
let mut version_code = None;
let mut version_major = None;
let mut closed = false;
loop {
cursor = cursor.trim_start();
if cursor.is_empty() {
break;
}
if cursor.starts_with('>') || cursor.starts_with("/>") {
closed = true;
break;
}
if cursor.starts_with("<!--") {
let close = cursor.find("-->")?;
cursor = &cursor[close + 3..];
continue;
}
let name_end = cursor
.find(|ch: char| ch.is_whitespace() || ch == '=')
.unwrap_or(cursor.len());
let name = &cursor[..name_end];
if name.is_empty() {
return None;
}
cursor = cursor[name_end..].trim_start();
if !cursor.starts_with('=') {
return None;
}
cursor = cursor[1..].trim_start();
let quote = cursor.chars().next()?;
if quote != '"' && quote != '\'' {
return None;
}
cursor = &cursor[quote.len_utf8()..];
let value_end = cursor.find(quote)?;
let value = &cursor[..value_end];
cursor = &cursor[value_end + quote.len_utf8()..];
match name {
"package" => package = Some(value.to_owned()),
"android:versionCode" => version_code = normalize_version(value),
"android:versionCodeMajor" => version_major = normalize_version(value),
_ => {}
}
}
if !closed || truncated {
return None;
}
finish_identity(package?, version_code, version_major)
}
/// Skip a BOM (already removed), XML declarations, and comments. A comment
/// that contains `<manifest` is not the root.
fn skip_preamble(mut text: &str) -> Option<&str> {
loop {
text = text.trim_start();
if text.starts_with("<?") {
let close = text.find("?>")?;
text = &text[close + 2..];
continue;
}
if text.starts_with("<!--") {
let close = text.find("-->")?;
text = &text[close + 3..];
continue;
}
break;
}
Some(text)
}
fn normalize_version(value: &str) -> Option<String> {
if value.starts_with(['-', '+']) {
return None;
}
let number = if let Some(hex) = value
.strip_prefix("0x")
.or_else(|| value.strip_prefix("0X"))
{
u32::from_str_radix(hex, 16).ok()?
} else {
value.parse::<u32>().ok()?
};
Some(number.to_string())
}
fn is_package_token(value: &str) -> bool {
!value.is_empty()
&& value
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-'))
}
fn is_version_token(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 80
&& value
.chars()
.all(|ch| ch.is_ascii_digit() || matches!(ch, '.' | '-' | 'x' | 'a'..='f'))
}
fn read_u16(bytes: &[u8], offset: usize) -> Option<u16> {
Some(u16::from_le_bytes(
bytes.get(offset..offset + 2)?.try_into().ok()?,
))
}
fn read_u32(bytes: &[u8], offset: usize) -> Option<u32> {
Some(u32::from_le_bytes(
bytes.get(offset..offset + 4)?.try_into().ok()?,
))
}
#[cfg(test)]
mod tests {
use super::*;
fn plain(body: &str) -> Vec<u8> {
body.as_bytes().to_vec()
}
#[test]
fn plain_requires_version_code_and_skips_comments() {
let text = plain(
"<?xml version=\"1.0\"?>\n<!-- <manifest package=\"fake.comment\" android:versionCode=\"9\"/> -->\n<manifest package='example.game' android:versionName=\"same\" android:versionCode=\"12\" android:versionCodeMajor=\"3\">\n</manifest>\n",
);
assert_eq!(
identity(&text, false),
Some(("example.game".to_owned(), "3.12".to_owned()))
);
let named = plain("<manifest package=\"example.game\" android:versionName=\"1\"/>");
assert_eq!(identity(&named, false), None);
let substring = plain("<notmanifest package=\"example.game\" android:versionCode=\"1\"/>");
assert_eq!(identity(&substring, false), None);
}
#[test]
fn plain_truncated_root_is_not_an_identity() {
let cut = plain("<manifest package=\"example.game\" android:versionCode=\"4\"");
assert_eq!(identity(&cut, false), None);
assert_eq!(identity(&cut, true), None);
}
#[test]
fn plain_hex_version_normalizes() {
let text = plain("<manifest package=\"example.game\" android:versionCode=\"0x10\"/>");
assert_eq!(
identity(&text, false),
Some(("example.game".to_owned(), "16".to_owned()))
);
}
#[test]
fn binary_typed_package_and_nonzero_major() {
// rawValue is 0xFFFFFFFF. The package exists only as a TYPE_STRING
// data index, and versionCodeMajor is a real non-zero integer.
let bytes = axml_with_data(
"manifest",
&[
("package", NO_STRING, "example.typed", TYPE_STRING, 0),
("versionCode", NO_STRING, "", TYPE_INT_DEC, 7),
("versionCodeMajor", NO_STRING, "", TYPE_INT_DEC, 2),
],
Encoding::Utf8,
);
assert_eq!(
identity(&bytes, false),
Some(("example.typed".to_owned(), "2.7".to_owned()))
);
}
#[test]
fn binary_utf16_and_hex_version() {
let bytes = axml_with_data(
"manifest",
&[
("package", NO_STRING, "example.utf16", 0, 0),
("versionCode", NO_STRING, "", TYPE_INT_HEX, 0x20),
],
Encoding::Utf16,
);
assert_eq!(
identity(&bytes, false),
Some(("example.utf16".to_owned(), "32".to_owned()))
);
let _ = bytes;
}
#[test]
fn binary_rejects_other_root_and_android_name_resource() {
let other = axml(
"application",
&[
attr(NO_STRING, "package", "example.nope", 0, ""),
attr(NO_STRING, "versionCode", "1", 0, ""),
],
Encoding::Utf8,
);
assert_eq!(identity(&other, false), None);
// Resource id 0x01010003 is android:name. It must not become the package
// even when its string is a package-looking value.
let named = axml_resources(
"manifest",
&[("name", 0x0101_0003, "example.fake", TYPE_STRING)],
&[],
Encoding::Utf8,
);
assert_eq!(identity(&named, false), None);
}
#[test]
fn binary_truncated_and_corrupt_ranges() {
let bytes = axml(
"manifest",
&[
attr(NO_STRING, "package", "example.cut", 0, ""),
attr(NO_STRING, "versionCode", "1", 0, ""),
],
Encoding::Utf8,
);
let mut short = bytes.clone();
short.truncate(20);
assert_eq!(identity(&short, true), None);
let mut bad = bytes.clone();
bad[4..8].copy_from_slice(&4_u32.to_le_bytes());
assert_eq!(identity(&bad, false), None);
}
#[test]
fn zero_major_is_omitted() {
let bytes = axml_with_data(
"manifest",
&[
("package", NO_STRING, "example.zero", TYPE_STRING, 0),
("versionCode", NO_STRING, "", TYPE_INT_DEC, 9),
("versionCodeMajor", NO_STRING, "", TYPE_INT_DEC, 0),
],
Encoding::Utf8,
);
assert_eq!(
identity(&bytes, false),
Some(("example.zero".to_owned(), "9".to_owned()))
);
}
struct BuiltAttr {
name: &'static str,
raw: u32,
raw_text: &'static str,
data_type: u8,
data_text: &'static str,
}
fn attr(
_namespace: u32,
name: &'static str,
raw_text: &'static str,
data_type: u8,
data_text: &'static str,
) -> BuiltAttr {
BuiltAttr {
name,
raw: if raw_text.is_empty() { NO_STRING } else { 0 },
raw_text,
data_type,
data_text,
}
}
#[derive(Clone, Copy)]
enum Encoding {
Utf8,
Utf16,
}
fn axml(root: &str, attributes: &[BuiltAttr], encoding: Encoding) -> Vec<u8> {
let specs: Vec<(&str, u32, &str, u8, u32)> = attributes
.iter()
.map(|item| {
let data = if item.data_type == TYPE_INT_DEC || item.data_type == TYPE_INT_HEX {
item.data_text.parse().unwrap_or(0)
} else {
0
};
(item.name, item.raw, item.raw_text, item.data_type, data)
})
.collect();
axml_with_data(root, &specs, encoding)
}
fn axml_with_data(
root: &str,
attributes: &[(&str, u32, &str, u8, u32)],
encoding: Encoding,
) -> Vec<u8> {
axml_resources(root, &[], attributes, encoding)
}
fn axml_resources(
root: &str,
named_resources: &[(&str, u32, &str, u8)],
attributes: &[(&str, u32, &str, u8, u32)],
encoding: Encoding,
) -> Vec<u8> {
let mut strings = vec![root.to_owned()];
let mut specs: Vec<(u32, u32, u32, u8, u32)> = Vec::new();
for (name, raw, raw_text, data_type, data) in attributes {
let name_index = push_string(&mut strings, name);
let raw_index = if raw_text.is_empty() {
*raw
} else {
push_string(&mut strings, raw_text)
};
let data_index = if *data_type == TYPE_STRING {
if raw_text.is_empty() {
*data
} else {
push_string(&mut strings, raw_text)
}
} else {
*data
};
specs.push((NO_STRING, name_index, raw_index, *data_type, data_index));
}
for (name, _resource, text, data_type) in named_resources {
let name_index = push_string(&mut strings, name);
let value_index = push_string(&mut strings, text);
specs.push((NO_STRING, name_index, NO_STRING, *data_type, value_index));
}
let resources: Vec<u32> = {
let mut ids = vec![0_u32; strings.len()];
for (name, resource, _, _) in named_resources {
if let Some(index) = strings.iter().position(|item| item == name) {
ids[index] = *resource;
}
}
ids
};
let pool = encode_pool(&strings, encoding);
let mut map = Vec::new();
map.extend(u16::to_le_bytes(0x0180));
map.extend(u16::to_le_bytes(8));
map.extend(u32::to_le_bytes((8 + resources.len() * 4) as u32));
for id in &resources {
map.extend(u32::to_le_bytes(*id));
}
let mut element = Vec::new();
element.extend(u16::to_le_bytes(0x0102));
element.extend(u16::to_le_bytes(16));
let attribute_bytes = specs.len() * 20;
let size = 16 + 20 + attribute_bytes;
element.extend(u32::to_le_bytes(size as u32));
element.extend(u32::to_le_bytes(1)); // line
element.extend(u32::to_le_bytes(NO_STRING)); // comment
element.extend(u32::to_le_bytes(NO_STRING)); // namespace
element.extend(u32::to_le_bytes(0)); // name = root
element.extend(u16::to_le_bytes(20)); // attributeStart from attrExt
element.extend(u16::to_le_bytes(20));
element.extend(u16::to_le_bytes(specs.len() as u16));
element.extend(u16::to_le_bytes(0));
element.extend(u16::to_le_bytes(0));
element.extend(u16::to_le_bytes(0));
for (namespace, name, raw, data_type, data) in specs {
element.extend(u32::to_le_bytes(namespace));
element.extend(u32::to_le_bytes(name));
element.extend(u32::to_le_bytes(raw));
element.extend(u16::to_le_bytes(8));
element.push(0);
element.push(data_type);
element.extend(u32::to_le_bytes(data));
}
let mut bytes = Vec::new();
bytes.extend(u16::to_le_bytes(0x0003));
bytes.extend(u16::to_le_bytes(8));
bytes.extend(u32::to_le_bytes(
(8 + pool.len() + map.len() + element.len()) as u32,
));
bytes.extend(pool);
bytes.extend(map);
bytes.extend(element);
bytes
}
fn push_string(strings: &mut Vec<String>, value: &str) -> u32 {
if let Some(index) = strings.iter().position(|item| item == value) {
return index as u32;
}
strings.push(value.to_owned());
(strings.len() - 1) as u32
}
fn encode_pool(strings: &[String], encoding: Encoding) -> Vec<u8> {
let mut data = Vec::new();
let mut offsets = Vec::new();
for value in strings {
offsets.push(data.len() as u32);
match encoding {
Encoding::Utf8 => {
data.push(value.chars().count() as u8);
data.push(value.len() as u8);
data.extend(value.as_bytes());
data.push(0);
}
Encoding::Utf16 => {
let units: Vec<u16> = value.encode_utf16().collect();
data.extend(u16::to_le_bytes(units.len() as u16));
for unit in units {
data.extend(u16::to_le_bytes(unit));
}
data.extend(u16::to_le_bytes(0));
}
}
}
let mut chunk = Vec::new();
chunk.extend(u16::to_le_bytes(0x0001));
chunk.extend(u16::to_le_bytes(28));
let size = 28 + strings.len() * 4 + data.len();
chunk.extend(u32::to_le_bytes(size as u32));
chunk.extend(u32::to_le_bytes(strings.len() as u32));
chunk.extend(u32::to_le_bytes(0));
let flags: u32 = match encoding {
Encoding::Utf8 => 1 << 8,
Encoding::Utf16 => 0,
};
chunk.extend(u32::to_le_bytes(flags));
chunk.extend(u32::to_le_bytes((28 + strings.len() * 4) as u32));
chunk.extend(u32::to_le_bytes(0));
for offset in offsets {
chunk.extend(u32::to_le_bytes(offset));
}
chunk.extend(data);
chunk
}
}
File diff suppressed because it is too large. Load diff
+131
View File
@@ -0,0 +1,131 @@
use super::*;
use std::io::Cursor;
use zip::write::SimpleFileOptions;
fn package(entries: &[(&str, &[u8])]) -> Vec<u8> {
let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
for (name, bytes) in entries {
writer
.start_file(
*name,
SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated),
)
.unwrap();
writer.write_all(bytes).unwrap();
}
writer.finish().unwrap().into_inner()
}
#[test]
fn compressed_entry_limit_leaves_no_restored_output() {
let temp = tempfile::tempdir().unwrap();
let input = temp.path().join("app.apk");
std::fs::write(&input, package(&[("lib/test.so", &vec![0; 4096])])).unwrap();
let limits = ResourceLimits {
max_entry_bytes: 1024,
..ResourceLimits::default()
};
let out = temp.path().join("out");
let error = restore_package_with_limits(
&input,
Path::new("app.apk"),
&out,
&mut HashSet::new(),
false,
&limits,
)
.unwrap_err();
assert!(format!("{error:#}").contains("extraction limit"));
assert!(!out.exists());
}
#[test]
fn archive_bytes_are_cumulative_across_packages_in_a_folder_run() {
let temp = tempfile::tempdir().unwrap();
let first = temp.path().join("first.apk");
let second = temp.path().join("second.apk");
let bytes = package(&[("lib/test.so", &vec![0; 400])]);
std::fs::write(&first, &bytes).unwrap();
std::fs::write(&second, &bytes).unwrap();
let limits = ResourceLimits {
max_entry_bytes: 1024,
max_total_bytes: 600,
..ResourceLimits::default()
};
let mut cache = run_cache_with_limits(&limits);
let out = temp.path().join("out");
let first =
restore_package_cached(&first, Path::new("first.apk"), &out, &mut cache, false).unwrap();
assert!(matches!(first[0].status, EntryStatus::NotTarget));
let error = restore_package_cached(&second, Path::new("second.apk"), &out, &mut cache, false)
.unwrap_err();
assert!(format!("{error:#}").contains("extraction limit"));
}
#[test]
fn nested_entries_share_the_outer_archive_count_budget() {
let temp = tempfile::tempdir().unwrap();
let input = temp.path().join("bundle.apks");
let nested = package(&[("lib/test.so", b"not an ELF")]);
std::fs::write(&input, package(&[("base.apk", &nested)])).unwrap();
let limits = ResourceLimits {
max_entries: 1,
..ResourceLimits::default()
};
let error = restore_package_with_limits(
&input,
Path::new("bundle.apks"),
&temp.path().join("out"),
&mut HashSet::new(),
false,
&limits,
)
.unwrap_err();
assert!(format!("{error:#}").contains("entry limit"), "{error:#}");
}
#[test]
fn zero_depth_refuses_nested_apks_before_extracting_them() {
let temp = tempfile::tempdir().unwrap();
let input = temp.path().join("bundle.apks");
std::fs::write(&input, package(&[("base.apk", b"not even a ZIP")])).unwrap();
let limits = ResourceLimits {
max_depth: 0,
..ResourceLimits::default()
};
let error = restore_package_with_limits(
&input,
Path::new("bundle.apks"),
&temp.path().join("out"),
&mut HashSet::new(),
false,
&limits,
)
.unwrap_err();
assert!(
format!("{error:#}").contains("nesting depth 1"),
"{error:#}"
);
}
#[test]
fn failed_entry_extraction_removes_its_partial_temporary() {
let bytes = package(&[("lib/test.so", &vec![0; 4096])]);
let mut archive = ZipArchive::new(Cursor::new(bytes)).unwrap();
let workspace = tempfile::tempdir().unwrap();
let mut budget = archive_limits::ArchiveBudget::from_limits(&ResourceLimits {
max_entry_bytes: 1024,
..ResourceLimits::default()
});
assert!(
extract_entry(
&mut archive,
0,
&workspace,
Path::new("app.apk::lib/test.so"),
&mut budget
)
.is_err()
);
assert_eq!(std::fs::read_dir(workspace.path()).unwrap().count(), 0);
}
File diff suppressed because it is too large. Load diff
+198 -11
View File
@@ -1,16 +1,203 @@
//! Shared atomic filesystem writes for native orchestration.
use std::path::{Path, PathBuf};
use std::io::{self, Write};
use std::path::Path;
use std::thread;
use std::time::Duration;
/// Write `bytes` through a sibling temporary file and replace `dest` only after
/// the complete write succeeds.
pub(crate) fn write_atomic(dest: &Path, bytes: &[u8]) -> std::io::Result<()> {
let mut temporary_name = dest.as_os_str().to_os_string();
temporary_name.push(".senbei-tmp");
let temporary = PathBuf::from(temporary_name);
let result = std::fs::write(&temporary, bytes).and_then(|()| std::fs::rename(&temporary, dest));
if result.is_err() {
let _ = std::fs::remove_file(&temporary);
use tempfile::Builder;
/// Write `bytes` through a uniquely named temporary file in `dest`'s directory
/// and replace `dest` only after the complete write succeeds.
///
/// The temporary name is random, so concurrent writers of the same destination
/// do not share one sibling file. An existing `dest` is replaced. On any
/// failure the temporary file is removed and `dest` is left unchanged.
pub(crate) fn write_atomic(dest: &Path, bytes: &[u8]) -> io::Result<()> {
let parent = dest
.parent()
.filter(|parent| !parent.as_os_str().is_empty());
let parent = parent.unwrap_or_else(|| Path::new("."));
// Same directory as `dest`: the final replace must stay on one filesystem.
// The temporary is private to this call and is removed if persist is not
// reached, or if persist fails.
let mut temporary = Builder::new()
.prefix(".senbei-")
.suffix(".tmp")
.tempfile_in(parent)?;
temporary.write_all(bytes)?;
temporary.flush()?;
temporary.as_file().sync_all()?;
// `persist` replaces an existing file and, on failure, hands the temporary
// back. Drop deletes that file, so a failed replace cannot leave `dest`
// half-written. Windows can refuse the replace while another handle still
// has `dest` open (the file is mapped, or a concurrent replace has not
// dropped its handle yet). Retry these refusals briefly; a persistent
// permission or sharing failure still leaves the old destination intact.
const ATTEMPTS: u32 = 8;
let mut attempt = 0;
loop {
match temporary.persist(dest) {
Ok(_) => return Ok(()),
Err(error) if replace_in_progress(&error.error) && attempt + 1 < ATTEMPTS => {
thread::sleep(Duration::from_millis(1 << attempt));
attempt += 1;
temporary = error.file;
}
Err(error) => return Err(error.error),
}
}
}
/// A replace refused because the destination is momentarily in use.
///
/// `ERROR_ACCESS_DENIED` (5) is what Windows returns when `MoveFileExW` cannot
/// replace a file another handle still has open. `ERROR_SHARING_VIOLATION`
/// (32) is the same condition reported through the sharing check. These codes
/// can also mean a permanent denial, so retries are bounded and never remove
/// the existing destination to force replacement.
fn replace_in_progress(error: &io::Error) -> bool {
#[cfg(windows)]
{
matches!(error.raw_os_error(), Some(5 | 32))
}
#[cfg(not(windows))]
{
let _ = error;
false
}
}
#[cfg(test)]
mod tests {
use std::fs;
use std::sync::{Arc, Barrier};
use std::thread;
use super::*;
fn stray_names(dir: &Path) -> Vec<String> {
fs::read_dir(dir)
.expect("read destination directory")
.map(|entry| {
entry
.expect("directory entry")
.file_name()
.to_string_lossy()
.into_owned()
})
.filter(|name| name.starts_with(".senbei-") || name.ends_with(".senbei-tmp"))
.collect()
}
#[test]
fn writes_new_file_and_leaves_no_temporary() {
let dir = tempfile::tempdir().expect("temp dir");
let dest = dir.path().join("out.bin");
write_atomic(&dest, b"payload").expect("write");
assert_eq!(fs::read(&dest).unwrap(), b"payload");
assert!(stray_names(dir.path()).is_empty());
}
#[test]
fn overwrites_existing_destination() {
let dir = tempfile::tempdir().expect("temp dir");
let dest = dir.path().join("out.bin");
fs::write(&dest, b"previous").expect("seed");
write_atomic(&dest, b"replacement").expect("overwrite");
assert_eq!(fs::read(&dest).unwrap(), b"replacement");
assert!(stray_names(dir.path()).is_empty());
}
#[test]
fn failure_leaves_existing_destination_and_no_temporary() {
let dir = tempfile::tempdir().expect("temp dir");
let dest = dir.path().join("out.bin");
fs::create_dir(&dest).expect("destination directory");
let error = write_atomic(&dest, b"replacement").expect_err("cannot replace a directory");
assert_ne!(error.kind(), io::ErrorKind::AlreadyExists);
assert!(dest.is_dir());
assert!(stray_names(dir.path()).is_empty());
assert!(
stray_names(&dest).is_empty(),
"temporary file was left inside the destination"
);
}
#[test]
fn missing_parent_writes_nothing() {
let dir = tempfile::tempdir().expect("temp dir");
let parent = dir.path().join("missing");
let dest = parent.join("out.bin");
let error = write_atomic(&dest, b"payload").expect_err("missing parent");
assert_eq!(error.kind(), io::ErrorKind::NotFound);
assert!(!parent.exists());
}
#[test]
fn concurrent_writers_leave_one_complete_output() {
let dir = tempfile::tempdir().expect("temp dir");
let dest = Arc::new(dir.path().join("out.bin"));
let barrier = Arc::new(Barrier::new(8));
let mut handles = Vec::new();
for id in 0..8u8 {
let dest = Arc::clone(&dest);
let barrier = Arc::clone(&barrier);
handles.push(thread::spawn(move || {
barrier.wait();
let payload = vec![id; 64 * 1024];
write_atomic(&dest, &payload).expect("concurrent write");
id
}));
}
let ids: Vec<u8> = handles
.into_iter()
.map(|handle| handle.join().expect("writer thread"))
.collect();
let written = fs::read(dest.as_path()).expect("read destination");
let writer = written[0];
assert!(ids.contains(&writer));
assert!(written.iter().all(|byte| *byte == writer));
assert_eq!(written.len(), 64 * 1024);
assert!(stray_names(dir.path()).is_empty());
}
#[test]
fn distinct_temporary_names_survive_a_failed_replace() {
// Two writers no longer share `<dest>.senbei-tmp`. Each temporary is
// removed when its own replace fails, including when the destination
// cannot be replaced at all.
let dir = tempfile::tempdir().expect("temp dir");
let dest = dir.path().join("out.bin");
fs::create_dir(&dest).expect("destination directory");
let barrier = Arc::new(Barrier::new(2));
let mut handles = Vec::new();
for payload in [b"first".as_slice(), b"second".as_slice()] {
let dest = dest.clone();
let barrier = Arc::clone(&barrier);
let payload = payload.to_vec();
handles.push(thread::spawn(move || {
barrier.wait();
write_atomic(&dest, &payload)
}));
}
for handle in handles {
handle
.join()
.expect("writer thread")
.expect_err("directory destination");
}
assert!(dest.is_dir());
assert!(stray_names(dir.path()).is_empty());
assert!(stray_names(&dest).is_empty());
}
result
}
+200 -92
View File
@@ -95,6 +95,27 @@ pub fn run_folder_opts(
verbose: bool,
no_log: bool,
scan_all: bool,
) -> anyhow::Result<Summary> {
run_folder_with_limits(
root,
out_dir,
quiet,
verbose,
no_log,
scan_all,
&senbei_engine::android::ResourceLimits::default(),
)
}
/// Folder mode with explicit Android archive and decoder ceilings.
pub fn run_folder_with_limits(
root: &Path,
out_dir: Option<&Path>,
quiet: u8,
verbose: bool,
no_log: bool,
scan_all: bool,
limits: &senbei_engine::android::ResourceLimits,
) -> anyhow::Result<Summary> {
let t0 = std::time::Instant::now();
let out_root = out_dir
@@ -119,7 +140,7 @@ pub fn run_folder_opts(
// [`crate::scan::find_targets_opts`]). Files the free directory metadata
// already rules out are never opened — on asset-heavy trees the per-file
// open+read latency, not the traversal, is the whole cost.
let scan = crate::scan::find_targets_opts(root, scan_all);
let scan = crate::scan::find_targets_with_limits(root, scan_all, limits);
let candidates = scan.crackproof.as_slice();
let metas = scan.metadata.as_slice();
let scan_stats = &scan.stats;
@@ -165,10 +186,6 @@ pub fn run_folder_opts(
..Summary::default()
};
// Silence the default panic hook's stderr spew during per-file processing.
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {})); // suppress "thread panicked" messages
for input in candidates {
let rel = rel_in_tree(root, input);
let dest = out_root.join(out_name(&rel));
@@ -176,9 +193,9 @@ pub fn run_folder_opts(
// Wrap in catch_unwind so a single bad file never aborts the folder run.
let input_owned = input.clone();
let dest_owned = dest.clone();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let result = senbei_engine::catch_unwind_quiet(|| {
unpack_one_v(&input_owned, &dest_owned, verbose_steps)
}));
});
match result {
Ok(Ok((kind, report))) => {
@@ -217,41 +234,50 @@ pub fn run_folder_opts(
bar.inc(1);
}
// Android pass: protected AArch64 libraries and app packages. Loose `.so`
// files restore first so the cross-source dedup keeps them over a copy
// inside a package (loose beats `.apk` beats `.apks`/`.xapk` bundle).
let mut android_seen = std::collections::HashSet::new();
let mut android_method_index_module = None;
// Hashing a protected library costs a full read, so only pay it when a
// duplicate source can actually exist in this run.
let android_dedup = scan.android_so.len() > 1 || !scan.android_packages.is_empty();
for input in &scan.android_so {
let rel = rel_in_tree(root, input);
// Android pass. Loose libraries restore before packages so an identical
// library keeps the loose destination (loose beats `.apk` beats bundle).
// Module 0x0C stays inside the application that produced it: an extracted
// `lib/<abi>/` tree shares with that tree's `assets/` metadata only, and a
// package shares only with splits of the same package name and version.
let mut android_cache = crate::android::run_cache_with_limits(limits);
let loose = crate::android::loose_scopes(&scan.android_so, metas);
for scoped in loose.iter().filter(|scoped| {
scan.android_so
.iter()
.any(|library| library == &scoped.path)
}) {
let rel = rel_in_tree(root, &scoped.path);
let dest = out_root.join(out_name(&rel));
// Unreadable here is fine: the restore reports the same error.
if android_dedup
&& let Ok(identity) = crate::android::file_content_identity(input)
&& !android_seen.insert(identity)
{
let input_owned = scoped.path.clone();
let dest_owned = dest.clone();
let key = scoped.key.clone();
let label = rel.display().to_string();
let result = senbei_engine::catch_unwind_quiet(|| {
crate::android::restore_loose_library(
&input_owned,
&dest_owned,
&label,
&key,
verbose_steps,
&mut android_cache,
)
});
match result {
Ok(Ok(outcome)) => match outcome.status {
crate::android::EntryStatus::Duplicate => {
s.skipped += 1;
if let Some(log) = &log {
log.step(&format!("SKIP {rel:?}: duplicate of an earlier target"));
}
bar.inc(1);
continue;
}
let input_owned = input.clone();
let dest_owned = dest.clone();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
crate::android::restore_so_file_with_method_index_module(
&input_owned,
&dest_owned,
verbose_steps,
&mut android_method_index_module,
)
}));
match result {
Ok(Ok(embedded)) => {
crate::android::EntryStatus::Failed(e) => {
s.errors += 1;
crate::ui::err(&bar, suppress_file_lines, &rel, &e);
if let Some(log) = &log {
log.step(&format!("ERR {rel:?}: {e:#}"));
}
}
crate::android::EntryStatus::Restored => {
s.unpacked += 1;
crate::ui::ok_label(
&bar,
@@ -263,8 +289,7 @@ pub fn run_folder_opts(
if let Some(log) = &log {
log.step(&format!("OK {rel:?} -> {dest:?} (Android SO)"));
}
match write_embedded_metadata(embedded, &dest) {
Ok(Some(meta_dest)) => {
if let Some(meta_dest) = outcome.embedded {
s.metadata += 1;
crate::ui::ok_label(
&bar,
@@ -277,16 +302,11 @@ pub fn run_folder_opts(
log.step(&format!("META {rel:?} (embedded) -> {meta_dest:?}"));
}
}
Ok(None) => {}
Err(e) => {
s.errors += 1;
crate::ui::err(&bar, suppress_file_lines, &rel, &e);
if let Some(log) = &log {
log.step(&format!("ERR {rel:?}: embedded metadata: {e:#}"));
}
}
}
crate::android::EntryStatus::NotTarget | crate::android::EntryStatus::Unchanged => {
s.skipped += 1;
}
},
Ok(Err(e)) => {
s.errors += 1;
crate::ui::err(&bar, suppress_file_lines, &rel, &e);
@@ -308,31 +328,33 @@ pub fn run_folder_opts(
}
bar.inc(1);
}
for package in &scan.android_packages {
let rel = rel_in_tree(root, package);
s.packages += 1;
let package_owned = package.clone();
let rel_owned = rel.clone().into_owned();
// Loose sibling APKs that name the same package and version are one split
// set. Their libraries run before their metadata because each package
// restores its own libraries first and the shared cache collects modules
// across the group. Bundles stay one package each.
let package_groups =
crate::android::loose_apk_groups_cached(&scan.android_packages, &mut android_cache);
for group in &package_groups {
if group.len() > 1 {
s.packages += group.len();
let group_owned = group.clone();
let root_owned = root.to_path_buf();
let out_root_owned = out_root.clone();
let mut seen_taken = std::mem::take(&mut android_seen);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let outcomes = crate::android::restore_package_with_method_index_module(
&package_owned,
&rel_owned,
let result = senbei_engine::catch_unwind_quiet(|| {
crate::android::restore_apk_group(
&group_owned,
&root_owned,
&out_root_owned,
&mut seen_taken,
&mut android_cache,
verbose_steps,
&mut android_method_index_module,
);
(outcomes, seen_taken)
}));
)
});
let rel = rel_in_tree(root, &group[0]);
match result {
Ok((Ok(outcomes), seen_back)) => {
android_seen = seen_back;
Ok(Ok(outcomes)) => {
apply_package_outcomes(outcomes, &mut s, &bar, suppress_file_lines, &log);
}
Ok((Err(e), seen_back)) => {
android_seen = seen_back;
Ok(Err(e)) => {
s.errors += 1;
crate::ui::err(&bar, suppress_file_lines, &rel, &e);
if let Some(log) = &log {
@@ -340,8 +362,49 @@ pub fn run_folder_opts(
}
}
Err(panic) => {
// The dedup set may be in an unknown state after a panic; a
// re-scan costs a duplicate restore at worst, never corruption.
let e = anyhow::anyhow!("unexpected panic: {}", panic_payload(&panic));
s.errors += 1;
crate::ui::err(&bar, suppress_file_lines, &rel, &e);
if let Some(log) = &log {
log.step(&format!(
"ERR {rel:?}: panic during package restore: {}",
panic_payload(&panic)
));
}
}
}
bar.inc(group.len() as u64);
continue;
}
for package in group {
let rel = rel_in_tree(root, package);
s.packages += 1;
let package_owned = package.clone();
let rel_owned = rel.clone().into_owned();
let out_root_owned = out_root.clone();
let result = senbei_engine::catch_unwind_quiet(|| {
crate::android::restore_package_cached(
&package_owned,
&rel_owned,
&out_root_owned,
&mut android_cache,
verbose_steps,
)
});
match result {
Ok(Ok(outcomes)) => {
apply_package_outcomes(outcomes, &mut s, &bar, suppress_file_lines, &log);
}
Ok(Err(e)) => {
s.errors += 1;
crate::ui::err(&bar, suppress_file_lines, &rel, &e);
if let Some(log) = &log {
log.step(&format!("ERR {rel:?}: {e:#}"));
}
}
Err(panic) => {
// A panic may leave a partial module recorded. Keeping the cache
// preserves successful dedup; a conflict still fails closed.
let e = anyhow::anyhow!("unexpected panic: {}", panic_payload(&panic));
s.errors += 1;
crate::ui::err(&bar, suppress_file_lines, &rel, &e);
@@ -355,34 +418,43 @@ pub fn run_folder_opts(
}
bar.inc(1);
}
}
// il2cpp metadata pass. Crackproof's `-GMD` option obfuscates the method
// tokens in `global-metadata.dat`; de-obfuscate any we find so the unpacked
// il2cpp game assembly resolves methods instead of indexing its per-module
// tables out of bounds (see [`senbei_metadata`]). This is additive to the
// Crackproof module unpack above — the metadata blob is not itself a
// Crackproof file.
for meta in metas.iter() {
let rel = rel_in_tree(root, meta);
// il2cpp metadata pass. A loose blob is paired only with the application
// assigned when the tree was grouped. v24.1 with a missing or conflicting
// module writes nothing. Other versions still restore when the application
// has no conflicting module. The paired libraries are recorded on the log
// line.
for scoped in loose
.iter()
.filter(|scoped| metas.iter().any(|meta| meta == &scoped.path))
{
let rel = rel_in_tree(root, &scoped.path);
let dest = out_root.join(out_name(&rel));
let meta_owned = meta.clone();
let meta_owned = scoped.path.clone();
let dest_owned = dest.clone();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
deobfuscate_metadata_to_with_module(
let key = scoped.key.clone();
let label = rel.display().to_string();
let result = senbei_engine::catch_unwind_quiet(|| {
crate::android::restore_loose_metadata(
&meta_owned,
&dest_owned,
&label,
&key,
verbose_steps,
android_method_index_module.as_deref(),
&mut android_cache,
)
}));
});
match result {
Ok(Ok(report)) if report.remapped > 0 => {
s.metadata += 1;
crate::ui::metadata(&bar, suppress_file_lines, &rel, report.remapped, &dest);
if let Some(log) = &log {
log.step(&format!(
"META {rel:?} -> {dest:?}: v{} remapped {} method fields",
report.version, report.remapped
"META {rel:?} -> {dest:?}: v{} remapped {} method fields{}",
report.version,
report.remapped,
provenance_suffix(&report.provenance)
));
}
}
@@ -391,8 +463,9 @@ pub fn run_folder_opts(
Ok(Ok(report)) => {
if let Some(log) = &log {
log.step(&format!(
"META {rel:?}: v{} already de-obfuscated",
report.version
"META {rel:?}: v{} already de-obfuscated{}",
report.version,
provenance_suffix(&report.provenance)
));
}
}
@@ -443,9 +516,6 @@ pub fn run_folder_opts(
}
}
// Restore the original panic hook.
std::panic::set_hook(default_hook);
bar.finish_and_clear();
s.duration_ms = t0.elapsed().as_millis();
if let Some(log) = &log {
@@ -466,6 +536,25 @@ pub fn run_file_v(
quiet: u8,
verbose: bool,
no_log: bool,
) -> anyhow::Result<Summary> {
run_file_with_limits(
input,
out_dir,
quiet,
verbose,
no_log,
&senbei_engine::android::ResourceLimits::default(),
)
}
/// Single-file mode with explicit Android archive and decoder ceilings.
pub fn run_file_with_limits(
input: &Path,
out_dir: Option<&Path>,
quiet: u8,
verbose: bool,
no_log: bool,
limits: &senbei_engine::android::ResourceLimits,
) -> anyhow::Result<Summary> {
let t0 = std::time::Instant::now();
let out_root = out_dir
@@ -508,8 +597,8 @@ pub fn run_file_v(
// library probe needs the whole file (its payload section is found through
// the section-header table at the end), while a package is a container
// handled entry-by-entry. Anything else falls through to the PE pipeline.
let is_android_so =
crate::android::is_elf64_aarch64(&prefix) && crate::android::is_protected_so_file(input);
let is_android_so = crate::android::is_elf64_aarch64(&prefix)
&& crate::android::probe_so_file_with_limits(input, limits).unwrap_or(true);
let is_android_package = !is_android_so && crate::android::is_app_package(input, &prefix);
if is_meta {
@@ -555,7 +644,12 @@ pub fn run_file_v(
}
}
} else if is_android_so {
match crate::android::restore_so_file(input, &dest, verbose && quiet == 0) {
match crate::android::restore_so_file_with_limits(
input,
&dest,
verbose && quiet == 0,
limits,
) {
Ok(embedded) => {
s.unpacked = 1;
if let Some(log) = &log {
@@ -604,12 +698,13 @@ pub fn run_file_v(
s.packages = 1;
let rel = PathBuf::from(input.file_name().unwrap_or_default());
let mut seen = std::collections::HashSet::new();
match crate::android::restore_package(
match crate::android::restore_package_with_limits(
input,
&rel,
&out_root,
&mut seen,
verbose && quiet == 0,
limits,
) {
Ok(outcomes) => {
let bar = crate::ui::progress(0, true);
@@ -706,6 +801,14 @@ pub fn out_name(input: &Path) -> PathBuf {
}
}
fn provenance_suffix(sources: &[String]) -> String {
if sources.is_empty() {
String::new()
} else {
format!("; module 0x0C from {}", sources.join(", "))
}
}
/// Extract a printable message from a caught panic payload.
fn panic_payload(panic: &(dyn std::any::Any + Send)) -> String {
if let Some(s) = panic.downcast_ref::<&str>() {
@@ -829,7 +932,12 @@ fn apply_package_outcomes(
}
}
if let Some(log) = log {
log.step(&format!("OK {} -> {:?}", outcome.label, outcome.dest));
log.step(&format!(
"OK {} -> {:?}{}",
outcome.label,
outcome.dest,
provenance_suffix(&outcome.provenance)
));
}
}
EntryStatus::Duplicate | EntryStatus::NotTarget | EntryStatus::Unchanged => {
+19 -4
View File
@@ -111,6 +111,21 @@ pub struct ScanStats {
/// [`find_targets`], but with the pre-filter explicitly controlled. When
/// `scan_all` is true selected target names below [`MIN_SIZE`] are also probed.
pub fn find_targets_opts(root: &Path, scan_all: bool) -> ScanResult {
find_targets_with_limits(
root,
scan_all,
&senbei_engine::android::ResourceLimits::default(),
)
}
/// Like [`find_targets_opts`], with Android probe allocations bounded by the
/// same limits as restoration. An over-limit probe remains a restore candidate
/// so the job reports the limit error rather than silently skipping the file.
pub fn find_targets_with_limits(
root: &Path,
scan_all: bool,
limits: &senbei_engine::android::ResourceLimits,
) -> ScanResult {
// Phase 1: serial traversal collecting regular-file paths only. No file is
// opened here; `readdir` is fast relative to the content probe that follows,
// and `entry.metadata()` is served from the directory entry on Windows, so
@@ -177,7 +192,7 @@ pub fn find_targets_opts(root: &Path, scan_all: bool) -> ScanResult {
let workers = senbei_engine::thread_cap().clamp(1, n.max(1));
if workers <= 1 {
for (p, c) in paths.iter().zip(class.iter_mut()) {
*c = classify(p);
*c = classify(p, limits);
}
} else {
let chunk = n.div_ceil(workers);
@@ -185,7 +200,7 @@ pub fn find_targets_opts(root: &Path, scan_all: bool) -> ScanResult {
for (pc, cc) in paths.chunks(chunk).zip(class.chunks_mut(chunk)) {
scope.spawn(move || {
for (p, c) in pc.iter().zip(cc.iter_mut()) {
*c = classify(p);
*c = classify(p, limits);
}
});
}
@@ -239,7 +254,7 @@ pub fn scan_all_env() -> bool {
/// payload lives in a section found via the section-header table at the *end*
/// of the file, so an ELF64/AArch64 prefix triggers a full-file read. Only
/// selected `.so` images pay for it.
fn classify(path: &Path) -> Option<Class> {
fn classify(path: &Path, limits: &senbei_engine::android::ResourceLimits) -> Option<Class> {
let head = read_prefix(path, DETECT_PREFIX)?;
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
if crate::android::is_package_name(path) && crate::android::is_app_package(path, &head) {
@@ -253,7 +268,7 @@ fn classify(path: &Path) -> Option<Class> {
}
if crate::android::is_so_name(path)
&& crate::android::is_elf64_aarch64(&head)
&& crate::android::is_protected_so_file(path)
&& crate::android::probe_so_file_with_limits(path, limits).unwrap_or(true)
{
return Class::AndroidSo;
}
+280
View File
@@ -0,0 +1,280 @@
//! Conservative recognition of the loader's external-file declaration.
use super::{HEADER_OFF, splice_companion};
use senbei_engine::UnpackError;
/// Recognize the observed PE32+ loader entry record, rather than guessing from
/// file size, hollow sections, or an unpack failure (all occur in single-file
/// builds too). Its last complemented immediate is a suffix appended to the
/// module path before CreateFileW; zero means reopen the module itself.
fn expects_companion(input: &[u8]) -> Option<()> {
senbei_engine::detect(input)?;
let pe = senbei_pe::parse(input).ok()?;
if !pe.is_pe32_plus
|| u16::from_le_bytes(
input
.get(pe.pe_offset + 4..pe.pe_offset + 6)?
.try_into()
.ok()?,
) != 0x8664
|| pe.sections == 0
|| pe.size_of_image == 0
|| pe.sections_offset < pe.pe_offset + 24 + 0xf0
{
return None;
}
let headers_end = pe
.sections_offset
.checked_add(usize::from(pe.sections) * 40)?;
let header_size = u32::from_le_bytes(
input
.get(pe.pe_offset + 84..pe.pe_offset + 88)?
.try_into()
.ok()?,
) as usize;
if header_size < headers_end || header_size > input.len() {
return None;
}
// A truncated section table/raw section is evidence of a broken stub, not
// sufficient evidence to diagnose a missing companion.
for section in senbei_pe::sections(input, pe).ok()? {
if section.raw_size != 0 {
let end = section.raw_offset.checked_add(section.raw_size)? as usize;
input.get(section.raw_offset as usize..end)?;
}
if section
.virtual_address
.checked_add(section.virtual_size.max(section.raw_size))?
> pe.size_of_image
{
return None;
}
}
let range = senbei_pe::rva_range(input, pe, pe.entry_rva, 63).ok()?;
let entry = &input[range];
let dword = |offset: usize| u32::from_le_bytes(entry[offset..offset + 4].try_into().unwrap());
// Four call/ret entry thunks must all reach the same, file-backed loader.
if entry[0] != 0xe8 || entry[5] != 0xc3 {
return None;
}
let target = i64::from(pe.entry_rva) + 5 + i64::from(dword(1) as i32);
let target = u32::try_from(target).ok()?;
senbei_pe::rva_range(input, pe, target, 1).ok()?;
for (offset, argument) in [(6, 0), (14, 1), (22, 2)] {
if entry[offset..offset + 3] != [0x6a, argument, 0xe8]
|| entry[offset + 7] != 0xc3
|| i64::from(pe.entry_rva) + offset as i64 + 7 + i64::from(dword(offset + 3) as i32)
!= i64::from(target)
{
return None;
}
}
if entry[30..32] != [0x6a, 3]
|| [32, 37, 42, 47, 52, 57]
.iter()
.any(|&offset| entry[offset] != 0x68)
|| entry[62] != 0xc3
|| dword(48).wrapping_add(pe.entry_rva).wrapping_add(1) != 0
|| !dword(53) != HEADER_OFF as u32
|| !dword(58) != u32::from_le_bytes(*b"._\0\0")
{
return None;
}
Some(())
}
pub(super) fn prepare_companion(
input: &[u8],
companion: Option<&[u8]>,
) -> Result<Option<Vec<u8>>, UnpackError> {
let spliced = companion.and_then(|payload| splice_companion(input, payload));
if spliced.is_none() && expects_companion(input).is_some() {
return Err(if companion.is_some() {
UnpackError::CompanionMismatch
} else {
UnpackError::CompanionMissing
});
}
Ok(spliced)
}
#[cfg(test)]
mod tests {
use super::*;
fn put32(bytes: &mut [u8], offset: usize, value: u32) {
bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
}
/// Generated PE and entry record; no protected binary bytes are embedded.
fn stub() -> Vec<u8> {
let mut bytes = vec![0; 0x3000];
bytes[..2].copy_from_slice(b"MZ");
put32(&mut bytes, 0x3c, 0x80);
bytes[0x80..0x84].copy_from_slice(b"PE\0\0");
bytes[0x84..0x86].copy_from_slice(&0x8664u16.to_le_bytes());
bytes[0x86..0x88].copy_from_slice(&1u16.to_le_bytes());
bytes[0x94..0x96].copy_from_slice(&0xf0u16.to_le_bytes());
bytes[0x98..0x9a].copy_from_slice(&0x20bu16.to_le_bytes());
put32(&mut bytes, 0xa8, 0x1100);
put32(&mut bytes, 0xd0, 0x4000);
put32(&mut bytes, 0xd4, 0x1000);
bytes[0x188..0x18d].copy_from_slice(b".text");
for (offset, value) in [
(0x190, 0x2000),
(0x194, 0x1000),
(0x198, 0x1000),
(0x19c, 0x2000),
] {
put32(&mut bytes, offset, value);
}
put32(&mut bytes, HEADER_OFF, 0x12345678);
put32(&mut bytes, HEADER_OFF + 4, 0x12345678 ^ 0x4e4e4f4b);
let entry = &mut bytes[0x2100..0x213f];
entry[0] = 0xe8;
put32(entry, 1, (-0x105i32) as u32);
entry[5] = 0xc3;
for (offset, argument) in [(6, 0), (14, 1), (22, 2)] {
entry[offset..offset + 3].copy_from_slice(&[0x6a, argument, 0xe8]);
put32(entry, offset + 3, (-(0x100 + offset as i32 + 7)) as u32);
entry[offset + 7] = 0xc3;
}
entry[30..32].copy_from_slice(&[0x6a, 3]);
for offset in [32, 37, 42, 47, 52, 57] {
entry[offset] = 0x68;
}
put32(entry, 48, !0x1100);
put32(entry, 53, !(HEADER_OFF as u32));
put32(entry, 58, !u32::from_le_bytes(*b"._\0\0"));
entry[62] = 0xc3;
bytes
}
#[test]
fn missing_companion_reaches_both_byte_entry_points() {
let input = stub();
for force in [false, true] {
let error = super::super::unpack_bytes_impl(&input, None, force)
.err()
.unwrap();
assert_eq!(error, UnpackError::CompanionMissing);
assert!(error.to_string().contains("Companion missing?"));
}
}
#[test]
fn provided_companion_is_not_reported_missing() {
let input = stub();
for payload in [&[][..], &[0; 32][..]] {
assert_eq!(
prepare_companion(&input, Some(payload)),
Err(UnpackError::CompanionMismatch)
);
}
let payload = &input[HEADER_OFF..];
assert_eq!(
prepare_companion(&input, Some(payload)).unwrap().unwrap(),
input
);
// A matching but damaged payload keeps its original pipeline error.
let error = super::super::unpack_bytes(&input, Some(payload))
.err()
.unwrap();
assert!(!matches!(
error,
UnpackError::CompanionMissing | UnpackError::CompanionMismatch
));
}
#[test]
fn single_file_and_damaged_or_unknown_stubs_are_not_diagnosed_as_missing() {
let input = stub();
let mut single = input.clone();
put32(&mut single, 0x213a, !0);
assert!(expects_companion(&single).is_none());
assert!(
prepare_companion(&single, Some(&[0; 32]))
.unwrap()
.is_none()
);
for (offset, value) in [
(0, 0),
(0x84, 0),
(0x98, 0),
(0x1004, 0),
(0x2101, 0),
(0x2109, 0),
(0x2130, 0),
(0x2135, 0),
(0x2139, 0),
] {
let mut damaged = input.clone();
damaged[offset] = value;
assert!(expects_companion(&damaged).is_none(), "offset {offset:x}");
}
for length in [0, 0x1000, 0x101f, 0x2100, input.len() - 1] {
assert!(expects_companion(&input[..length]).is_none());
}
let mut unbacked = input.clone();
put32(&mut unbacked, 0x198, 0x100);
assert!(expects_companion(&unbacked).is_none());
}
#[test]
fn filesystem_reports_expected_path_and_preserves_read_errors() {
let dir = tempfile::tempdir().unwrap();
let input = dir.path().join("module.dll");
let payload = dir.path().join("module.dll._");
let bytes = stub();
std::fs::write(&input, &bytes).unwrap();
let error = super::super::read_unpacker_input(&input).err().unwrap();
assert!(matches!(
error.downcast_ref(),
Some(UnpackError::CompanionMissing)
));
assert!(format!("{error:#}").contains("module.dll._"));
std::fs::write(&payload, [0; 32]).unwrap();
let error = super::super::read_unpacker_input(&input).err().unwrap();
assert!(matches!(
error.downcast_ref(),
Some(UnpackError::CompanionMismatch)
));
std::fs::write(&payload, &bytes[HEADER_OFF..]).unwrap();
let paired = super::super::read_unpacker_input(&input).unwrap();
assert_eq!(paired.bytes, bytes);
assert_eq!(paired.stub.as_deref(), Some(bytes.as_slice()));
std::fs::remove_file(&payload).unwrap();
std::fs::create_dir(&payload).unwrap();
let error = super::super::read_unpacker_input(&input).err().unwrap();
assert!(error.downcast_ref::<std::io::Error>().is_some());
assert!(error.downcast_ref::<UnpackError>().is_none());
}
#[test]
fn file_and_folder_jobs_count_missing_companion_as_error_and_log_hint() {
for folder in [false, true] {
let dir = tempfile::tempdir().unwrap();
let input = dir.path().join("module.exe");
let out = dir.path().join("unpack");
std::fs::write(&input, stub()).unwrap();
let summary = if folder {
crate::job::run_folder_v(dir.path(), Some(&out), 2, false, false)
} else {
crate::job::run_file_v(&input, Some(&out), 2, false, false)
}
.unwrap();
assert_eq!(summary.errors, 1);
assert_eq!(summary.unpacked, 0);
assert!(!out.join("module.unpack.exe").exists());
let log = std::fs::read_dir(&out)
.unwrap()
.next()
.unwrap()
.unwrap()
.path();
let log = std::fs::read_to_string(log).unwrap();
assert!(log.contains("Companion missing?"), "{log}");
assert!(log.contains("module.exe._"), "{log}");
}
}
}
+18 -11
View File
@@ -4,6 +4,9 @@ use senbei_engine as unpacker;
use std::path::Path;
use crate::atomic::write_atomic;
use anyhow::Context;
mod companion;
pub(crate) fn is_pe_extension(path: &Path) -> bool {
path.extension()
@@ -57,9 +60,10 @@ const HEADER_OFF: usize = 4096;
///
/// The splice fires only when a sibling `<input>._` exists *and* its first 32
/// bytes equal the stub's header at offset 4096 — a precise signal that the
/// companion is this stub's payload. Otherwise the file is returned untouched,
/// so normal (embedded-payload) inputs are unaffected.
pub(crate) fn read_unpacker_input(input: &Path) -> std::io::Result<UnpackerInput> {
/// companion is this stub's payload. A recognized external-file declaration
/// without a matching payload reports a companion error. Other inputs are
/// returned untouched, so normal (embedded-payload) inputs are unaffected.
pub(crate) fn read_unpacker_input(input: &Path) -> anyhow::Result<UnpackerInput> {
let stub = std::fs::read(input)?;
// Companion path: append "._" to the full file name (Foo.dll -> Foo.dll._).
@@ -76,14 +80,17 @@ pub(crate) fn read_unpacker_input(input: &Path) -> std::io::Result<UnpackerInput
});
}
};
if !companion.is_file() {
return Ok(UnpackerInput {
bytes: stub,
stub: None,
});
let comp = match std::fs::read(&companion) {
Ok(bytes) => Some(bytes),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => {
return Err(error)
.with_context(|| format!("couldn't read companion {}", companion.display()));
}
let comp = std::fs::read(&companion)?;
match splice_companion(&stub, &comp) {
};
match companion::prepare_companion(&stub, comp.as_deref())
.with_context(|| format!("companion {}", companion.display()))?
{
// A splice fired: keep the stub so its plaintext export table can be
// overlaid onto the unpacked image (the companion does not carry it).
Some(spliced) => Ok(UnpackerInput {
@@ -499,7 +506,7 @@ fn unpack_bytes_impl(
companion: Option<&[u8]>,
force_exe: bool,
) -> Result<UnpackedImage, unpacker::UnpackError> {
let spliced = companion.and_then(|c| splice_companion(input, c));
let spliced = companion::prepare_companion(input, companion)?;
let bytes: &[u8] = spliced.as_deref().unwrap_or(input);
let (kind, mut out) = unpack_spliced_or_auto(bytes, spliced.is_some(), force_exe, false)?;
if spliced.is_some() {
+93 -70
View File
@@ -4,9 +4,9 @@ version = 4
[[package]]
name = "aes"
version = "0.9.3"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32"
checksum = "fc0fc36d7440d015b45468ba08a21e80e056ebbd014c611c6298bb7503e04bd1"
dependencies = [
"cipher",
"cpubits",
@@ -21,9 +21,9 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.1"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "block-buffer"
@@ -42,9 +42,15 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "cfg-if"
version = "1.0.4"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "cipher"
@@ -58,9 +64,9 @@ dependencies = [
[[package]]
name = "console"
version = "0.16.4"
version = "0.16.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c"
checksum = "e96a4956774c13c126a8b5af4daa79384f4d826534c95a02d76afb39e2ab64e3"
dependencies = [
"encode_unicode",
"libc",
@@ -101,9 +107,9 @@ dependencies = [
[[package]]
name = "crc32fast"
version = "1.5.1"
version = "1.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04"
dependencies = [
"cfg-if",
]
@@ -136,9 +142,9 @@ checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "equivalent"
version = "1.0.2"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "errno"
@@ -196,8 +202,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"wasm-bindgen",
]
[[package]]
@@ -219,18 +227,18 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "hybrid-array"
version = "0.4.14"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
dependencies = [
"typenum",
]
[[package]]
name = "indexmap"
version = "2.14.1"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
@@ -251,9 +259,9 @@ dependencies = [
[[package]]
name = "inout"
version = "0.2.2"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f"
dependencies = [
"hybrid-array",
]
@@ -266,9 +274,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.104"
version = "0.3.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a"
dependencies = [
"cfg-if",
"futures-util",
@@ -277,9 +285,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.189"
version = "0.2.191"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
[[package]]
name = "linux-raw-sys"
@@ -289,9 +297,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "log"
version = "0.4.34"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "memchr"
@@ -301,13 +309,25 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "memmap2"
version = "0.9.11"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
checksum = "6b0ca1155a9ecc25aa12e8313a119f32edf0255fea2f44839c4ac0a12b2ac157"
dependencies = [
"libc",
]
[[package]]
name = "nix"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -364,9 +384,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rustix"
version = "1.1.4"
version = "1.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
dependencies = [
"bitflags",
"errno",
@@ -407,12 +427,12 @@ checksum = "e1a36a382ed65dbcc0ab47fd5e9a94112417ccd34560a392ef3b7b0f0ec39148"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
"syn 3.0.9",
]
[[package]]
name = "senbei-crypto"
version = "1.3.3"
version = "1.3.4"
dependencies = [
"aes",
"thiserror",
@@ -420,7 +440,7 @@ dependencies = [
[[package]]
name = "senbei-elf"
version = "1.3.3"
version = "1.3.4"
dependencies = [
"goblin",
"thiserror",
@@ -428,7 +448,7 @@ dependencies = [
[[package]]
name = "senbei-engine"
version = "1.3.3"
version = "1.3.4"
dependencies = [
"memmap2",
"senbei-crypto",
@@ -443,7 +463,7 @@ dependencies = [
[[package]]
name = "senbei-io"
version = "1.3.3"
version = "1.3.4"
dependencies = [
"anyhow",
"indicatif",
@@ -464,7 +484,7 @@ dependencies = [
[[package]]
name = "senbei-metadata"
version = "1.3.3"
version = "1.3.4"
dependencies = [
"serde",
"thiserror",
@@ -472,14 +492,14 @@ dependencies = [
[[package]]
name = "senbei-pe"
version = "1.3.3"
version = "1.3.4"
dependencies = [
"thiserror",
]
[[package]]
name = "senbei-wasm"
version = "1.3.3"
version = "1.3.4"
dependencies = [
"console_error_panic_hook",
"senbei-engine",
@@ -515,14 +535,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
"syn 3.0.9",
]
[[package]]
name = "serde_json"
version = "1.0.151"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
@@ -533,9 +553,9 @@ dependencies = [
[[package]]
name = "sha2"
version = "0.11.0"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
dependencies = [
"cfg-if",
"cpufeatures",
@@ -567,9 +587,9 @@ dependencies = [
[[package]]
name = "syn"
version = "3.0.4"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
@@ -591,29 +611,29 @@ dependencies = [
[[package]]
name = "thiserror"
version = "2.0.20"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.20"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
"syn 3.0.9",
]
[[package]]
name = "typed-path"
version = "0.12.3"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
[[package]]
name = "typenum"
@@ -623,15 +643,15 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.24"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "unicode-width"
version = "0.2.2"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557"
[[package]]
name = "unit-prefix"
@@ -651,12 +671,11 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.127"
version = "0.2.130"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
@@ -664,9 +683,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.127"
version = "0.2.130"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -674,22 +693,22 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.127"
version = "0.2.130"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 3.0.9",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.127"
version = "0.2.130"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e"
dependencies = [
"unicode-ident",
]
@@ -834,23 +853,27 @@ dependencies = [
[[package]]
name = "zip"
version = "8.6.0"
version = "9.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
checksum = "eaeb7f16d2605b49aef586996ded29e65642fab7259a3077e537351df6d80426"
dependencies = [
"crc32fast",
"flate2",
"getrandom",
"indexmap",
"libc",
"memchr",
"nix",
"typed-path",
"windows-sys",
"zopfli",
]
[[package]]
name = "zlib-rs"
version = "0.6.7"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
[[package]]
name = "zmij"
@@ -860,9 +883,9 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zopfli"
version = "0.8.3"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
dependencies = [
"bumpalo",
"crc32fast",
+2 -2
View File
@@ -1,8 +1,8 @@
[package]
name = "senbei-wasm"
version = "1.3.3"
version = "1.3.4"
edition = "2024"
rust-version = "1.98.1"
rust-version = "1.99.1"
description = "WebAssembly bindings for senbei (browser frontend assets live in web/)"
license = "AGPL-3.0-only"
+1 -4
View File
@@ -130,10 +130,7 @@ pub fn detect(input: &[u8]) -> Option<String> {
/// is none). Throws a string error when the input is not a supported
/// Crackproof file or is corrupt.
#[wasm_bindgen]
pub fn unpack_file(
input: &[u8],
companion: Option<Vec<u8>>,
) -> Result<UnpackResult, JsError> {
pub fn unpack_file(input: &[u8], companion: Option<Vec<u8>>) -> Result<UnpackResult, JsError> {
let r = senbei_io::job::unpack_bytes(input, companion.as_deref())
.map_err(|e| JsError::new(&e.to_string()))?;
Ok(UnpackResult {