Files
Senbei/senbei-io/src/android/tests.rs
T

1226 lines
40 KiB
Rust

use super::*;
use crate::android::context::{self, AppKey, LibraryRestorer, RestoredLibrary};
use zip::write::SimpleFileOptions;
#[derive(Default)]
struct ScriptedRestorer {
/// Encrypted library bytes -> module 0x0C profile.
modules: Vec<(Vec<u8>, Vec<u8>)>,
embedded: Option<Vec<u8>>,
}
impl LibraryRestorer for ScriptedRestorer {
fn restore(
&mut self,
input: &Path,
dest: &Path,
_verbose: bool,
) -> anyhow::Result<RestoredLibrary> {
let bytes = std::fs::read(input)?;
let module = self
.modules
.iter()
.find(|(library, _)| library == &bytes)
.map(|(_, module)| module.clone())
.ok_or_else(|| anyhow::anyhow!("no scripted module for library"))?;
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
// The restored image is not an ELF; tests only check which metadata the
// module recovered. A stable marker keeps duplicate reuse observable.
std::fs::write(dest, b"restored")?;
Ok(RestoredLibrary {
embedded_metadata: self.embedded.clone(),
method_index_module: Some(module),
module_image: Some("module-0c.bin".to_owned()),
})
}
fn protected_override(&mut self, input: &Path) -> Option<Vec<u8>> {
let bytes = std::fs::read(input).ok()?;
self.modules
.iter()
.any(|(library, _)| library == &bytes)
.then_some(bytes)
}
}
fn pair(seed: u32) -> (Vec<u8>, Vec<u8>) {
// Local copy of the v24.1 fixture transform. Seeds 1..=5 and `0x77700DCC`
// miss exactly `0..255` after the repeated high-half image, which is the
// exception table written below. The metadata crate does not export this.
const STRIDE: usize = 0x34;
const INDEX_AT: usize = 0x14;
fn transform(mut value: u32, count: u32, seed: u32) -> u32 {
let key = seed % (count / 2) + count / 4;
for _ in 0..5 {
let mirror = count * 2 - 1;
if value & 1 != 0 {
value = mirror - value;
}
value >>= 1;
if value >= count {
value = mirror - value;
}
let adjusted = i64::from(value) - i64::from(key);
value = if adjusted < 0 {
(adjusted + i64::from(count)) as u32
} else {
adjusted as u32
};
}
value
}
let active = 512_u32;
let mut inverse = vec![u32::MAX; active as usize];
for value in 0..active {
inverse[transform(value, active, seed) as usize] = value;
}
let header = 0x100_usize;
let methods = active as usize * STRIDE;
let mut metadata = vec![0_u8; header + methods];
metadata[0..4].copy_from_slice(&0xfab1_1baf_u32.to_le_bytes());
metadata[4..8].copy_from_slice(&24_u32.to_le_bytes());
metadata[0x30..0x34].copy_from_slice(&(header as u32).to_le_bytes());
metadata[0x34..0x38].copy_from_slice(&(methods as u32).to_le_bytes());
for method in 0..active as usize {
let image = 256 + (method % 256) as u32;
let protected = inverse[image as usize] as i32;
let offset = header + method * STRIDE + INDEX_AT;
metadata[offset..offset + 4].copy_from_slice(&protected.to_le_bytes());
}
let mut module = vec![0_u8; 0x1000];
module[0x100..0x104].copy_from_slice(&seed.to_le_bytes());
module[0x104..0x108].copy_from_slice(&24_u32.to_le_bytes());
module[0x3f8..0x3fc].copy_from_slice(&1_u32.to_le_bytes());
module[0x3fc..0x400].copy_from_slice(&(active - 1).to_le_bytes());
for value in 0..256_u32 {
let offset = 0x400 + value as usize * 4;
module[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
}
(metadata, module)
}
fn restored_with(metadata: &[u8], module: &[u8]) -> Vec<u8> {
match senbei_metadata::android::restore_method_indices_v24_1(metadata, module) {
Ok((bytes, _)) => bytes,
Err(error) => panic!("fixture restore failed: {error:?}"),
}
}
fn manifest(package: &str, version: &str) -> String {
format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<manifest package="{package}" android:versionCode="{version}">
</manifest>
"#
)
}
fn library_bytes(marker: &[u8]) -> Vec<u8> {
// Distinct from metadata magic so a mis-classified entry cannot take the
// metadata path. The scripted restorer recognises these exact bytes.
let mut bytes = b"SENBEI-SYNTHETIC-LIBRARY".to_vec();
bytes.extend_from_slice(marker);
bytes
}
fn metadata_fixture() -> Vec<u8> {
let mut data = vec![0; 0x1a4];
let mut put = |offset: usize, value: u32| {
data[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
};
put(0, 0xfab1_1baf);
put(4, 31);
put(0x30, 0x180);
put(0x34, 0x24);
put(0xa0, 0x128);
put(0xa4, 0x58);
put(0xa8, 0x100);
put(0xac, 0x28);
put(0x10c, 1);
put(0x14c, 0);
put(0x168, 1);
put(0x198, 0x0600_1234);
data
}
fn write_package(path: &Path, entries: &[(&str, &[u8])]) {
let mut archive = zip::ZipWriter::new(File::create(path).unwrap());
for (name, data) in entries {
archive
.start_file(*name, SimpleFileOptions::default())
.unwrap();
archive.write_all(data).unwrap();
}
archive.finish().unwrap();
}
#[test]
fn failed_metadata_output_does_not_poison_deduplication() {
let temp = tempfile::tempdir().unwrap();
let package = temp.path().join("app.apk");
let out = temp.path().join("out");
let data = metadata_fixture();
write_package(
&package,
&[
("a/global-metadata.dat", &data),
("b/global-metadata.dat", &data),
("c/global-metadata.dat", &data),
],
);
std::fs::create_dir_all(out.join("app.apk")).unwrap();
std::fs::write(out.join("app.apk/a"), b"blocks the output directory").unwrap();
let mut seen = HashSet::new();
let outcomes = restore_package(&package, Path::new("app.apk"), &out, &mut seen, false).unwrap();
assert_eq!(outcomes.len(), 3);
assert!(matches!(outcomes[0].status, EntryStatus::Failed(_)));
assert!(matches!(outcomes[1].status, EntryStatus::Restored));
assert!(matches!(outcomes[2].status, EntryStatus::Duplicate));
assert_eq!(seen.len(), 1);
let expected = restore_metadata_bytes(&data).unwrap().0;
assert_eq!(std::fs::read(&outcomes[1].dest).unwrap(), expected);
assert!(!outcomes[2].dest.exists());
}
#[test]
fn repeated_failed_metadata_is_retried() {
let temp = tempfile::tempdir().unwrap();
let package = temp.path().join("app.apk");
let malformed = [0xaf, 0x1b, 0xb1, 0xfa, 31, 0, 0, 0];
write_package(
&package,
&[
("a/global-metadata.dat", &malformed),
("b/global-metadata.dat", &malformed),
],
);
let mut seen = HashSet::new();
let outcomes = restore_package(
&package,
Path::new("app.apk"),
&temp.path().join("out"),
&mut seen,
false,
)
.unwrap();
assert_eq!(outcomes.len(), 2);
assert!(
outcomes
.iter()
.all(|outcome| matches!(outcome.status, EntryStatus::Failed(_)))
);
assert!(seen.is_empty());
}
#[test]
fn unchanged_metadata_is_a_successful_deduplication_source() {
let temp = tempfile::tempdir().unwrap();
let package = temp.path().join("app.apk");
let data = restore_metadata_bytes(&metadata_fixture()).unwrap().0;
write_package(
&package,
&[
("a/global-metadata.dat", &data),
("b/global-metadata.dat", &data),
],
);
let mut seen = HashSet::new();
let outcomes = restore_package(
&package,
Path::new("app.apk"),
&temp.path().join("out"),
&mut seen,
false,
)
.unwrap();
assert!(matches!(outcomes[0].status, EntryStatus::Unchanged));
assert!(matches!(outcomes[1].status, EntryStatus::Duplicate));
assert_eq!(seen.len(), 1);
assert!(outcomes.iter().all(|outcome| !outcome.dest.exists()));
}
fn restore_scripted(
package: &Path,
rel: &Path,
out: &Path,
cache: &mut context::AndroidRunCache,
restorer: &mut ScriptedRestorer,
) -> Vec<EntryOutcome> {
restore_package_with_restorer(package, rel, out, cache, false, restorer).unwrap()
}
fn metadata_outcomes(outcomes: &[EntryOutcome]) -> Vec<&EntryOutcome> {
outcomes
.iter()
.filter(|outcome| matches!(outcome.kind, EntryKind::Metadata { .. }))
.collect()
}
#[test]
fn two_extracted_apps_do_not_share_module_context() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
let (meta_a, module_a) = pair(1);
let (meta_b, module_b) = pair(2);
let lib_a = library_bytes(b"app-a");
let lib_b = library_bytes(b"app-b");
let app_a = root.join("games/alpha");
let app_b = root.join("games/beta");
std::fs::create_dir_all(app_a.join("lib/arm64-v8a")).unwrap();
std::fs::create_dir_all(app_a.join("assets/bin/Data/Managed/Metadata")).unwrap();
std::fs::create_dir_all(app_b.join("lib/arm64-v8a")).unwrap();
std::fs::create_dir_all(app_b.join("assets/bin/Data/Managed/Metadata")).unwrap();
let so_a = app_a.join("lib/arm64-v8a/libil2cpp.so");
let so_b = app_b.join("lib/arm64-v8a/libil2cpp.so");
let md_a = app_a.join("assets/bin/Data/Managed/Metadata/global-metadata.dat");
let md_b = app_b.join("assets/bin/Data/Managed/Metadata/global-metadata.dat");
std::fs::write(&so_a, &lib_a).unwrap();
std::fs::write(&so_b, &lib_b).unwrap();
std::fs::write(&md_a, &meta_a).unwrap();
std::fs::write(&md_b, &meta_b).unwrap();
let scoped = loose_scopes(&[so_b.clone(), so_a.clone()], &[md_a.clone(), md_b.clone()]);
assert!(
scoped
.iter()
.any(|item| item.path == so_a && item.key == AppKey::Extracted(app_a.clone())),
"scopes: {:?}",
scoped
.iter()
.map(|item| (item.path.display().to_string(), format!("{:?}", item.key)))
.collect::<Vec<_>>()
);
assert!(
scoped
.iter()
.any(|item| item.path == md_b && item.key == AppKey::Extracted(app_b.clone()))
);
let mut cache = run_cache();
let mut restorer = ScriptedRestorer {
embedded: None,
modules: vec![(lib_a, module_a.clone()), (lib_b, module_b.clone())],
};
let out = root.join("unpack");
// Restore beta's library first. A last-writer cache would then poison alpha.
for (library, app) in [(&so_b, &app_b), (&so_a, &app_a)] {
let scoped = scoped.iter().find(|item| &item.path == library).unwrap();
assert_eq!(&scoped.key, &AppKey::Extracted(app.clone()));
let outcome = restore_loose_library_with(
library,
&out.join(library.file_name().unwrap()),
&library.display().to_string(),
&scoped.key,
false,
&mut cache,
&mut restorer,
)
.unwrap();
assert!(matches!(outcome.status, EntryStatus::Restored));
}
let restored_a = restore_loose_metadata(
&md_a,
&out.join("a.dat"),
"alpha metadata",
&AppKey::Extracted(app_a.clone()),
false,
&mut cache,
)
.unwrap_or_else(|error| panic!("alpha metadata: {error:#}; cache apps will not print"));
let restored_b = restore_loose_metadata(
&md_b,
&out.join("b.dat"),
"beta metadata",
&AppKey::Extracted(app_b),
false,
&mut cache,
)
.unwrap();
assert_eq!(
std::fs::read(out.join("a.dat")).unwrap(),
restored_with(&meta_a, &module_a)
);
assert_eq!(
std::fs::read(out.join("b.dat")).unwrap(),
restored_with(&meta_b, &module_b)
);
assert!(
senbei_metadata::android::restore_method_indices_v24_1(&meta_a, &module_b).is_err(),
"beta's module must not restore alpha's metadata"
);
assert!(
restored_a
.provenance
.iter()
.any(|source| source.contains("alpha") || source.contains("libil2cpp"))
);
assert!(
restored_b
.provenance
.iter()
.all(|source| !source.contains("games/alpha") && !source.contains("games\\alpha"))
);
}
#[test]
fn bundle_splits_share_context_independent_of_order() {
let temp = tempfile::tempdir().unwrap();
let (metadata, module) = pair(3);
let library = library_bytes(b"split-lib");
let manifest_xml = manifest("example.game", "123");
let write = |name: &str, base_first: bool| {
let path = temp.path().join(name);
let base = (
"base.apk",
entries(&[
("AndroidManifest.xml", manifest_xml.as_bytes()),
(
"assets/bin/Data/Managed/Metadata/global-metadata.dat",
&metadata,
),
]),
);
let split = (
"split_config.arm64_v8a.apk",
entries(&[
("AndroidManifest.xml", manifest_xml.as_bytes()),
("lib/arm64-v8a/libil2cpp.so", &library),
]),
);
let nested = if base_first {
vec![base, split]
} else {
vec![split, base]
};
write_nested(&path, &nested);
path
};
let mut cache_forward = run_cache();
let mut cache_reverse = run_cache();
let mut restorer = ScriptedRestorer {
embedded: None,
modules: vec![(library.clone(), module.clone())],
};
let forward = write("forward.apks", true);
let reverse = write("reverse.apks", false);
let out_forward = temp.path().join("out-forward");
let out_reverse = temp.path().join("out-reverse");
let first = restore_scripted(
&forward,
Path::new("forward.apks"),
&out_forward,
&mut cache_forward,
&mut restorer,
);
let second = restore_scripted(
&reverse,
Path::new("reverse.apks"),
&out_reverse,
&mut cache_reverse,
&mut restorer,
);
let meta_first = metadata_outcomes(&first);
let meta_second = metadata_outcomes(&second);
assert_eq!(meta_first.len(), 1);
assert_eq!(meta_second.len(), 1);
assert!(matches!(meta_first[0].status, EntryStatus::Restored));
assert!(matches!(meta_second[0].status, EntryStatus::Restored));
let expected = restored_with(&metadata, &module);
assert_eq!(std::fs::read(&meta_first[0].dest).unwrap(), expected);
assert_eq!(std::fs::read(&meta_second[0].dest).unwrap(), expected);
assert!(!meta_first[0].provenance.is_empty());
assert!(!meta_second[0].provenance.is_empty());
}
#[test]
fn mixed_bundle_does_not_cross_package_identities() {
let temp = tempfile::tempdir().unwrap();
let (meta_a, module_a) = pair(1);
let (meta_b, module_b) = pair(2);
let lib_a = library_bytes(b"mix-a");
let lib_b = library_bytes(b"mix-b");
let package = temp.path().join("mixed.apks");
write_nested(
&package,
&[
(
"first.apk",
entries(&[
(
"AndroidManifest.xml",
manifest("example.one", "1").as_bytes(),
),
("lib/arm64-v8a/libil2cpp.so", &lib_a),
(
"assets/bin/Data/Managed/Metadata/global-metadata.dat",
&meta_a,
),
]),
),
(
"second.apk",
entries(&[
(
"AndroidManifest.xml",
manifest("example.two", "1").as_bytes(),
),
("lib/arm64-v8a/libil2cpp.so", &lib_b),
(
"assets/bin/Data/Managed/Metadata/global-metadata.dat",
&meta_b,
),
]),
),
],
);
let mut cache = run_cache();
let mut restorer = ScriptedRestorer {
embedded: None,
modules: vec![(lib_a, module_a.clone()), (lib_b, module_b.clone())],
};
let outcomes = restore_scripted(
&package,
Path::new("mixed.apks"),
&temp.path().join("out"),
&mut cache,
&mut restorer,
);
let metas = metadata_outcomes(&outcomes);
assert_eq!(metas.len(), 2);
assert!(
metas
.iter()
.all(|outcome| matches!(outcome.status, EntryStatus::Restored)),
"outcomes: {:?}",
metas
.iter()
.map(|outcome| {
let status = match &outcome.status {
EntryStatus::Failed(error) => format!("failed: {error:#}"),
other => format!("{other:?}"),
};
(outcome.label.clone(), status, outcome.provenance.clone())
})
.collect::<Vec<_>>()
);
let bytes: Vec<_> = metas
.iter()
.map(|outcome| std::fs::read(&outcome.dest).unwrap())
.collect();
// Canonical method order does not depend on the seed, so both restored
// blobs match their own module. Isolation is the provenance: each metadata
// entry records only the library inside its own package.
assert!(
bytes
.iter()
.all(|item| item == &restored_with(&meta_a, &module_a)
|| item == &restored_with(&meta_b, &module_b))
);
assert!(metas.iter().all(|outcome| outcome.provenance.len() == 1));
assert_ne!(metas[0].provenance, metas[1].provenance);
}
#[test]
fn duplicate_library_populates_the_owning_context() {
let temp = tempfile::tempdir().unwrap();
let (metadata, module) = pair(4);
let library = library_bytes(b"dup-lib");
let package = temp.path().join("dups.apk");
let xml = manifest("example.dups", "9");
write_package(
&package,
&[
("AndroidManifest.xml", xml.as_bytes()),
("lib/arm64-v8a/libil2cpp.so", &library),
("lib/armeabi-v7a/libil2cpp.so", &library),
(
"assets/bin/Data/Managed/Metadata/global-metadata.dat",
&metadata,
),
],
);
let mut cache = run_cache();
let mut restorer = ScriptedRestorer {
embedded: None,
modules: vec![(library, module.clone())],
};
let outcomes = restore_scripted(
&package,
Path::new("dups.apk"),
&temp.path().join("out"),
&mut cache,
&mut restorer,
);
let libraries: Vec<_> = outcomes
.iter()
.filter(|outcome| matches!(outcome.kind, EntryKind::So))
.collect();
assert!(matches!(libraries[0].status, EntryStatus::Restored));
assert!(matches!(libraries[1].status, EntryStatus::Duplicate));
let meta = metadata_outcomes(&outcomes);
assert_eq!(meta.len(), 1);
assert!(matches!(meta[0].status, EntryStatus::Restored));
assert_eq!(
std::fs::read(&meta[0].dest).unwrap(),
restored_with(&metadata, &module)
);
assert!(
meta[0].provenance.len() >= 2,
"both library copies must be recorded, got {:?}",
meta[0].provenance
);
}
#[test]
fn missing_module_writes_no_v24_metadata() {
let temp = tempfile::tempdir().unwrap();
let (metadata, module) = pair(5);
let package = temp.path().join("bare.apk");
write_package(
&package,
&[(
"assets/bin/Data/Managed/Metadata/global-metadata.dat",
metadata.as_slice(),
)],
);
let mut cache = run_cache();
let mut restorer = ScriptedRestorer {
embedded: None,
modules: vec![(library_bytes(b"unused"), module)],
};
let outcomes = restore_scripted(
&package,
Path::new("bare.apk"),
&temp.path().join("out"),
&mut cache,
&mut restorer,
);
assert!(
outcomes
.iter()
.all(|outcome| matches!(outcome.status, EntryStatus::Failed(_)))
);
assert!(outcomes.iter().all(|outcome| !outcome.dest.exists()));
}
#[test]
fn conflicting_modules_write_no_metadata() {
let temp = tempfile::tempdir().unwrap();
let (metadata, module_a) = pair(1);
let (_, module_b) = pair(2);
let lib_a = library_bytes(b"conflict-a");
let lib_b = library_bytes(b"conflict-b");
let package = temp.path().join("conflict.apk");
let xml = manifest("example.conflict", "1");
write_package(
&package,
&[
("AndroidManifest.xml", xml.as_bytes()),
("lib/arm64-v8a/one.so", &lib_a),
("lib/arm64-v8a/two.so", &lib_b),
(
"assets/bin/Data/Managed/Metadata/global-metadata.dat",
&metadata,
),
],
);
let mut cache = run_cache();
let mut restorer = ScriptedRestorer {
embedded: None,
modules: vec![(lib_a, module_a), (lib_b, module_b)],
};
let outcomes = restore_scripted(
&package,
Path::new("conflict.apk"),
&temp.path().join("out"),
&mut cache,
&mut restorer,
);
let meta = metadata_outcomes(&outcomes);
assert_eq!(meta.len(), 1);
assert!(matches!(meta[0].status, EntryStatus::Failed(_)));
assert!(!meta[0].dest.exists());
assert!(meta[0].provenance.len() >= 2);
}
#[test]
fn same_metadata_bytes_cache_separately_per_module() {
let temp = tempfile::tempdir().unwrap();
let (metadata, module_a) = pair(3);
// A second copy of the same module is still one profile. A different seed
// that does not restore this metadata must not be substituted for it.
let module_copy = module_a.clone();
let (_other_metadata, other_module) = pair(4);
let lib_a = library_bytes(b"cache-a");
let lib_b = library_bytes(b"cache-b");
let bundle = temp.path().join("same-bytes.apks");
write_nested(
&bundle,
&[
(
"one.apk",
entries(&[
(
"AndroidManifest.xml",
manifest("example.cache", "1").as_bytes(),
),
("lib/arm64-v8a/libil2cpp.so", &lib_a),
(
"assets/bin/Data/Managed/Metadata/global-metadata.dat",
&metadata,
),
]),
),
(
"two.apk",
entries(&[
(
"AndroidManifest.xml",
manifest("example.cache", "2").as_bytes(),
),
("lib/arm64-v8a/libil2cpp.so", &lib_b),
(
"assets/bin/Data/Managed/Metadata/global-metadata.dat",
&metadata,
),
]),
),
],
);
let mut cache = run_cache();
let mut restorer = ScriptedRestorer {
embedded: None,
modules: vec![(lib_a, module_copy), (lib_b, other_module)],
};
let outcomes = restore_scripted(
&bundle,
Path::new("same-bytes.apks"),
&temp.path().join("out"),
&mut cache,
&mut restorer,
);
let metas = metadata_outcomes(&outcomes);
assert_eq!(metas.len(), 2);
// Version 1 has this metadata's module and restores. Version 2 has a
// different module; the shared encrypted bytes must not reuse version 1's
// output, and version 2 writes nothing.
assert!(matches!(metas[0].status, EntryStatus::Restored));
assert!(matches!(metas[1].status, EntryStatus::Failed(_)));
assert_eq!(
std::fs::read(&metas[0].dest).unwrap(),
restored_with(&metadata, &module_a)
);
assert!(!metas[1].dest.exists());
assert!(metas.iter().all(|outcome| outcome.provenance.len() == 1));
assert_ne!(metas[0].provenance, metas[1].provenance);
}
#[test]
fn loose_metadata_outside_an_extracted_boundary_is_isolated() {
let temp = tempfile::tempdir().unwrap();
let (metadata, module) = pair(5);
let library = library_bytes(b"loose-boundary");
let app = temp.path().join("game");
std::fs::create_dir_all(app.join("lib/arm64-v8a")).unwrap();
let so = app.join("lib/arm64-v8a/libil2cpp.so");
// Same scan root, but not under `<app>/assets/`. Must not borrow the module.
let stray = temp.path().join("global-metadata.dat");
std::fs::write(&so, &library).unwrap();
std::fs::write(&stray, &metadata).unwrap();
let scoped = loose_scopes(std::slice::from_ref(&so), std::slice::from_ref(&stray));
let stray_key = scoped
.iter()
.find(|item| item.path == stray)
.unwrap()
.key
.clone();
assert!(matches!(stray_key, AppKey::Unidentifiable { .. }));
let mut cache = run_cache();
let mut restorer = ScriptedRestorer {
embedded: None,
modules: vec![(library, module)],
};
restore_loose_library_with(
&so,
&temp.path().join("out.so"),
"library",
&AppKey::Extracted(app),
false,
&mut cache,
&mut restorer,
)
.unwrap();
let error = restore_loose_metadata(
&stray,
&temp.path().join("out.dat"),
"stray",
&stray_key,
false,
&mut cache,
)
.unwrap_err();
let message = format!("{error:#}");
assert!(
message.contains("no module 0x0C"),
"unexpected error: {message}"
);
assert!(!temp.path().join("out.dat").exists());
}
type NestedEntries = Vec<(String, Vec<u8>)>;
fn entries(pairs: &[(&str, &[u8])]) -> NestedEntries {
pairs
.iter()
.map(|(name, data)| ((*name).to_owned(), (*data).to_vec()))
.collect()
}
fn write_nested(path: &Path, apks: &[(&str, NestedEntries)]) {
let mut archive = zip::ZipWriter::new(File::create(path).unwrap());
for (name, nested) in apks {
let mut payload = Vec::new();
{
let mut inner = zip::ZipWriter::new(std::io::Cursor::new(&mut payload));
for (entry, data) in nested {
inner
.start_file(entry, SimpleFileOptions::default())
.unwrap();
inner.write_all(data).unwrap();
}
inner.finish().unwrap();
}
archive
.start_file(*name, SimpleFileOptions::default())
.unwrap();
archive.write_all(&payload).unwrap();
}
archive.finish().unwrap();
}
#[test]
fn duplicate_library_writes_no_output() {
let temp = tempfile::tempdir().unwrap();
let (metadata, module) = pair(1);
let library = library_bytes(b"dup-output");
let package = temp.path().join("once.apk");
write_package(
&package,
&[
(
"AndroidManifest.xml",
manifest("example.once", "1").as_bytes(),
),
("lib/arm64-v8a/libil2cpp.so", &library),
("lib/armeabi-v7a/libil2cpp.so", &library),
(
"assets/bin/Data/Managed/Metadata/global-metadata.dat",
&metadata,
),
],
);
let mut cache = run_cache();
let mut restorer = ScriptedRestorer {
embedded: None,
modules: vec![(library, module)],
};
let outcomes = restore_scripted(
&package,
Path::new("once.apk"),
&temp.path().join("out"),
&mut cache,
&mut restorer,
);
let libraries: Vec<_> = outcomes
.iter()
.filter(|outcome| matches!(outcome.kind, EntryKind::So))
.collect();
assert!(matches!(libraries[0].status, EntryStatus::Restored));
assert!(libraries[0].dest.exists());
assert!(matches!(libraries[1].status, EntryStatus::Duplicate));
assert!(
!libraries[1].dest.exists(),
"a duplicate library must not write {}",
libraries[1].dest.display()
);
}
#[test]
fn embedded_metadata_failure_is_retried() {
let temp = tempfile::tempdir().unwrap();
let library = library_bytes(b"embedded-retry");
let package = temp.path().join("embed.apk");
write_package(&package, &[("lib/arm64-v8a/libil2cpp.so", &library)]);
let first_root = temp.path().join("first");
let library_dest = first_root.join("embed.apk/lib/arm64-v8a/libil2cpp.unpack.so");
let metadata_dest = embedded_metadata_dest(&library_dest);
// Let the library write succeed, but prevent replacing its metadata file.
std::fs::create_dir_all(&metadata_dest).unwrap();
let mut cache = run_cache();
let mut restorer = ScriptedRestorer {
modules: vec![(library.clone(), pair(1).1)],
embedded: Some(b"embedded-blob".to_vec()),
};
let first = restore_scripted(
&package,
Path::new("embed.apk"),
&first_root,
&mut cache,
&mut restorer,
);
assert!(
first
.iter()
.any(|outcome| matches!(outcome.status, EntryStatus::Failed(_))),
"embedded write must fail, got {first:?}"
);
assert!(cache.library(&content_identity(&library)).is_none());
assert_eq!(std::fs::read(&library_dest).unwrap(), b"restored");
assert!(metadata_dest.is_dir());
let retry_root = temp.path().join("retry");
let second = restore_scripted(
&package,
Path::new("embed.apk"),
&retry_root,
&mut cache,
&mut restorer,
);
assert!(
second
.iter()
.any(|outcome| matches!(outcome.status, EntryStatus::Restored))
);
}
#[test]
fn content_only_duplicate_recovers_context_without_touching_existing_output() {
let temp = tempfile::tempdir().unwrap();
let library = library_bytes(b"prior-success");
let (metadata, module) = pair(1);
let package = temp.path().join("app.apk");
write_package(
&package,
&[
("lib/arm64-v8a/libil2cpp.so", &library),
("assets/global-metadata.dat", &metadata),
],
);
let out = temp.path().join("out");
let dest = out.join("app.apk/lib/arm64-v8a/libil2cpp.unpack.so");
std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
std::fs::write(&dest, b"keep existing output").unwrap();
let mut cache = run_cache();
cache.insert_prior_success(content_identity(&library));
let mut restorer = ScriptedRestorer {
modules: vec![(library, module.clone())],
embedded: None,
};
let outcomes = restore_scripted(
&package,
Path::new("app.apk"),
&out,
&mut cache,
&mut restorer,
);
assert!(matches!(outcomes[0].status, EntryStatus::Duplicate));
assert_eq!(std::fs::read(&dest).unwrap(), b"keep existing output");
let meta = metadata_outcomes(&outcomes);
assert!(matches!(meta[0].status, EntryStatus::Restored));
assert_eq!(
std::fs::read(&meta[0].dest).unwrap(),
restored_with(&metadata, &module)
);
}
#[test]
fn loose_embedded_metadata_reports_its_written_path() {
let temp = tempfile::tempdir().unwrap();
let input = temp.path().join("library.so");
let library = library_bytes(b"loose-embedded");
std::fs::write(&input, &library).unwrap();
let dest = temp.path().join("out/library.unpack.so");
let mut cache = run_cache();
let mut restorer = ScriptedRestorer {
modules: vec![(library, pair(1).1)],
embedded: Some(b"metadata".to_vec()),
};
let outcome = restore_loose_library_with(
&input,
&dest,
"library.so",
&AppKey::Unidentifiable {
source: "one".to_owned(),
},
false,
&mut cache,
&mut restorer,
)
.unwrap();
assert!(matches!(outcome.status, EntryStatus::Restored));
assert_eq!(outcome.embedded, Some(embedded_metadata_dest(&dest)));
assert_eq!(
std::fs::read(outcome.embedded.unwrap()).unwrap(),
b"metadata"
);
}
#[test]
fn public_seen_survives_package_error() {
let temp = tempfile::tempdir().unwrap();
let missing = temp.path().join("missing.apk");
let mut seen = std::collections::HashSet::from(["already-restored".to_owned()]);
let error = restore_package(
&missing,
Path::new("missing.apk"),
&temp.path().join("out"),
&mut seen,
false,
);
assert!(error.is_err());
assert!(
seen.contains("already-restored"),
"an error must not drop successes the caller already had: {seen:?}"
);
}
#[test]
fn binary_manifest_splits_share_and_versions_do_not() {
let temp = tempfile::tempdir().unwrap();
let (metadata, _module) = pair(1);
let library = library_bytes(b"axml-lib");
let same = axml_manifest("example.axml", 7, None, None);
let other = axml_manifest("example.axml", 8, None, None);
let base = temp.path().join("base.apk");
let split = temp.path().join("split.apk");
let newer = temp.path().join("newer.apk");
write_package(
&base,
&[
("AndroidManifest.xml", &same),
(
"assets/bin/Data/Managed/Metadata/global-metadata.dat",
&metadata,
),
],
);
write_package(
&split,
&[
("AndroidManifest.xml", &same),
("lib/arm64-v8a/libil2cpp.so", &library),
],
);
write_package(
&newer,
&[
("AndroidManifest.xml", &other),
("lib/arm64-v8a/libil2cpp.so", &library),
],
);
let groups = loose_apk_groups(&[newer.clone(), base.clone(), split.clone()]);
let together = groups.iter().find(|group| group.contains(&base)).unwrap();
assert!(together.contains(&split), "groups: {groups:?}");
assert!(!together.contains(&newer), "groups: {groups:?}");
}
#[test]
fn truncated_binary_manifest_does_not_name_a_package() {
let temp = tempfile::tempdir().unwrap();
let package = temp.path().join("cut.apk");
let mut truncated = axml_manifest("example.cut", 1, None, None);
truncated.truncate(12);
write_package(&package, &[("AndroidManifest.xml", &truncated)]);
let groups = loose_apk_groups(std::slice::from_ref(&package));
assert_eq!(groups, vec![vec![package]]);
}
#[test]
fn non_v24_metadata_ignores_conflicting_modules() {
let temp = tempfile::tempdir().unwrap();
let package = temp.path().join("later.apk");
let lib_a = library_bytes(b"later-a");
let lib_b = library_bytes(b"later-b");
let metadata = metadata_fixture();
write_package(
&package,
&[
(
"AndroidManifest.xml",
manifest("example.later", "1").as_bytes(),
),
("lib/arm64-v8a/one.so", &lib_a),
("lib/arm64-v8a/two.so", &lib_b),
(
"assets/bin/Data/Managed/Metadata/global-metadata.dat",
&metadata,
),
],
);
let mut cache = run_cache();
let mut restorer = ScriptedRestorer {
embedded: None,
modules: vec![(lib_a, pair(1).1), (lib_b, pair(2).1)],
};
let outcomes = restore_scripted(
&package,
Path::new("later.apk"),
&temp.path().join("out"),
&mut cache,
&mut restorer,
);
let meta = metadata_outcomes(&outcomes);
assert_eq!(meta.len(), 1);
assert!(
matches!(
meta[0].status,
EntryStatus::Restored | EntryStatus::Unchanged
),
"v31 must not be blocked by conflicting modules: {:?}",
meta[0].status
);
assert!(
meta[0].provenance.is_empty(),
"a module that was not used must not be recorded: {:?}",
meta[0].provenance
);
}
#[test]
fn version_name_alone_does_not_merge_builds() {
let temp = tempfile::tempdir().unwrap();
let named = r#"<manifest package="example.same" android:versionName="1.0" android:versionCode="{code}"></manifest>"#;
let first = temp.path().join("a.apk");
let second = temp.path().join("b.apk");
write_package(
&first,
&[(
"AndroidManifest.xml",
named.replace("{code}", "1").as_bytes(),
)],
);
write_package(
&second,
&[(
"AndroidManifest.xml",
named.replace("{code}", "2").as_bytes(),
)],
);
let groups = loose_apk_groups(&[first.clone(), second.clone()]);
assert!(groups.iter().all(|group| group.len() == 1), "{groups:?}");
}
#[test]
fn direct_assets_metadata_shares_the_extracted_root() {
let temp = tempfile::tempdir().unwrap();
let app = temp.path().join("game");
std::fs::create_dir_all(app.join("lib/arm64-v8a")).unwrap();
std::fs::create_dir_all(app.join("assets")).unwrap();
let so = app.join("lib/arm64-v8a/libil2cpp.so");
let meta = app.join("assets/global-metadata.dat");
std::fs::write(&so, b"so").unwrap();
std::fs::write(&meta, b"meta").unwrap();
let scoped = loose_scopes(std::slice::from_ref(&so), std::slice::from_ref(&meta));
assert_eq!(scoped[0].key, scoped[1].key);
assert!(matches!(scoped[0].key, AppKey::Extracted(_)));
}
fn axml_manifest(
package: &str,
version_code: u32,
major: Option<u32>,
version_name: Option<&str>,
) -> Vec<u8> {
let mut strings = vec![
"manifest".to_owned(),
"package".to_owned(),
package.to_owned(),
"versionCode".to_owned(),
];
if major.is_some() {
strings.push("versionCodeMajor".to_owned());
}
if let Some(version_name) = version_name {
strings.push("versionName".to_owned());
strings.push(version_name.to_owned());
}
let pool = axml_pool(&strings);
let mut attributes = Vec::new();
attributes.extend(axml_attr(1, 2, 0x03, 2));
attributes.extend(axml_attr(3, 0xffff_ffff, 0x10, version_code));
if let Some(major) = major {
attributes.extend(axml_attr(4, 0xffff_ffff, 0x10, major));
}
if version_name.is_some() {
let name_index = (strings.len() - 2) as u32;
let value_index = (strings.len() - 1) as u32;
attributes.extend(axml_attr(name_index, value_index, 0x03, value_index));
}
let attr_count = attributes.len() / 20;
let mut element = Vec::new();
element.extend(u16::to_le_bytes(0x0102));
element.extend(u16::to_le_bytes(16));
let element_size = 8 + 28 + attributes.len();
element.extend(u32::to_le_bytes(element_size as u32));
element.extend(u32::to_le_bytes(0));
element.extend(u32::to_le_bytes(0xffff_ffff));
element.extend(u32::to_le_bytes(0xffff_ffff));
element.extend(u32::to_le_bytes(0));
element.extend(u16::to_le_bytes(0x14)); // attribute start, from the node
element.extend(u16::to_le_bytes(20));
element.extend(u16::to_le_bytes(attr_count as u16));
element.extend(u16::to_le_bytes(0));
element.extend(u16::to_le_bytes(0));
element.extend(u16::to_le_bytes(0));
element.extend(attributes);
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() + element.len()) as u32));
bytes.extend(pool);
bytes.extend(element);
bytes
}
fn axml_pool(strings: &[String]) -> Vec<u8> {
let mut data = Vec::new();
let mut offsets = Vec::new();
for value in strings {
offsets.push(data.len() as u32);
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)); // style count
chunk.extend(u32::to_le_bytes(0)); // flags
chunk.extend(u32::to_le_bytes((28 + strings.len() * 4) as u32));
chunk.extend(u32::to_le_bytes(0)); // styles start
for offset in offsets {
chunk.extend(u32::to_le_bytes(offset));
}
chunk.extend(data);
chunk
}
fn axml_attr(name: u32, raw: u32, value_type: u8, data: u32) -> Vec<u8> {
let mut record = Vec::new();
record.extend(u32::to_le_bytes(0xffff_ffff));
record.extend(u32::to_le_bytes(name));
record.extend(u32::to_le_bytes(raw));
record.extend(u16::to_le_bytes(8));
record.push(0);
record.push(value_type);
record.extend(u32::to_le_bytes(data));
record
}