mirror of
https://github.com/Momoko-Ayase/Senbei.git
synced 2026-11-04 10:31:29 -05:00
fix(android): bound archive extraction and decoder resources
This commit is contained in:
1 parent
22dd92982e
commit
a380031567
21 files changed
+2714
-181
No files matched your search
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,10 +72,12 @@ pub(crate) enum ContextAmbiguity {
|
||||
/// 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 the restored image, while a metadata hit is
|
||||
/// keyed by the recovery context as well as the encrypted bytes.
|
||||
/// 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>,
|
||||
@@ -111,6 +113,14 @@ pub(crate) enum CachedMetadata {
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -305,7 +315,7 @@ fn component_is(component: Component<'_>, expected: &str) -> bool {
|
||||
/// 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. A later resource budget must charge this archive's
|
||||
/// 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)]
|
||||
@@ -333,10 +343,10 @@ pub(crate) struct PackageInventory {
|
||||
|
||||
/// Manifest-first application identity for one ZIP package.
|
||||
///
|
||||
/// Reads only the root `AndroidManifest.xml` entry, and only the leading window
|
||||
/// needed for the plain-XML `package` and version attributes. A missing or
|
||||
/// binary manifest does not fall back to the ZIP file name; the caller isolates
|
||||
/// that package. `package_set` is the bundle or directory that owns this set of
|
||||
/// 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
|
||||
@@ -347,6 +357,7 @@ pub(crate) fn inventory_manifest_package<R: std::io::Read + std::io::Seek>(
|
||||
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;
|
||||
@@ -379,20 +390,13 @@ pub(crate) fn inventory_manifest_package<R: std::io::Read + std::io::Seek>(
|
||||
};
|
||||
let mut entry = archive.by_index(index)?;
|
||||
// The package and version attributes are in the root `<manifest` start tag.
|
||||
// Bound the read. A later shared ZIP budget should charge this probe; it
|
||||
// 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 mut buffer = vec![0_u8; MANIFEST_PROBE];
|
||||
let mut filled = 0_usize;
|
||||
while filled < buffer.len() {
|
||||
match std::io::Read::read(&mut entry, &mut buffer[filled..])? {
|
||||
0 => break,
|
||||
read => filled += read,
|
||||
}
|
||||
}
|
||||
buffer.truncate(filled);
|
||||
let truncated = entry.size() > MANIFEST_PROBE as u64;
|
||||
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 {
|
||||
@@ -503,10 +507,11 @@ pub(crate) trait LibraryRestorer {
|
||||
}
|
||||
|
||||
/// Production restorer: stage-2 extract, ELF rebuild, embedded metadata unwrap.
|
||||
pub(crate) struct EngineRestorer;
|
||||
#[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(input, dest, verbose)
|
||||
crate::android::restore_so_with_engine_limits(input, dest, verbose, &self.0)
|
||||
}
|
||||
}
|
||||
+168
-64
@@ -15,9 +15,12 @@
|
||||
//! All functions in this module are native filesystem orchestration; the web
|
||||
//! app (wasm) never touches them.
|
||||
|
||||
#[cfg(test)]
|
||||
mod resource_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
mod archive_limits;
|
||||
mod context;
|
||||
mod manifest;
|
||||
|
||||
@@ -31,8 +34,8 @@ use std::path::{Path, PathBuf};
|
||||
use anyhow::{Context, Result, bail, ensure};
|
||||
use memmap2::{Mmap, MmapOptions};
|
||||
use senbei_crypto::hex_digest;
|
||||
use senbei_engine::android::{ExtractOptions, extract_stage2, is_protected_libil2cpp};
|
||||
use senbei_engine::android::{RestoreOptions, restore_libil2cpp};
|
||||
use senbei_engine::android::{ExtractOptions, extract_stage2_with_limits, is_protected_libil2cpp};
|
||||
use senbei_engine::android::{ResourceLimits, RestoreOptions, restore_libil2cpp_with_limits};
|
||||
use sha2::{Digest, Sha256};
|
||||
use zip::ZipArchive;
|
||||
|
||||
@@ -94,6 +97,18 @@ pub fn is_protected_so_file(path: &Path) -> bool {
|
||||
is_elf64_aarch64(&bytes) && is_protected_libil2cpp(&bytes)
|
||||
}
|
||||
|
||||
pub(crate) fn probe_so_file_with_limits(
|
||||
path: &Path,
|
||||
limits: &senbei_engine::android::ResourceLimits,
|
||||
) -> Result<bool> {
|
||||
let file = File::open(path).with_context(|| format!("open `{}`", path.display()))?;
|
||||
let bytes = map_read_only(&file, path)?;
|
||||
if !is_elf64_aarch64(&bytes) {
|
||||
return Ok(false);
|
||||
}
|
||||
senbei_engine::android::probe_libil2cpp_with_limits(&bytes, limits).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn file_content_identity(path: &Path) -> std::io::Result<String> {
|
||||
let file = File::open(path)?;
|
||||
// SAFETY: the file remains open for the mapping lifetime and the mapping
|
||||
@@ -113,17 +128,18 @@ pub fn file_content_identity(path: &Path) -> std::io::Result<String> {
|
||||
/// that restore more than one application must keep those modules apart; this
|
||||
/// single-file entry point has only one application, so it keeps the module
|
||||
/// only long enough to unwrap embedded metadata (which does not consume it).
|
||||
pub(crate) fn restore_so_with_engine(
|
||||
pub(crate) fn restore_so_with_engine_limits(
|
||||
input: &Path,
|
||||
dest: &Path,
|
||||
verbose: bool,
|
||||
limits: &ResourceLimits,
|
||||
) -> Result<context::RestoredLibrary> {
|
||||
let temporary = tempfile::tempdir().context("create stage-2 workspace")?;
|
||||
let stage2_dir = temporary.path().join("stage2");
|
||||
let extraction = extract_stage2(&ExtractOptions::with_defaults(
|
||||
input.to_path_buf(),
|
||||
stage2_dir.clone(),
|
||||
))
|
||||
let extraction = extract_stage2_with_limits(
|
||||
&ExtractOptions::with_defaults(input.to_path_buf(), stage2_dir.clone()),
|
||||
limits,
|
||||
)
|
||||
.context("extract stage-1/stage-2 payload")?;
|
||||
// Every module 0x0C is collected. Two distinct images in one library are a
|
||||
// conflict for that library's application, not a last-writer-wins update.
|
||||
@@ -152,15 +168,18 @@ pub(crate) fn restore_so_with_engine(
|
||||
.pop()
|
||||
.map(|(image, bytes)| (Some(image), Some(bytes)))
|
||||
.unwrap_or((None, None));
|
||||
restore_libil2cpp(&RestoreOptions {
|
||||
input: input.to_path_buf(),
|
||||
output: dest.to_path_buf(),
|
||||
index: stage2_dir.join("index.json"),
|
||||
dump_auxiliary: None,
|
||||
outer_only: false,
|
||||
preserve_entrypoint: false,
|
||||
verbose,
|
||||
})
|
||||
restore_libil2cpp_with_limits(
|
||||
&RestoreOptions {
|
||||
input: input.to_path_buf(),
|
||||
output: dest.to_path_buf(),
|
||||
index: stage2_dir.join("index.json"),
|
||||
dump_auxiliary: None,
|
||||
outer_only: false,
|
||||
preserve_entrypoint: false,
|
||||
verbose,
|
||||
},
|
||||
limits,
|
||||
)
|
||||
.context("restore protected library")?;
|
||||
let restored_file =
|
||||
File::open(dest).with_context(|| format!("read restored `{}`", dest.display()))?;
|
||||
@@ -173,17 +192,32 @@ pub(crate) fn restore_so_with_engine(
|
||||
}
|
||||
|
||||
pub fn restore_so_file(input: &Path, dest: &Path, verbose: bool) -> Result<Option<Vec<u8>>> {
|
||||
Ok(context::EngineRestorer
|
||||
restore_so_file_with_limits(input, dest, verbose, &ResourceLimits::default())
|
||||
}
|
||||
|
||||
/// Restore a loose Android library with explicit resource ceilings.
|
||||
pub fn restore_so_file_with_limits(
|
||||
input: &Path,
|
||||
dest: &Path,
|
||||
verbose: bool,
|
||||
limits: &ResourceLimits,
|
||||
) -> Result<Option<Vec<u8>>> {
|
||||
Ok(context::EngineRestorer(*limits)
|
||||
.restore(input, dest, verbose)?
|
||||
.embedded_metadata)
|
||||
}
|
||||
|
||||
/// Fresh run-scoped cache. Library hits reuse a restored image; metadata hits
|
||||
/// Fresh run-scoped cache. Library hits reuse their module context; metadata hits
|
||||
/// are keyed by encrypted bytes and the module that recovered them.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn run_cache() -> context::AndroidRunCache {
|
||||
context::AndroidRunCache::default()
|
||||
}
|
||||
|
||||
pub(crate) fn run_cache_with_limits(limits: &ResourceLimits) -> context::AndroidRunCache {
|
||||
context::AndroidRunCache::with_limits(limits)
|
||||
}
|
||||
|
||||
/// Assign loose libraries and metadata to application contexts.
|
||||
pub(crate) fn loose_scopes(
|
||||
libraries: &[PathBuf],
|
||||
@@ -210,6 +244,7 @@ pub(crate) fn restore_loose_library(
|
||||
verbose: bool,
|
||||
cache: &mut context::AndroidRunCache,
|
||||
) -> Result<LooseLibraryOutcome> {
|
||||
let limits = cache.limits;
|
||||
restore_loose_library_with(
|
||||
input,
|
||||
dest,
|
||||
@@ -217,7 +252,7 @@ pub(crate) fn restore_loose_library(
|
||||
app,
|
||||
verbose,
|
||||
cache,
|
||||
&mut context::EngineRestorer,
|
||||
&mut context::EngineRestorer(limits),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -243,8 +278,13 @@ fn restore_loose_library_with<R: context::LibraryRestorer>(
|
||||
// A prior content-only success still needs context extraction. Restore it
|
||||
// in a private workspace, never over a destination that must stay untouched.
|
||||
let prior = cache.is_prior_success(&identity);
|
||||
let workspace = prior.then(tempfile::tempdir).transpose().context("create duplicate context workspace")?;
|
||||
let scratch = workspace.as_ref().map(|directory| directory.path().join("restored.so"));
|
||||
let workspace = prior
|
||||
.then(tempfile::tempdir)
|
||||
.transpose()
|
||||
.context("create duplicate context workspace")?;
|
||||
let scratch = workspace
|
||||
.as_ref()
|
||||
.map(|directory| directory.path().join("restored.so"));
|
||||
let restore_dest = scratch.as_deref().unwrap_or(dest);
|
||||
match restorer.restore(input, restore_dest, verbose) {
|
||||
Ok(restored) => {
|
||||
@@ -268,7 +308,11 @@ fn restore_loose_library_with<R: context::LibraryRestorer>(
|
||||
record_module(cache, app, &cached, label);
|
||||
cache.insert_library(identity, cached);
|
||||
Ok(LooseLibraryOutcome {
|
||||
status: if prior { EntryStatus::Duplicate } else { EntryStatus::Restored },
|
||||
status: if prior {
|
||||
EntryStatus::Duplicate
|
||||
} else {
|
||||
EntryStatus::Restored
|
||||
},
|
||||
embedded,
|
||||
})
|
||||
}
|
||||
@@ -323,7 +367,9 @@ pub(crate) fn restore_loose_metadata(
|
||||
restore_metadata_bytes_with_module(&data, module).with_context(|| label.to_owned())?;
|
||||
if report.remapped > 0 {
|
||||
write_metadata_blob(dest, &out)?;
|
||||
if verbose { println!("Write to {}", dest.display()); }
|
||||
if verbose {
|
||||
println!("Write to {}", dest.display());
|
||||
}
|
||||
}
|
||||
Ok(LooseMetadataOutcome {
|
||||
version: report.version,
|
||||
@@ -336,7 +382,15 @@ pub(crate) fn restore_loose_metadata(
|
||||
/// the same manifest package name plus version. Bundles are never grouped
|
||||
/// with anything. Order inside a group does not matter; the caller restores
|
||||
/// every library in the group before any of its metadata.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn loose_apk_groups(packages: &[PathBuf]) -> Vec<Vec<PathBuf>> {
|
||||
loose_apk_groups_cached(packages, &mut context::AndroidRunCache::default())
|
||||
}
|
||||
|
||||
pub(crate) fn loose_apk_groups_cached(
|
||||
packages: &[PathBuf],
|
||||
cache: &mut context::AndroidRunCache,
|
||||
) -> Vec<Vec<PathBuf>> {
|
||||
let mut groups: Vec<(context::AppKey, Vec<PathBuf>)> = Vec::new();
|
||||
let mut alone = Vec::new();
|
||||
for package in packages {
|
||||
@@ -346,7 +400,7 @@ pub(crate) fn loose_apk_groups(packages: &[PathBuf]) -> Vec<Vec<PathBuf>> {
|
||||
alone.push(vec![package.clone()]);
|
||||
continue;
|
||||
}
|
||||
let key = apk_manifest_key(package);
|
||||
let key = apk_manifest_key(package, cache);
|
||||
if let Some(key) = key {
|
||||
if let Some((_, members)) = groups.iter_mut().find(|(existing, _)| existing == &key) {
|
||||
members.push(package.clone());
|
||||
@@ -362,9 +416,11 @@ pub(crate) fn loose_apk_groups(packages: &[PathBuf]) -> Vec<Vec<PathBuf>> {
|
||||
grouped
|
||||
}
|
||||
|
||||
fn apk_manifest_key(package: &Path) -> Option<context::AppKey> {
|
||||
let file = std::fs::File::open(package).ok()?;
|
||||
let mut archive = zip::ZipArchive::new(file).ok()?;
|
||||
fn apk_manifest_key(
|
||||
package: &Path,
|
||||
cache: &mut context::AndroidRunCache,
|
||||
) -> Option<context::AppKey> {
|
||||
let mut archive = cache.archive.open_archive(package).ok()?;
|
||||
let parent = package
|
||||
.parent()
|
||||
.filter(|parent| !parent.as_os_str().is_empty())
|
||||
@@ -374,6 +430,7 @@ fn apk_manifest_key(package: &Path) -> Option<context::AppKey> {
|
||||
package,
|
||||
parent,
|
||||
&package.display().to_string(),
|
||||
&mut cache.archive,
|
||||
)
|
||||
.ok()?;
|
||||
match inventory.key {
|
||||
@@ -392,13 +449,14 @@ pub(crate) fn restore_package_cached(
|
||||
cache: &mut context::AndroidRunCache,
|
||||
verbose: bool,
|
||||
) -> Result<Vec<EntryOutcome>> {
|
||||
let limits = cache.limits;
|
||||
restore_package_with_restorer(
|
||||
package,
|
||||
rel,
|
||||
out_root,
|
||||
cache,
|
||||
verbose,
|
||||
&mut context::EngineRestorer,
|
||||
&mut context::EngineRestorer(limits),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -412,6 +470,7 @@ pub(crate) fn restore_apk_group(
|
||||
cache: &mut context::AndroidRunCache,
|
||||
verbose: bool,
|
||||
) -> Result<Vec<EntryOutcome>> {
|
||||
let limits = cache.limits;
|
||||
let mut outcomes = Vec::new();
|
||||
for package in packages {
|
||||
let rel = package.strip_prefix(root).unwrap_or(package);
|
||||
@@ -421,7 +480,7 @@ pub(crate) fn restore_apk_group(
|
||||
out_root,
|
||||
cache,
|
||||
verbose,
|
||||
&mut context::EngineRestorer,
|
||||
&mut context::EngineRestorer(limits),
|
||||
PackagePass::Libraries,
|
||||
)?);
|
||||
}
|
||||
@@ -433,7 +492,7 @@ pub(crate) fn restore_apk_group(
|
||||
out_root,
|
||||
cache,
|
||||
verbose,
|
||||
&mut context::EngineRestorer,
|
||||
&mut context::EngineRestorer(limits),
|
||||
PackagePass::Metadata,
|
||||
)?);
|
||||
}
|
||||
@@ -591,6 +650,26 @@ pub fn restore_package(
|
||||
seen: &mut HashSet<String>,
|
||||
verbose: bool,
|
||||
) -> Result<Vec<EntryOutcome>> {
|
||||
restore_package_with_limits(
|
||||
package,
|
||||
rel,
|
||||
out_root,
|
||||
seen,
|
||||
verbose,
|
||||
&ResourceLimits::default(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Restore one Android package with explicit archive and decoder ceilings.
|
||||
pub fn restore_package_with_limits(
|
||||
package: &Path,
|
||||
rel: &Path,
|
||||
out_root: &Path,
|
||||
seen: &mut HashSet<String>,
|
||||
verbose: bool,
|
||||
limits: &ResourceLimits,
|
||||
) -> Result<Vec<EntryOutcome>> {
|
||||
let limits = *limits;
|
||||
// `seen` is the public successful-content set shared with loose libraries.
|
||||
// Metadata recovery context does not fit in that set, so this wrapper keeps
|
||||
// a run-scoped cache and only publishes content identities of entries that
|
||||
@@ -598,7 +677,7 @@ pub fn restore_package(
|
||||
// package still skips those bytes; module context for metadata is rebuilt
|
||||
// inside this package (one package, or one manifest-scoped bundle, is one
|
||||
// application boundary).
|
||||
let mut cache = context::AndroidRunCache::default();
|
||||
let mut cache = context::AndroidRunCache::with_limits(&limits);
|
||||
for identity in seen.iter().cloned() {
|
||||
cache.insert_prior_success(identity);
|
||||
}
|
||||
@@ -608,7 +687,7 @@ pub fn restore_package(
|
||||
out_root,
|
||||
&mut cache,
|
||||
verbose,
|
||||
&mut context::EngineRestorer,
|
||||
&mut context::EngineRestorer(limits),
|
||||
);
|
||||
// Publish successes even when the package returns `Err`. The caller's set
|
||||
// is only replaced after that, so a panic still leaves the old successes.
|
||||
@@ -652,7 +731,7 @@ fn restore_package_pass<R: context::LibraryRestorer>(
|
||||
.is_some_and(|value| {
|
||||
value.eq_ignore_ascii_case("apks") || value.eq_ignore_ascii_case("xapk")
|
||||
});
|
||||
let mut archive = open_package(package)?;
|
||||
let mut archive = cache.archive.open_archive(package)?;
|
||||
let temporary = tempfile::tempdir().context("create package workspace")?;
|
||||
let mut outcomes = Vec::new();
|
||||
let mut state = PackageRestoreState {
|
||||
@@ -687,7 +766,7 @@ fn restore_package_pass<R: context::LibraryRestorer>(
|
||||
}
|
||||
}
|
||||
// One direct package is one application inside its own package set (the
|
||||
// package file). A plain manifest names it by package plus version; without
|
||||
// package file). Its manifest names it by package plus version; without
|
||||
// both, the package is isolated. The ZIP file name is not a package id.
|
||||
// Inventory already walked this central directory; entry extraction below
|
||||
// reuses the same open and must not be charged as a second package.
|
||||
@@ -702,6 +781,7 @@ fn restore_package_pass<R: context::LibraryRestorer>(
|
||||
.filter(|parent| !parent.as_os_str().is_empty())
|
||||
.unwrap_or_else(|| Path::new(".")),
|
||||
&package.display().to_string(),
|
||||
&mut state.cache.archive,
|
||||
)?)
|
||||
};
|
||||
direct.retain(|(_, name)| match pass {
|
||||
@@ -733,21 +813,27 @@ fn restore_package_pass<R: context::LibraryRestorer>(
|
||||
base: PathBuf,
|
||||
app: context::AppKey,
|
||||
/// Manifest inventory, including the reopen key. Extraction opens
|
||||
/// `path` again; `inventory.archive` is the pass a later budget must
|
||||
/// not charge a second time.
|
||||
/// `path` again; `inventory.archive` is not charged a second time.
|
||||
inventory: context::PackageInventory,
|
||||
entries: Vec<(usize, PathBuf)>,
|
||||
}
|
||||
|
||||
let mut nested_packages = Vec::with_capacity(nested.len());
|
||||
for (index, name) in nested {
|
||||
state.cache.limits.check_depth(1)?;
|
||||
let nested_label = rel.join(&name);
|
||||
let nested_path = extract_entry(&mut archive, index, &temporary, &nested_label)
|
||||
.with_context(|| format!("extract `{}`", nested_label.display()))?;
|
||||
let mut nested_archive = open_package(&nested_path)?;
|
||||
let nested_path = extract_entry(
|
||||
&mut archive,
|
||||
index,
|
||||
&temporary,
|
||||
&nested_label,
|
||||
&mut state.cache.archive,
|
||||
)
|
||||
.with_context(|| format!("extract `{}`", nested_label.display()))?;
|
||||
let mut nested_archive = state.cache.archive.open_archive(&nested_path)?;
|
||||
// Manifest first, before any library or metadata entry is chosen. Splits
|
||||
// that name the same package and version, inside this bundle only, share
|
||||
// one context. A split with no plain package name or no version is
|
||||
// one context. A split with no recognized package name or version is
|
||||
// isolated and cannot borrow a sibling's module. `nested_path` is the
|
||||
// reopen key: the central directory listed here is not charged again
|
||||
// when entries are extracted below.
|
||||
@@ -756,6 +842,7 @@ fn restore_package_pass<R: context::LibraryRestorer>(
|
||||
&nested_path,
|
||||
package,
|
||||
&nested_path.display().to_string(),
|
||||
&mut state.cache.archive,
|
||||
)?;
|
||||
let mut entries = Vec::new();
|
||||
for nested_index in 0..nested_archive.len() {
|
||||
@@ -805,7 +892,7 @@ fn restore_package_pass<R: context::LibraryRestorer>(
|
||||
nested_package.inventory.archive.reopen_key,
|
||||
nested_package.path
|
||||
);
|
||||
let mut nested_archive = open_package(&nested_package.path)?;
|
||||
let mut nested_archive = state.cache.archive.open_archive(&nested_package.path)?;
|
||||
for (nested_index, entry_name) in nested_package
|
||||
.entries
|
||||
.iter()
|
||||
@@ -853,14 +940,24 @@ fn restore_package_entry<R: Read + Seek, T: context::LibraryRestorer>(
|
||||
app: &context::AppKey,
|
||||
state: &mut PackageRestoreState<'_, T>,
|
||||
) -> Result<Vec<EntryOutcome>> {
|
||||
let entry_path = extract_entry(archive, index, temporary, Path::new(label))?;
|
||||
let entry_path = extract_entry(
|
||||
archive,
|
||||
index,
|
||||
temporary,
|
||||
Path::new(label),
|
||||
&mut state.cache.archive,
|
||||
)?;
|
||||
let entry_file =
|
||||
File::open(&entry_path).with_context(|| format!("open extracted `{label}`"))?;
|
||||
let entry_data = map_read_only(&entry_file, &entry_path)?;
|
||||
|
||||
let protected_override = state.restorer.protected_override(&entry_path);
|
||||
let is_so = protected_override.is_some()
|
||||
|| (is_elf64_aarch64(&entry_data) && is_protected_libil2cpp(&entry_data));
|
||||
|| (is_elf64_aarch64(&entry_data)
|
||||
&& senbei_engine::android::probe_libil2cpp_with_limits(
|
||||
&entry_data,
|
||||
&state.cache.limits,
|
||||
)?);
|
||||
let is_meta = !is_so && senbei_metadata::is_metadata(&entry_data);
|
||||
let outcome = |kind, status, provenance: Vec<String>| EntryOutcome {
|
||||
label: label.to_owned(),
|
||||
@@ -894,12 +991,19 @@ fn restore_package_entry<R: Read + Seek, T: context::LibraryRestorer>(
|
||||
drop(entry_data);
|
||||
drop(entry_file);
|
||||
let prior = state.cache.is_prior_success(&identity);
|
||||
let workspace = prior.then(|| tempfile::tempdir_in(temporary.path())).transpose()
|
||||
let workspace = prior
|
||||
.then(|| tempfile::tempdir_in(temporary.path()))
|
||||
.transpose()
|
||||
.context("create duplicate context workspace")?;
|
||||
let scratch = workspace.as_ref().map(|directory| directory.path().join("restored.so"));
|
||||
let scratch = workspace
|
||||
.as_ref()
|
||||
.map(|directory| directory.path().join("restored.so"));
|
||||
let restore_dest = scratch.as_deref().unwrap_or(dest);
|
||||
return Ok(
|
||||
match state.restorer.restore(&entry_path, restore_dest, state.verbose) {
|
||||
match state
|
||||
.restorer
|
||||
.restore(&entry_path, restore_dest, state.verbose)
|
||||
{
|
||||
Ok(restored) => {
|
||||
// A prior content-only success is restored only in the
|
||||
// private workspace to recover its module context.
|
||||
@@ -1104,11 +1208,6 @@ fn write_metadata_blob(dest: &Path, data: &[u8]) -> Result<()> {
|
||||
.context("write metadata output")
|
||||
}
|
||||
|
||||
fn open_package(path: &Path) -> Result<ZipArchive<std::fs::File>> {
|
||||
let file = std::fs::File::open(path).with_context(|| format!("open `{}`", path.display()))?;
|
||||
ZipArchive::new(file).with_context(|| format!("read package `{}`", path.display()))
|
||||
}
|
||||
|
||||
/// Stream one package entry to a temporary, seekable file. The Android engine
|
||||
/// needs random access to ELF section tables, while the ZIP reader itself is
|
||||
/// consumed directly without creating an in-memory compressed or decompressed
|
||||
@@ -1118,23 +1217,28 @@ fn extract_entry<R: Read + Seek>(
|
||||
index: usize,
|
||||
temporary: &tempfile::TempDir,
|
||||
label: &Path,
|
||||
budget: &mut archive_limits::ArchiveBudget,
|
||||
) -> Result<PathBuf> {
|
||||
let mut entry = archive.by_index(index)?;
|
||||
let key = format!("{}-{index:08x}", label.display());
|
||||
// `:` appears in `package::entry` labels and is invalid in Windows file
|
||||
// names; sanitize every path-ish separator.
|
||||
let destination = temporary.path().join(key.replace(['\\', '/', ':'], "_"));
|
||||
let output_size = entry.size();
|
||||
let mut output = BufWriter::new(std::fs::File::create(&destination)?);
|
||||
let written = std::io::copy(&mut entry, &mut output)?;
|
||||
output.flush()?;
|
||||
if written != output_size {
|
||||
bail!(
|
||||
"entry `{key}` decompressed to 0x{:x}, expected 0x{output_size:x}",
|
||||
written
|
||||
);
|
||||
let mut file = tempfile::Builder::new()
|
||||
.prefix("entry-")
|
||||
.tempfile_in(temporary.path())?;
|
||||
{
|
||||
let mut output = BufWriter::new(file.as_file_mut());
|
||||
budget.copy_entry(
|
||||
&mut entry,
|
||||
&mut output,
|
||||
output_size,
|
||||
&label.display().to_string(),
|
||||
)?;
|
||||
output.flush()?;
|
||||
}
|
||||
Ok(destination)
|
||||
// A failed copy drops the unique temporary immediately. Successful entries
|
||||
// remain seekable until the enclosing package workspace is dropped.
|
||||
file.into_temp_path()
|
||||
.keep()
|
||||
.context("retain extracted package entry")
|
||||
}
|
||||
|
||||
fn map_read_only(file: &File, path: &Path) -> Result<Mmap> {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+54
-7
@@ -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;
|
||||
@@ -218,7 +239,7 @@ pub fn run_folder_opts(
|
||||
// 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();
|
||||
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
|
||||
@@ -311,7 +332,8 @@ pub fn run_folder_opts(
|
||||
// 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(&scan.android_packages);
|
||||
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();
|
||||
@@ -514,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
|
||||
@@ -556,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 {
|
||||
@@ -603,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 {
|
||||
@@ -652,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);
|
||||
|
||||
+19
-4
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user