fix(android): bound archive extraction and decoder resources

This commit is contained in:
Momoko-Ayase committed 2026-11-04 13:33:38 +08:00
1 parent 22dd92982e
commit a380031567
21 files changed
+2714 -181

No files matched your search

+113 -4
View File
@@ -1,3 +1,4 @@
use senbei_engine::android::ResourceLimits;
use senbei_io::{job, pause, scan};
use std::path::Path;
@@ -11,6 +12,7 @@ fn main() -> std::process::ExitCode {
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() {
@@ -28,6 +30,13 @@ fn main() -> std::process::ExitCode {
"--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),
_ => {
@@ -62,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) => {
@@ -92,7 +102,7 @@ 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.
@@ -108,9 +118,40 @@ fn exit_code(summary: &job::Summary, strict_integrity: bool) -> u8 {
}
}
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] [--strict-integrity] [--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\
@@ -129,6 +170,19 @@ fn print_help() {
\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)]
@@ -172,3 +226,58 @@ mod strict_integrity {
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());
}
}
+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:?}"
);
}