From d16f1c9394a7496708b7552321043012f144d1b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=B6=BE=E7=80=AC=E6=A1=83=E6=A1=83?= Date: Wed, 4 Nov 2026 11:34:40 +0800 Subject: [PATCH] fix(android): deduplicate only successfully restored inputs --- senbei-io/src/android/mod.rs | 18 ++++- senbei-io/src/android/tests.rs | 118 +++++++++++++++++++++++++++++++++ senbei-io/src/job.rs | 18 +++-- 3 files changed, 149 insertions(+), 5 deletions(-) create mode 100644 senbei-io/src/android/tests.rs diff --git a/senbei-io/src/android/mod.rs b/senbei-io/src/android/mod.rs index e151abe..4e5c10f 100644 --- a/senbei-io/src/android/mod.rs +++ b/senbei-io/src/android/mod.rs @@ -15,6 +15,9 @@ //! All functions in this module are native filesystem orchestration; the web //! app (wasm) never touches them. +#[cfg(test)] +mod tests; + use std::collections::HashSet; use std::fs::File; use std::io::{BufWriter, Read, Seek, Write}; @@ -480,7 +483,8 @@ fn restore_package_entry( if !is_so && !is_meta { return Ok(vec![outcome(EntryKind::So, EntryStatus::NotTarget)]); } - if !seen.insert(content_identity(&entry_data)) { + let identity = content_identity(&entry_data); + if seen.contains(&identity) { let kind = if is_so { EntryKind::So } else { @@ -512,6 +516,12 @@ fn restore_package_entry( status, }); } + if outcomes + .iter() + .all(|outcome| !matches!(outcome.status, EntryStatus::Failed(_))) + { + seen.insert(identity); + } outcomes } Err(error) => vec![outcome(EntryKind::So, EntryStatus::Failed(error))], @@ -540,6 +550,12 @@ fn restore_package_entry( EntryStatus::Failed(error), ), }; + if matches!( + kind_and_status.1, + EntryStatus::Restored | EntryStatus::Unchanged + ) { + seen.insert(identity); + } Ok(vec![outcome(kind_and_status.0, kind_and_status.1)]) } diff --git a/senbei-io/src/android/tests.rs b/senbei-io/src/android/tests.rs new file mode 100644 index 0000000..29304d9 --- /dev/null +++ b/senbei-io/src/android/tests.rs @@ -0,0 +1,118 @@ +use super::*; +use zip::write::SimpleFileOptions; + +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())); +} diff --git a/senbei-io/src/job.rs b/senbei-io/src/job.rs index 0990228..99e4611 100644 --- a/senbei-io/src/job.rs +++ b/senbei-io/src/job.rs @@ -228,10 +228,14 @@ pub fn run_folder_opts( for input in &scan.android_so { let rel = rel_in_tree(root, input); let dest = out_root.join(out_name(&rel)); + // Failed restores must not hide an identical input encountered later. // Unreadable here is fine: the restore reports the same error. - if android_dedup - && let Ok(identity) = crate::android::file_content_identity(input) - && !android_seen.insert(identity) + let identity = android_dedup + .then(|| crate::android::file_content_identity(input).ok()) + .flatten(); + if identity + .as_ref() + .is_some_and(|id| android_seen.contains(id)) { s.skipped += 1; if let Some(log) = &log { @@ -263,7 +267,13 @@ pub fn run_folder_opts( if let Some(log) = &log { log.step(&format!("OK {rel:?} -> {dest:?} (Android SO)")); } - match write_embedded_metadata(embedded, &dest) { + let embedded_result = write_embedded_metadata(embedded, &dest); + if embedded_result.is_ok() + && let Some(identity) = identity + { + android_seen.insert(identity); + } + match embedded_result { Ok(Some(meta_dest)) => { s.metadata += 1; crate::ui::ok_label(