diff --git a/crates/ltbox-gui/lang/en.json b/crates/ltbox-gui/lang/en.json index 809dd617..d1c751cb 100644 --- a/crates/ltbox-gui/lang/en.json +++ b/crates/ltbox-gui/lang/en.json @@ -117,6 +117,8 @@ "country_reason_no_known_code": "no known code detected", "country_reason_no_lun": "no hardcoded LUN for label", "country_reason_patch_failed": "patch failed: {error}", + "country_reason_verify_failed": "read-back failed: {error}", + "country_reason_verify_mismatch": "read-back does not match the patched image", "dash_action_firmware_lookup": "Firmware Lookup", "dash_action_ota_lookup": "OTA Lookup", "dash_adb_server_blocking": "An external ADB server is preventing device access.", @@ -548,6 +550,7 @@ "live_country_patch_transition": "{label}: {from} → {to}", "live_country_patched_flashed": "{label} patched + flashed", "live_country_processing": "Processing {path}", + "live_country_verified": "{label} read back and verified", "live_country_warning": "Warning: {error}; firmware already flashed, continuing to reboot.", "live_country_written": "{name}: {old_code} → {new_code} written to {path}", "live_crypto_decrypted": "Decrypted {bytes} bytes", diff --git a/crates/ltbox-gui/lang/fr.json b/crates/ltbox-gui/lang/fr.json index e809a8aa..26e81f47 100644 --- a/crates/ltbox-gui/lang/fr.json +++ b/crates/ltbox-gui/lang/fr.json @@ -117,6 +117,8 @@ "country_reason_no_known_code": "aucun code connu détecté", "country_reason_no_lun": "aucun LUN prédéfini pour ce libellé", "country_reason_patch_failed": "échec du patch : {error}", + "country_reason_verify_failed": "échec de la relecture : {error}", + "country_reason_verify_mismatch": "la relecture ne correspond pas à l’image patchée", "dash_action_firmware_lookup": "Rechercher un firmware", "dash_action_ota_lookup": "Rechercher une OTA", "dash_adb_server_blocking": "Un serveur ADB externe empêche l’accès à l’appareil.", @@ -548,6 +550,7 @@ "live_country_patch_transition": "{label} : {from} → {to}", "live_country_patched_flashed": "{label} patché + flashé", "live_country_processing": "Traitement de {path}", + "live_country_verified": "{label} relu et vérifié", "live_country_warning": "Attention : {error} ; firmware déjà flashé, poursuite vers le redémarrage.", "live_country_written": "{name} : {old_code} → {new_code} écrit dans {path}", "live_crypto_decrypted": "{bytes} octets déchiffrés", diff --git a/crates/ltbox-gui/lang/ja.json b/crates/ltbox-gui/lang/ja.json index 2b26c195..9bc70e08 100644 --- a/crates/ltbox-gui/lang/ja.json +++ b/crates/ltbox-gui/lang/ja.json @@ -117,6 +117,8 @@ "country_reason_no_known_code": "既知のコードを検出できません", "country_reason_no_lun": "ラベルにハードコードされた LUN がありません", "country_reason_patch_failed": "パッチ失敗:{error}", + "country_reason_verify_failed": "読み戻しに失敗:{error}", + "country_reason_verify_mismatch": "読み戻した内容がパッチ済みイメージと一致しません", "dash_action_firmware_lookup": "ファームウェア検索", "dash_action_ota_lookup": "OTA 検索", "dash_adb_server_blocking": "外部のADBサーバーが動作しているため、デバイスに接続できません。", @@ -548,6 +550,7 @@ "live_country_patch_transition": "{label}: {from} → {to}", "live_country_patched_flashed": "{label} のパッチ適用 + フラッシュ完了", "live_country_processing": "{path} を処理中", + "live_country_verified": "{label} を読み戻して確認しました", "live_country_warning": "警告:{error}。ファームウェアはすでにフラッシュされているため、再起動を続行します。", "live_country_written": "{name}: {old_code} → {new_code} が {path} に書き込まれました", "live_crypto_decrypted": "{bytes} バイトを復号しました", diff --git a/crates/ltbox-gui/lang/ko.json b/crates/ltbox-gui/lang/ko.json index 188541ec..5b5c2f8b 100644 --- a/crates/ltbox-gui/lang/ko.json +++ b/crates/ltbox-gui/lang/ko.json @@ -117,6 +117,8 @@ "country_reason_no_known_code": "알려진 코드 감지 안 됨", "country_reason_no_lun": "파티션 LUN 매핑 없음", "country_reason_patch_failed": "패치 실패: {error}", + "country_reason_verify_failed": "다시 읽기 실패: {error}", + "country_reason_verify_mismatch": "다시 읽은 내용이 패치한 이미지와 다름", "dash_action_firmware_lookup": "펌웨어 조회", "dash_action_ota_lookup": "OTA 조회", "dash_adb_server_blocking": "외부 ADB 서버가 실행 중이어서 기기에 연결할 수 없습니다.", @@ -548,6 +550,7 @@ "live_country_patch_transition": "{label}: {from} → {to}", "live_country_patched_flashed": "{label} 패치 후 플래싱 완료", "live_country_processing": "{path} 처리 중", + "live_country_verified": "{label} 다시 읽어 확인 완료", "live_country_warning": "경고: {error}; 펌웨어는 이미 플래싱되었으므로 재부팅을 계속합니다.", "live_country_written": "{name} 국가 코드 변경 완료: {old_code} → {new_code} (저장: {path})", "live_crypto_decrypted": "복호화 완료 ({bytes} 바이트)", diff --git a/crates/ltbox-gui/lang/ru.json b/crates/ltbox-gui/lang/ru.json index b6b988f2..d3450118 100644 --- a/crates/ltbox-gui/lang/ru.json +++ b/crates/ltbox-gui/lang/ru.json @@ -117,6 +117,8 @@ "country_reason_no_known_code": "известный код не обнаружен", "country_reason_no_lun": "для метки нет жёстко заданного LUN", "country_reason_patch_failed": "патч не удался: {error}", + "country_reason_verify_failed": "не удалось прочитать обратно: {error}", + "country_reason_verify_mismatch": "прочитанные данные не совпадают с исправленным образом", "dash_action_firmware_lookup": "Поиск прошивки", "dash_action_ota_lookup": "Поиск OTA", "dash_adb_server_blocking": "Внешний сервер ADB мешает подключению к устройству.", @@ -548,6 +550,7 @@ "live_country_patch_transition": "{label}: {from} → {to}", "live_country_patched_flashed": "Раздел пропатчен и прошит: {label}", "live_country_processing": "Обработка {path}", + "live_country_verified": "{label}: записанные данные проверены", "live_country_warning": "Предупреждение: {error}; прошивка уже записана, продолжаем перезагрузку.", "live_country_written": "{name}: {old_code} → {new_code} записано в {path}", "live_crypto_decrypted": "Расшифровано байт: {bytes}", diff --git a/crates/ltbox-gui/lang/zh.json b/crates/ltbox-gui/lang/zh.json index 0813d2a9..7d4e39f2 100644 --- a/crates/ltbox-gui/lang/zh.json +++ b/crates/ltbox-gui/lang/zh.json @@ -117,6 +117,8 @@ "country_reason_no_known_code": "未检测到已知代码", "country_reason_no_lun": "该标签没有硬编码 LUN", "country_reason_patch_failed": "修补失败:{error}", + "country_reason_verify_failed": "回读失败:{error}", + "country_reason_verify_mismatch": "回读内容与已修补镜像不一致", "dash_action_firmware_lookup": "查询固件", "dash_action_ota_lookup": "查询 OTA", "dash_adb_server_blocking": "外部 ADB 服务器正在运行,无法连接设备。", @@ -548,6 +550,7 @@ "live_country_patch_transition": "{label}:{from} → {to}", "live_country_patched_flashed": "{label} 已修补并刷写", "live_country_processing": "正在处理 {path}", + "live_country_verified": "已回读并验证 {label}", "live_country_warning": "警告:{error};固件已刷写,继续重启。", "live_country_written": "{name}:{old_code} → {new_code} 已写入 {path}", "live_crypto_decrypted": "已解密 {bytes} 字节", diff --git a/crates/ltbox-gui/src/workers/flash/country_verify.rs b/crates/ltbox-gui/src/workers/flash/country_verify.rs new file mode 100644 index 00000000..bed62e53 --- /dev/null +++ b/crates/ltbox-gui/src/workers/flash/country_verify.rs @@ -0,0 +1,262 @@ +//! Country write/readback orchestration shared by both country-change callers. + +use crate::CountryPatchProgress; +use ltbox_core::{live, tr_args}; +use std::path::Path; + +// Keep the seam at the device boundary so tests exercise the production +// ordering, comparison, and aggregate outcome without a connected device. +pub(super) trait CountrySession { + fn flash_partition( + &mut self, + label: &str, + path: &Path, + slot: u8, + lun: u8, + log: &mut Vec, + ) -> Result<(), String>; + fn dump_partition( + &mut self, + label: &str, + path: &Path, + slot: u8, + lun: u8, + log: &mut Vec, + ) -> Result<(), String>; +} + +impl CountrySession for ltbox_device::edl::EdlSession { + fn flash_partition( + &mut self, + label: &str, + path: &Path, + slot: u8, + lun: u8, + log: &mut Vec, + ) -> Result<(), String> { + Self::flash_partition(self, label, path, slot, lun, log).map_err(|e| e.to_string()) + } + fn dump_partition( + &mut self, + label: &str, + path: &Path, + slot: u8, + lun: u8, + log: &mut Vec, + ) -> Result<(), String> { + Self::dump_partition(self, label, path, slot, lun, log).map_err(|e| e.to_string()) + } +} + +pub(super) fn flash_and_verify_country( + session: &mut impl CountrySession, + label: &str, + lun: u8, + patched_path: &Path, + verify_path: &Path, + country_progress: &mut CountryPatchProgress, + log: &mut Vec, +) { + if let Err(e) = session.flash_partition(label, patched_path, 0, lun, log) { + ltbox_core::live!( + log, + "[Country] {}", + tr_args!( + "live_country_flash_failed", + label = label, + error = e.to_string() + ) + ); + country_progress.mark_failed(label, tr_args!("country_reason_flash_failed", error = e)); + } else { + live!( + log, + "[Country] {}", + tr_args!("live_country_patched_flashed", label = label) + ); + // Read the partition back: a write the programmer acknowledged + // is not yet proof the device holds the patched bytes. + let verified = session + .dump_partition(label, verify_path, 0, lun, log) + .map_err(|e| tr_args!("country_reason_verify_failed", error = e)) + .and_then(|_| { + super::files_identical(patched_path, verify_path) + .map_err(|e| tr_args!("country_reason_verify_failed", error = e)) + }); + match verified { + Ok(true) => { + live!( + log, + "[Country] {}", + tr_args!("live_country_verified", label = label) + ); + country_progress.mark_flashed(label); + } + Ok(false) => { + let reason = ltbox_core::i18n::tr("country_reason_verify_mismatch"); + ltbox_core::live!( + log, + "[Country] {}", + tr_args!( + "live_country_partition_status", + label = label, + reason = reason + ) + ); + country_progress.mark_failed(label, reason); + } + Err(reason) => { + ltbox_core::live!( + log, + "[Country] {}", + tr_args!( + "live_country_partition_status", + label = label, + reason = reason + ) + ); + country_progress.mark_failed(label, reason); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[derive(Clone, Copy, Debug)] + enum Scenario { + Match, + Mismatch, + DumpError, + FlashError, + MissingReadback, + } + + #[derive(Debug, PartialEq)] + struct Call { + operation: &'static str, + label: String, + path: PathBuf, + slot: u8, + lun: u8, + } + + struct ScriptedSession { + scenario: Scenario, + calls: Vec, + } + + impl CountrySession for ScriptedSession { + fn flash_partition( + &mut self, + label: &str, + path: &Path, + slot: u8, + lun: u8, + _: &mut Vec, + ) -> Result<(), String> { + self.calls.push(Call { + operation: "flash", + label: label.into(), + path: path.into(), + slot, + lun, + }); + assert_eq!(std::fs::read(path).unwrap(), b"patched country"); + if matches!(self.scenario, Scenario::FlashError) { + Err("write rejected".into()) + } else { + Ok(()) + } + } + + fn dump_partition( + &mut self, + label: &str, + path: &Path, + slot: u8, + lun: u8, + _: &mut Vec, + ) -> Result<(), String> { + self.calls.push(Call { + operation: "dump", + label: label.into(), + path: path.into(), + slot, + lun, + }); + match self.scenario { + Scenario::Match => std::fs::write(path, b"patched country").unwrap(), + Scenario::Mismatch => std::fs::write(path, b"old country ").unwrap(), + Scenario::DumpError => return Err("read rejected".into()), + Scenario::MissingReadback => {} + Scenario::FlashError => panic!("must not read back after a rejected write"), + } + Ok(()) + } + } + + #[test] + fn country_write_requires_successful_matching_readback() { + for scenario in [ + Scenario::Match, + Scenario::Mismatch, + Scenario::DumpError, + Scenario::FlashError, + Scenario::MissingReadback, + ] { + let dir = tempfile::tempdir().unwrap(); + let patched = dir.path().join("proinfo.patched.img"); + let readback = dir.path().join("proinfo.verify.img"); + std::fs::write(&patched, b"patched country").unwrap(); + let mut session = ScriptedSession { + scenario, + calls: Vec::new(), + }; + let mut progress = CountryPatchProgress::new(&["proinfo"]); + let mut log = Vec::new(); + flash_and_verify_country( + &mut session, + "proinfo", + 4, + &patched, + &readback, + &mut progress, + &mut log, + ); + + let mut expected = vec![Call { + operation: "flash", + label: "proinfo".into(), + path: patched, + slot: 0, + lun: 4, + }]; + if !matches!(scenario, Scenario::FlashError) { + expected.push(Call { + operation: "dump", + label: "proinfo".into(), + path: readback, + slot: 0, + lun: 4, + }); + } + assert_eq!(session.calls, expected, "{scenario:?}"); + assert_eq!( + progress.finish().is_ok(), + matches!(scenario, Scenario::Match), + "{scenario:?}" + ); + let verified = tr_args!("live_country_verified", label = "proinfo"); + assert_eq!( + log.iter() + .any(|line| line == &format!("[Country] {verified}")), + matches!(scenario, Scenario::Match), + "{scenario:?}" + ); + } + } +} diff --git a/crates/ltbox-gui/src/workers/flash/mod.rs b/crates/ltbox-gui/src/workers/flash/mod.rs index 55dc23af..a1b3025e 100644 --- a/crates/ltbox-gui/src/workers/flash/mod.rs +++ b/crates/ltbox-gui/src/workers/flash/mod.rs @@ -775,6 +775,27 @@ pub(crate) fn is_field_only_country_partition(label: &str) -> bool { matches!(label, "persist" | "proinfo") } +/// Whether two files hold the same bytes, compared in chunks so a large +/// partition image is never read whole. +fn files_identical(a: &std::path::Path, b: &std::path::Path) -> std::io::Result { + use std::io::Read; + if std::fs::metadata(a)?.len() != std::fs::metadata(b)?.len() { + return Ok(false); + } + let (mut a, mut b) = (std::fs::File::open(a)?, std::fs::File::open(b)?); + let (mut buf_a, mut buf_b) = (vec![0u8; 1 << 16], vec![0u8; 1 << 16]); + loop { + let n = a.read(&mut buf_a)?; + if n == 0 { + return Ok(true); + } + b.read_exact(&mut buf_b[..n])?; + if buf_a[..n] != buf_b[..n] { + return Ok(false); + } + } +} + /// Rewrite the device's country code over an open EDL session, in the /// partitions [`country_partitions_for`] selects. Best-effort per partition /// (logs + continues on failure). Shared by `flash_worker`'s post-flash country @@ -1029,26 +1050,15 @@ fn run_country_change( if let Some(phases) = phases { phases.mark_writes_started(); } - if let Err(e) = session.flash_partition(label, &patched_path, 0, lun, log) { - ltbox_core::live!( - log, - "[Country] {}", - tr_args!( - "live_country_flash_failed", - label = label, - error = e.to_string() - ) - ); - country_progress - .mark_failed(label, tr_args!("country_reason_flash_failed", error = e)); - } else { - live!( - log, - "[Country] {}", - tr_args!("live_country_patched_flashed", label = label) - ); - country_progress.mark_flashed(label); - } + country_verify::flash_and_verify_country( + session, + label, + lun, + &patched_path, + &work_dir.join(format!("{label}.verify.img")), + &mut country_progress, + log, + ); } else if target_code.is_some_and(|target| detected.as_deref() == Some(target)) { ltbox_core::live!( log, @@ -1096,6 +1106,7 @@ fn run_country_change( } mod country; +mod country_verify; mod full; pub(crate) mod manual; mod simple; @@ -1106,6 +1117,28 @@ pub(crate) use simple::simple_flash_worker; #[cfg(test)] mod tests { + #[test] + fn read_back_comparison_catches_any_differing_byte() { + let dir = tempfile::tempdir().unwrap(); + let (a, b, c, d) = ( + dir.path().join("a"), + dir.path().join("b"), + dir.path().join("c"), + dir.path().join("d"), + ); + let mut image = vec![0u8; (1 << 16) + 17]; + image[5] = b'F'; + std::fs::write(&a, &image).unwrap(); + std::fs::write(&b, &image).unwrap(); + *image.last_mut().unwrap() = 1; + std::fs::write(&c, &image).unwrap(); + std::fs::write(&d, &image[..10]).unwrap(); + assert!(super::files_identical(&a, &b).unwrap()); + assert!(!super::files_identical(&a, &c).unwrap()); + assert!(!super::files_identical(&a, &d).unwrap()); + assert!(super::files_identical(&a, &dir.path().join("missing")).is_err()); + } + use super::{ LenovoFirmwareDevicePolicy, ZSTD_FREE_SPACE_RESERVE_BYTES, dump_presence, lenovo_firmware_device_policy, require_firmware_loader,