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, Vec)>, embedded: Option>, } impl LibraryRestorer for ScriptedRestorer { fn restore( &mut self, input: &Path, dest: &Path, _verbose: bool, ) -> anyhow::Result { 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> { let bytes = std::fs::read(input).ok()?; self.modules .iter() .any(|(library, _)| library == &bytes) .then_some(bytes) } } fn pair(seed: u32) -> (Vec, Vec) { // 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 { 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#" "# ) } fn library_bytes(marker: &[u8]) -> Vec { // 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 { 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 { 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::>() ); 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::>() ); 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 `/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)>; 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#""#; 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, version_name: Option<&str>, ) -> Vec { 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 { let mut data = Vec::new(); let mut offsets = Vec::new(); for value in strings { offsets.push(data.len() as u32); let units: Vec = 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 { 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 }