//! 固件维护:串口探测、坏块规避、官方 bundle 烧录。 //! //! # 为什么不是“找镜像字节刚好可写” //! //! 旧 `scripts/flash.sh` 搜索一个 64 KiB 对齐的偏移,使 `0xFADE7` 上的那个镜像字节 //! 恰好能写。这个办法有两个问题:它只对已知坏点有效,而且每次重编译都可能把镜像 //! 挪到另一个字节上,所以要反复重编。 //! //! 这里换一个思路:**把 App 写到旧 App 之后的空闲槽位,写入后整段回读**。镜像里 //! 的每个字节都实际写进去、再读回来;坏字节只要落在镜像范围内,第一次回读就会暴露。 //! 一个槽位失败就换下一个,全部通过才切换分区表。这样不需要知道坏点的具体位置, //! 也不需要重编译。 //! //! # 安全顺序 //! //! 1. 探测并保存当前分区表(以及旧 App); //! 2. 在空闲槽位写 App,回读逐字节比较; //! 3. 只有 App 校验通过,才写 patch 后的分区表; //! 4. 分区表回读校验;失败或启动失败时,可以用备份恢复旧表,旧 App 从未被覆盖。 //! //! 串口 I/O 是阻塞的,所有命令都通过 `spawn_blocking` 进入这里;进度用 Tauri 事件 //! `maintenance-progress` 推给界面。 use std::fs::{self, File}; use std::io::Read; use std::path::{Component, Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, OnceLock}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use anyhow::{anyhow, bail, Context, Result}; use espflash::connection::{Connection, ResetAfterOperation, ResetBeforeOperation}; use espflash::flasher::Flasher; use espflash::target::ProgressCallbacks; use md5::{Digest, Md5}; use serde::{Deserialize, Serialize}; use serialport::{FlowControl, SerialPortType, UsbPortInfo}; use sha2::Sha256; use tauri::{AppHandle, Emitter, Manager, State}; use zip::ZipArchive; // --------------------------------------------------------------------------- // DTO // --------------------------------------------------------------------------- #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct PortInfo { pub port_name: String, pub label: String, pub vid: Option, pub pid: Option, pub likely_esp: bool, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct PartitionEntry { pub name: String, pub kind: u8, pub subtype: u8, pub offset: u32, pub size: u32, pub flags: u32, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct FreeRange { pub start: u32, pub size: u32, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct AppInfo { pub offset: u32, pub version: String, pub project_name: String, pub build_time: String, pub idf_version: String, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct DeviceSnapshot { pub port: String, pub chip: String, pub revision: Option, pub flash_size: u32, pub flash_size_text: String, pub mac: Option, pub features: Vec, pub secure_boot: bool, pub secure_download: bool, pub flash_encryption: bool, pub security_flags: u32, pub flash_crypt_cnt: u8, pub partition_table: Vec, pub app: Option, pub free_ranges: Vec, pub table_sha256: String, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct BundleInfo { pub path: String, pub source_kind: String, pub source_path: String, pub source_sha256: Option, pub entry_count: Option, pub name: String, pub version: String, pub chip: String, pub app_file: String, pub app_sha256: String, pub app_size: u32, pub partition_table_file: String, pub partition_table_sha256: String, pub bootloader_file: Option, pub bootloader_sha256: Option, pub app_partition_size: u32, pub flash_size: String, pub flash_size_bytes: u32, pub flash_mode: String, pub flash_frequency: String, pub notes: Option, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct SlotCandidate { pub offset: u32, pub size: u32, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct FlashPlan { pub port: String, pub device: DeviceSnapshot, pub bundle: BundleInfo, pub candidates: Vec, pub target_offset: u32, pub target_size: u32, pub app_size: u32, pub app_partition_size: u32, pub current_factory_offset: u32, pub current_factory_size: u32, pub current_table: Vec, pub patched_table: Vec, pub table_sha_before: String, pub table_sha_after: String, pub old_app_preserved: bool, pub warnings: Vec, pub can_flash: bool, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct Mismatch { pub offset: u32, pub expected: u8, pub actual: u8, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct SlotAttempt { pub offset: u32, pub size: u32, pub write_ok: bool, pub verify_ok: bool, pub mismatches: Vec, pub bad_sectors: Vec, pub error: Option, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct FlashProgress { pub phase: String, pub message: String, pub current: u64, pub total: u64, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct FlashReport { pub ok: bool, pub port: String, pub chip: String, pub mac: Option, pub bundle_name: String, pub bundle_version: String, pub target_offset: u32, pub target_size: u32, pub app_size: u32, pub app_sha256: String, pub table_sha_before: String, pub table_sha_after: String, pub attempts: Vec, pub app_verified: bool, pub table_verified: bool, pub table_restored: bool, pub needs_rollback: bool, pub boot_ok: Option, pub boot_log: String, pub backup_dir: String, pub note: Option, pub error: Option, pub started_at: String, pub finished_at: String, } // --------------------------------------------------------------------------- // 状态与命令 // --------------------------------------------------------------------------- static TEMP_DIR: OnceLock = OnceLock::new(); /// 指定读写 Flash 时用的临时目录。 /// /// 不能盲用 `%TEMP%`:Windows 上它可能被组策略/沙箱拒绝,`espflash::read_flash` /// 打开临时文件失败时会报成一句极易误导的“Error while connecting to device”。 /// Tauri 启动时指向 app cache;命令行探测指向仓库的 `target/flash-tmp`。 pub fn set_temp_dir(path: PathBuf) { let _ = TEMP_DIR.set(path); } #[derive(Default)] pub struct MaintenanceState { busy: AtomicBool, cancel: AtomicBool, } pub struct BusyGuard<'a> { state: &'a MaintenanceState, } impl Drop for BusyGuard<'_> { fn drop(&mut self) { self.state.busy.store(false, Ordering::SeqCst); } } impl MaintenanceState { fn begin(&self) -> Result> { if self .busy .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) .is_err() { bail!("另一个维护操作正在进行,请等它结束"); } self.cancel.store(false, Ordering::SeqCst); Ok(BusyGuard { state: self }) } fn cancel_requested(&self) -> bool { self.cancel.load(Ordering::SeqCst) } } async fn run_blocking(state: Arc, f: F) -> Result where T: Send + 'static, F: FnOnce(&MaintenanceState) -> Result + Send + 'static, { tauri::async_runtime::spawn_blocking(move || { let _guard = state.begin().map_err(|err| format!("{err:#}"))?; f(&state).map_err(|err| format!("{err:#}")) }) .await .map_err(|err| format!("维护任务异常结束:{err}"))? } #[tauri::command] pub fn maintenance_list_ports() -> Result, String> { list_ports().map_err(|err| format!("{err:#}")) } #[tauri::command] pub fn maintenance_load_bundle(path: String) -> Result { load_bundle(Path::new(&path)) .map(|bundle| bundle.info) .map_err(|err| format!("{err:#}")) } #[tauri::command] pub async fn maintenance_probe( state: State<'_, Arc>, port: String, ) -> Result { let state = Arc::clone(&state); run_blocking(state, move |_| { probe_and_reset(&port).map(|data| data.snapshot) }) .await } #[tauri::command] pub async fn maintenance_plan( state: State<'_, Arc>, port: String, bundle_path: String, ) -> Result { let state = Arc::clone(&state); run_blocking(state, move |_| plan_impl(&port, Path::new(&bundle_path))).await } #[tauri::command] pub async fn maintenance_flash( app: AppHandle, state: State<'_, Arc>, port: String, bundle_path: String, backup_app: Option, ) -> Result { let state = Arc::clone(&state); let app_handle = app.clone(); run_blocking(state, move |state| { flash_impl( &app_handle, state, &port, Path::new(&bundle_path), backup_app.unwrap_or(true), ) }) .await } #[tauri::command] pub async fn maintenance_restore_table( state: State<'_, Arc>, port: String, backup_dir: String, ) -> Result { let state = Arc::clone(&state); run_blocking(state, move |_| { restore_table_command(&port, Path::new(&backup_dir)) }) .await } #[tauri::command] pub fn maintenance_cancel(state: State<'_, Arc>) { state.cancel.store(true, Ordering::SeqCst); } // --------------------------------------------------------------------------- // 串口与探测 // --------------------------------------------------------------------------- fn list_ports() -> Result> { let mut ports = serialport::available_ports() .context("无法枚举串口")? .into_iter() .map(|port| { let (vid, pid, label, likely_esp) = match &port.port_type { SerialPortType::UsbPort(usb) => { let mut label = String::new(); if let Some(name) = &usb.manufacturer { label.push_str(name); } if let Some(name) = &usb.product { if !label.is_empty() { label.push(' '); } label.push_str(name); } if label.is_empty() { label = port.port_name.clone(); } let likely = usb.vid == 0x303A || label.to_ascii_lowercase().contains("espressif") || label.to_ascii_lowercase().contains("jtag"); (Some(usb.vid), Some(usb.pid), label, likely) } _ => (None, None, port.port_name.clone(), false), }; PortInfo { port_name: port.port_name, label, vid, pid, likely_esp, } }) .collect::>(); ports.sort_by(|a, b| { b.likely_esp .cmp(&a.likely_esp) .then_with(|| a.port_name.cmp(&b.port_name)) }); Ok(ports) } fn open_flasher(port: &str, verify: bool) -> Result { let info = serialport::available_ports() .context("无法枚举串口")? .into_iter() .find(|candidate| candidate.port_name == port) .ok_or_else(|| anyhow!("串口 {port} 不存在"))?; let usb = match info.port_type { SerialPortType::UsbPort(usb) => usb, _ => UsbPortInfo { vid: 0, pid: 0, serial_number: None, manufacturer: None, product: None, }, }; let mut last_error = None; for baud in [921_600u32, 460_800, 115_200] { match try_open(port, &usb, verify, baud) { Ok(flasher) => return Ok(flasher), Err(err) => last_error = Some(err), } } Err(last_error.unwrap_or_else(|| anyhow!("无法连接 {port}"))) } fn try_open(port: &str, usb: &UsbPortInfo, verify: bool, baud: u32) -> Result { let serial = serialport::new(port, 115_200) .flow_control(FlowControl::None) .open_native() .with_context(|| format!("无法打开串口 {port}"))?; let connection = Connection::new( *Box::new(serial), usb.clone(), ResetAfterOperation::HardReset, ResetBeforeOperation::DefaultReset, 115_200, ); Flasher::connect(connection, true, verify, false, None, Some(baud)) .map_err(|err| anyhow!("连接 {port} 失败({baud} baud):{err}")) } fn reset_to_app(flasher: &mut Flasher) -> Result<()> { let chip = flasher.chip(); flasher .connection() .reset_after(true, chip) .context("复位到 App 失败")?; Ok(()) } fn best_effort_reset(port: &str) { if let Ok(mut flasher) = open_flasher(port, true) { let _ = reset_to_app(&mut flasher); } } pub(crate) struct DeviceData { pub snapshot: DeviceSnapshot, pub table: Vec, pub factory_offset: u32, pub factory_size: u32, } pub fn probe_snapshot(port: &str) -> Result { probe_and_reset(port).map(|data| data.snapshot) } fn probe_and_reset(port: &str) -> Result { let mut flasher = open_flasher(port, true)?; let data = match read_device(&mut flasher, port) { Ok(data) => data, Err(err) => { let _ = reset_to_app(&mut flasher); return Err(err); } }; reset_to_app(&mut flasher)?; Ok(data) } fn read_device(flasher: &mut Flasher, port: &str) -> Result { let info = flasher.device_info().context("读取芯片信息失败")?; let security = flasher.security_info().ok(); let table = read_region(flasher, 0x8000, 0xC00) .map_err(|err| anyhow!("读取分区表失败:{err:#}"))?; let parsed = parse_partition_table(&table)?; let (factory_index, factory) = find_factory(&parsed.entries) .ok_or_else(|| anyhow!("分区表里没有 factory App 分区"))?; let _ = factory_index; let app = read_app_info(flasher, factory.offset).ok().flatten(); let flash_size = info.flash_size.size(); let free = free_ranges(&parsed.entries, flash_size); let (secure_boot, secure_download, flash_encryption, flags, crypt_cnt) = match security { Some(sec) => ( sec.flags & 0x1 != 0, sec.flags & 0x4 != 0, sec.flash_crypt_cnt != 0, sec.flags, sec.flash_crypt_cnt, ), None => (false, false, false, 0, 0), }; let revision = info .revision .map(|(major, minor)| format!("v{major}.{minor}")); let snapshot = DeviceSnapshot { port: port.to_string(), chip: format!("{:?}", info.chip).to_ascii_lowercase(), revision, flash_size, flash_size_text: info.flash_size.to_string(), mac: info.mac_address.clone(), features: info.features.clone(), secure_boot, secure_download, flash_encryption, security_flags: flags, flash_crypt_cnt: crypt_cnt, partition_table: parsed.entries.clone(), app, free_ranges: free, table_sha256: sha256_hex(&table), }; Ok(DeviceData { snapshot, table, factory_offset: factory.offset, factory_size: factory.size, }) } fn read_app_info(flasher: &mut Flasher, offset: u32) -> Result> { let data = read_region(flasher, offset, 0x200)?; if data.len() < 0x20 + 0xB0 { return Ok(None); } let desc = &data[0x20..0x20 + 0xB0]; let magic = u32::from_le_bytes(desc[0..4].try_into().unwrap()); if magic != 0xABCD_5432 { return Ok(None); } let version = cstr(&desc[0x10..0x30]); let project_name = cstr(&desc[0x30..0x50]); let time = cstr(&desc[0x50..0x60]); let date = cstr(&desc[0x60..0x70]); let idf_version = cstr(&desc[0x70..0x90]); Ok(Some(AppInfo { offset, version, project_name, build_time: format!("{date} {time}").trim().to_string(), idf_version, })) } fn cstr(bytes: &[u8]) -> String { let end = bytes.iter().position(|&byte| byte == 0).unwrap_or(bytes.len()); String::from_utf8_lossy(&bytes[..end]).trim().to_string() } // --------------------------------------------------------------------------- // 分区表 // --------------------------------------------------------------------------- struct PartitionTable { entries: Vec, md5_offset: usize, } fn parse_partition_table(data: &[u8]) -> Result { if data.len() != 0xC00 { bail!("分区表长度应为 0xC00,实际 {}", data.len()); } let mut entries = Vec::new(); for index in 0..(data.len() / 32) { let entry = &data[index * 32..(index + 1) * 32]; let magic = u16::from_le_bytes([entry[0], entry[1]]); if magic != 0x50AA && magic != 0xAA50 { break; } let kind = entry[2]; let subtype = entry[3]; let offset = u32::from_le_bytes(entry[4..8].try_into().unwrap()); let size = u32::from_le_bytes(entry[8..12].try_into().unwrap()); let name = cstr(&entry[12..28]); let flags = u32::from_le_bytes(entry[28..32].try_into().unwrap()); entries.push(PartitionEntry { name, kind, subtype, offset, size, flags, }); } if entries.is_empty() { bail!("分区表里没有有效条目"); } let entries_end = entries.len() * 32; let digest = Md5::digest(&data[..entries_end]); let mut md5_offset = None; for candidate in [entries_end, entries_end + 16, data.len().saturating_sub(16)] { if candidate + 16 <= data.len() && data[candidate..candidate + 16] == digest[..] { md5_offset = Some(candidate); break; } } let md5_offset = md5_offset.ok_or_else(|| anyhow!("分区表 MD5 位置无法确认"))?; Ok(PartitionTable { entries, md5_offset, }) } fn find_factory(entries: &[PartitionEntry]) -> Option<(usize, &PartitionEntry)> { entries .iter() .enumerate() .find(|(_, entry)| entry.kind == 0 && entry.name == "factory") .or_else(|| { entries .iter() .enumerate() .find(|(_, entry)| entry.kind == 0 && entry.subtype == 0) }) } fn free_ranges(entries: &[PartitionEntry], flash_size: u32) -> Vec { let mut sorted = entries.to_vec(); sorted.sort_by_key(|entry| entry.offset); let mut ranges = Vec::new(); let mut cursor = 0u32; for entry in &sorted { if entry.offset > cursor { push_free(&mut ranges, cursor, entry.offset); } cursor = cursor.max(entry.offset.saturating_add(entry.size)); } if cursor < flash_size { push_free(&mut ranges, cursor, flash_size); } ranges } fn push_free(ranges: &mut Vec, start: u32, end: u32) { if end <= start || start < 0x10000 { return; } ranges.push(FreeRange { start, size: end - start, }); } fn patch_partition_table( table: &[u8], factory_index: usize, new_offset: u32, new_size: u32, flash_size: u32, app_size: u32, ) -> Result<(Vec, Vec)> { let parsed = parse_partition_table(table)?; if new_offset % 0x10000 != 0 { bail!("App 偏移必须 64 KiB 对齐:0x{new_offset:x}"); } if new_size % 0x1000 != 0 { bail!("App 分区大小必须 4 KiB 对齐:0x{new_size:x}"); } if new_size < app_size { bail!("App 镜像 {app_size} 字节大于分区大小 {new_size} 字节"); } if new_offset < 0x10000 || new_offset.saturating_add(new_size) > flash_size { bail!("App 槽位 0x{new_offset:x}..0x{:x} 超出 Flash", new_offset + new_size); } for (index, entry) in parsed.entries.iter().enumerate() { if index == factory_index { continue; } if new_offset < entry.offset.saturating_add(entry.size) && new_offset.saturating_add(new_size) > entry.offset { bail!("App 槽位与分区 {} 重叠", entry.name); } } let mut patched = table.to_vec(); let entry = &mut patched[factory_index * 32..(factory_index + 1) * 32]; entry[4..8].copy_from_slice(&new_offset.to_le_bytes()); entry[8..12].copy_from_slice(&new_size.to_le_bytes()); // MD5 覆盖的是全部条目,不是截止到 factory 的那一段。 let entries_end = parsed.entries.len() * 32; let digest = Md5::digest(&patched[..entries_end]); patched[parsed.md5_offset..parsed.md5_offset + 16].copy_from_slice(&digest); let mut entries = parsed.entries; entries[factory_index].offset = new_offset; entries[factory_index].size = new_size; Ok((patched, entries)) } // --------------------------------------------------------------------------- // Bundle // --------------------------------------------------------------------------- #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct BundleManifest { format: u32, name: String, version: String, chip: String, app: String, app_sha256: String, partition_table: String, partition_table_sha256: String, #[serde(default)] bootloader: Option, #[serde(default)] bootloader_sha256: Option, app_partition_size: serde_json::Value, flash_size: String, flash_mode: String, flash_frequency: String, #[serde(default)] notes: Option, } struct BundleLoaded { info: BundleInfo, app: Vec, manifest_text: String, } fn load_bundle(path: &Path) -> Result { if path.is_dir() { return load_bundle_from_dir(path); } if path.is_file() { if path.file_name().and_then(|name| name.to_str()) == Some("bundle.json") { let parent = path .parent() .ok_or_else(|| anyhow!("bundle.json 没有父目录"))?; return load_bundle_from_dir(parent); } if is_zip_file(path)? { return load_bundle_zip(path); } bail!("不是有效的固件包(既不是 .zip,也没有 bundle.json)"); } bail!("固件包路径不存在:{}", path.display()) } fn load_bundle_from_dir(path: &Path) -> Result { let manifest_path = if path.is_dir() { path.join("bundle.json") } else if path.file_name().and_then(|name| name.to_str()) == Some("bundle.json") { path.to_path_buf() } else { bail!("请选择包含 bundle.json 的官方固件包目录"); }; let manifest_text = fs::read_to_string(&manifest_path) .with_context(|| format!("读取 {}", manifest_path.display()))?; let manifest: BundleManifest = serde_json::from_str(&manifest_text) .with_context(|| format!("解析 {}", manifest_path.display()))?; if manifest.format != 1 { bail!("不支持的 bundle 格式版本:{}", manifest.format); } let bundle_dir = manifest_path .parent() .ok_or_else(|| anyhow!("bundle.json 没有父目录"))? .to_path_buf(); let bundle_dir = bundle_dir .canonicalize() .with_context(|| format!("无法解析目录 {}", bundle_dir.display()))?; let app_path = resolve_bundle_file(&bundle_dir, &manifest.app)?; let partition_table_path = resolve_bundle_file(&bundle_dir, &manifest.partition_table)?; let app = fs::read(&app_path).with_context(|| format!("读取 {}", app_path.display()))?; let partition_table = fs::read(&partition_table_path) .with_context(|| format!("读取 {}", partition_table_path.display()))?; verify_sha256(&app, &manifest.app_sha256, "App")?; verify_sha256(&partition_table, &manifest.partition_table_sha256, "分区表")?; if app.is_empty() { bail!("App 镜像为空"); } if partition_table.len() != 0xC00 { bail!("bundle 分区表长度应为 0xC00,实际 {}", partition_table.len()); } let app_partition_size = parse_size_value(&manifest.app_partition_size)?; if app_partition_size % 0x1000 != 0 { bail!("appPartitionSize 必须 4 KiB 对齐"); } if app.len() as u32 > app_partition_size { bail!( "App 镜像 {} 字节大于 appPartitionSize {} 字节", app.len(), app_partition_size ); } let flash_size_bytes = parse_size(&manifest.flash_size)?; if app_partition_size > flash_size_bytes { bail!("appPartitionSize 大于 Flash 容量"); } let (bootloader_file, bootloader_sha256) = match (&manifest.bootloader, &manifest.bootloader_sha256) { (Some(file), Some(expected)) => { let bootloader_path = resolve_bundle_file(&bundle_dir, file)?; let bootloader = fs::read(&bootloader_path) .with_context(|| format!("读取 {}", bootloader_path.display()))?; verify_sha256(&bootloader, expected, "Bootloader")?; (Some(file.clone()), Some(expected.clone())) } (None, None) => (None, None), _ => bail!("bundle 里 bootloader 与 bootloaderSha256 必须同时出现或同时省略"), }; let path_text = bundle_dir.display().to_string(); let path_text = path_text .strip_prefix(r"\\?\") .unwrap_or(&path_text) .to_string(); let info = BundleInfo { path: path_text.clone(), source_kind: "dir".to_string(), source_path: path_text, source_sha256: None, entry_count: None, name: manifest.name, version: manifest.version, chip: manifest.chip.to_ascii_lowercase(), app_file: manifest.app, app_sha256: manifest.app_sha256.to_ascii_lowercase(), app_size: app.len() as u32, partition_table_file: manifest.partition_table, partition_table_sha256: manifest.partition_table_sha256.to_ascii_lowercase(), bootloader_file, bootloader_sha256, app_partition_size, flash_size: manifest.flash_size, flash_size_bytes, flash_mode: manifest.flash_mode, flash_frequency: manifest.flash_frequency, notes: manifest.notes, }; Ok(BundleLoaded { info, app, manifest_text, }) } fn resolve_bundle_file(dir: &Path, relative: &str) -> Result { let path = dir.join(relative); let canonical = path .canonicalize() .with_context(|| format!("找不到 {}", path.display()))?; if !canonical.starts_with(dir) { bail!("bundle 文件越界:{relative}"); } Ok(canonical) } fn is_zip_file(path: &Path) -> Result { let mut file = File::open(path).with_context(|| format!("打开 {}", path.display()))?; let mut magic = [0u8; 4]; match file.read_exact(&mut magic) { Ok(()) => Ok(magic == *b"PK\x03\x04"), Err(_) => Ok(false), } } fn load_bundle_zip(path: &Path) -> Result { let file = File::open(path).with_context(|| format!("打开 {}", path.display()))?; let mut archive = ZipArchive::new(file) .with_context(|| format!("解析 zip {}", path.display()))?; if archive.len() > 32 { bail!("固件包条目过多({} > 32)", archive.len()); } let temp = temp_bundle_dir(); fs::create_dir_all(&temp).with_context(|| format!("创建临时目录 {}", temp.display()))?; let result = (|| -> Result { let manifest_bytes = read_zip_entry(&mut archive, "bundle.json", 64 * 1024)?; let manifest_text = String::from_utf8(manifest_bytes) .context("固件包里的 bundle.json 不是 UTF-8")?; let manifest: BundleManifest = serde_json::from_str(&manifest_text) .context("解析固件包里的 bundle.json 失败")?; if manifest.app == "bundle.json" || manifest.partition_table == "bundle.json" || manifest.bootloader.as_deref() == Some("bundle.json") { bail!("固件包内文件名冲突:bundle.json"); } let app_partition_size = parse_size_value(&manifest.app_partition_size)?; let app = read_zip_entry(&mut archive, &manifest.app, app_partition_size as u64)?; let partition_table = read_zip_entry(&mut archive, &manifest.partition_table, 0xC00)?; if let (Some(bootloader), Some(_)) = (&manifest.bootloader, &manifest.bootloader_sha256) { let bootloader_bytes = read_zip_entry(&mut archive, bootloader, 1024 * 1024)?; fs::write(temp.join(bootloader), bootloader_bytes) .with_context(|| format!("写入临时文件 {bootloader}"))?; } fs::write(temp.join("bundle.json"), &manifest_text) .context("写入临时 bundle.json 失败")?; fs::write(temp.join(&manifest.app), &app) .with_context(|| format!("写入临时文件 {}", manifest.app))?; fs::write(temp.join(&manifest.partition_table), &partition_table) .with_context(|| format!("写入临时文件 {}", manifest.partition_table))?; let mut loaded = load_bundle_from_dir(&temp)?; let source = display_path(path); loaded.info.path = source.clone(); loaded.info.source_kind = "zip".to_string(); loaded.info.source_path = source; loaded.info.source_sha256 = Some(sha256_file(path)?); loaded.info.entry_count = Some(archive.len() as u32); Ok(loaded) })(); let _ = fs::remove_dir_all(&temp); result } fn read_zip_entry(archive: &mut ZipArchive, name: &str, max: u64) -> Result> { if !safe_zip_name(name) { bail!("固件包内有非法路径:{name}"); } let mut entry = archive .by_name(name) .with_context(|| format!("固件包缺少 {name}"))?; if entry.size() > max { bail!("固件包条目 {name} 过大({} 字节)", entry.size()); } let mut data = Vec::with_capacity(entry.size().min(max) as usize); entry .read_to_end(&mut data) .with_context(|| format!("读取固件包条目 {name} 失败"))?; if data.len() as u64 > max { bail!("固件包条目 {name} 解压后过大"); } Ok(data) } fn safe_zip_name(name: &str) -> bool { if name.is_empty() || name.len() > 128 || name.contains('/') || name.contains('\\') || name.contains(':') { return false; } let mut components = Path::new(name).components(); matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none() } fn temp_bundle_dir() -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_nanos()) .unwrap_or(0); let base = TEMP_DIR .get() .cloned() .unwrap_or_else(std::env::temp_dir); let _ = fs::create_dir_all(&base); base.join(format!( "cam-desktop-bundle-{}-{nanos}", std::process::id() )) } fn display_path(path: &Path) -> String { let text = path.display().to_string(); text.strip_prefix(r"\\?\").unwrap_or(&text).to_string() } fn sha256_file(path: &Path) -> Result { let mut file = File::open(path).with_context(|| format!("打开 {}", path.display()))?; let mut hasher = Sha256::new(); // 64 KiB 在堆上;1 MiB 的栈数组在 Windows 主线程(默认 1 MiB 栈)会直接溢出。 let mut buffer = vec![0u8; 64 * 1024]; loop { let count = file .read(&mut buffer) .with_context(|| format!("读取 {}", path.display()))?; if count == 0 { break; } hasher.update(&buffer[..count]); } Ok(hex::encode(hasher.finalize())) } fn verify_sha256(data: &[u8], expected: &str, label: &str) -> Result<()> { let actual = sha256_hex(data); if !actual.eq_ignore_ascii_case(expected.trim()) { bail!("{label} SHA256 不匹配:期望 {expected},实际 {actual}"); } Ok(()) } fn parse_size_value(value: &serde_json::Value) -> Result { match value { serde_json::Value::String(text) => parse_size(text), serde_json::Value::Number(number) => number .as_u64() .and_then(|value| u32::try_from(value).ok()) .ok_or_else(|| anyhow!("大小不是有效的 u32")), _ => bail!("大小必须是字符串或数字"), } } fn parse_size(text: &str) -> Result { let text = text.trim().to_ascii_lowercase(); if let Some(hex) = text.strip_prefix("0x") { return u32::from_str_radix(hex, 16).with_context(|| format!("无效大小 {text}")); } if let Some(value) = text.strip_suffix("mb") { return value .trim() .parse::() .map(|value| value.saturating_mul(1024 * 1024)) .with_context(|| format!("无效大小 {text}")); } if let Some(value) = text.strip_suffix("kb") { return value .trim() .parse::() .map(|value| value.saturating_mul(1024)) .with_context(|| format!("无效大小 {text}")); } text.parse::().with_context(|| format!("无效大小 {text}")) } fn check_bundle_against_device(bundle: &BundleLoaded, device: &DeviceSnapshot) -> Result<()> { if bundle.info.chip != device.chip { bail!( "固件包是 {},当前设备是 {}", bundle.info.chip, device.chip ); } if bundle.info.flash_size_bytes != device.flash_size { bail!( "固件包要求 {} Flash,当前设备是 {}", bundle.info.flash_size, device.flash_size_text ); } if device.secure_boot || device.secure_download { bail!("设备启用了 Secure Boot / Secure Download,本流程不适用"); } if device.flash_encryption { bail!("设备启用了 Flash Encryption,本流程不适用"); } if bundle.info.app_size as u32 > bundle.info.app_partition_size { bail!("App 镜像大于 appPartitionSize"); } Ok(()) } // --------------------------------------------------------------------------- // 规划 // --------------------------------------------------------------------------- pub fn inspect_bundle(bundle_path: &Path) -> Result { load_bundle(bundle_path).map(|bundle| bundle.info) } pub fn plan_snapshot(port: &str, bundle_path: &Path) -> Result { plan_impl(port, bundle_path) } fn plan_impl(port: &str, bundle_path: &Path) -> Result { let device = probe_and_reset(port)?; let bundle = load_bundle(bundle_path)?; check_bundle_against_device(&bundle, &device.snapshot)?; let (factory_index, _) = find_factory(&device.snapshot.partition_table) .ok_or_else(|| anyhow!("分区表里没有 factory App 分区"))?; let candidates = choose_candidates( &device.snapshot.partition_table, device.factory_offset, device.factory_size, bundle.info.app_partition_size, device.snapshot.flash_size, )?; let first = candidates .first() .cloned() .ok_or_else(|| anyhow!("没有可用的空闲 App 槽位"))?; let (patched, patched_entries) = patch_partition_table( &device.table, factory_index, first.offset, first.size, device.snapshot.flash_size, bundle.info.app_size, )?; let mut warnings = Vec::new(); if first.size != device.factory_size { warnings.push(format!( "factory 分区大小将从 0x{:x} 调整为 0x{:x}", device.factory_size, first.size )); } if candidates.len() > 1 { warnings.push(format!( "第一个槽位校验失败时会自动尝试其余 {} 个候选槽位", candidates.len() - 1 )); } if bundle.info.bootloader_file.is_some() { warnings.push("固件包带 bootloader,但本流程默认不重写 0x0 的 bootloader".to_string()); } if bundle.info.flash_mode != "dio" { warnings.push(format!( "固件包声明 Flash 模式 {},本板当前为 DIO", bundle.info.flash_mode )); } if bundle.info.flash_frequency != "40mhz" { warnings.push(format!( "固件包声明 Flash 频率 {},本板当前为 40 MHz", bundle.info.flash_frequency )); } if candidates.len() > 1 { warnings.push("候选槽位彼此重叠是允许的:分区表只有在某个槽位校验通过后才会切换".to_string()); } let plan = FlashPlan { port: port.to_string(), device: device.snapshot.clone(), bundle: bundle.info.clone(), candidates: candidates.clone(), target_offset: first.offset, target_size: first.size, app_size: bundle.info.app_size, app_partition_size: bundle.info.app_partition_size, current_factory_offset: device.factory_offset, current_factory_size: device.factory_size, current_table: device.snapshot.partition_table.clone(), patched_table: patched_entries, table_sha_before: sha256_hex(&device.table), table_sha_after: sha256_hex(&patched), old_app_preserved: true, warnings, can_flash: true, }; Ok(plan) } /// 只做“写入 + 回读”,不碰分区表。用于实机验证坏块检测链路,设备仍从旧 App 启动。 pub fn write_test(port: &str, bundle_path: &Path, offset: u32) -> Result { if offset % 0x10000 != 0 { bail!("测试偏移必须 64 KiB 对齐:0x{offset:x}"); } let device = probe_and_reset(port)?; let bundle = load_bundle(bundle_path)?; let flash_size = device.snapshot.flash_size; let app_size = bundle.info.app_size; if offset.saturating_add(app_size) > flash_size { bail!("测试区超出 Flash"); } let fits = free_ranges(&device.snapshot.partition_table, flash_size) .iter() .any(|range| { offset >= range.start && offset.saturating_add(app_size) <= range.start.saturating_add(range.size) }); if !fits { bail!("0x{offset:x} 不在空闲区间;拒绝覆盖已有分区"); } let candidate = SlotCandidate { offset, size: bundle.info.app_partition_size, }; Ok(try_slot(None, port, &bundle.app, &candidate)) } fn choose_candidates( entries: &[PartitionEntry], factory_offset: u32, factory_size: u32, partition_size: u32, flash_size: u32, ) -> Result> { if partition_size == 0 { bail!("appPartitionSize 不能为 0"); } let factory_end = factory_offset.saturating_add(factory_size); let mut all = Vec::new(); for range in free_ranges(entries, flash_size) { let mut start = align_up(range.start, 0x10000); let end = range.start.saturating_add(range.size); while start.saturating_add(partition_size) <= end { let overlaps_old_app = start < factory_end && start.saturating_add(partition_size) > factory_offset; if !overlaps_old_app { all.push(SlotCandidate { offset: start, size: partition_size, }); } start = start.saturating_add(0x10000); } } if all.is_empty() { bail!("没有足够的空闲 Flash 放置 0x{partition_size:x} 的 App 分区"); } all.sort_by_key(|candidate| candidate.offset); let mut preferred = Vec::new(); preferred.extend(all.iter().filter(|candidate| candidate.offset >= 0x200000).cloned()); preferred.extend( all.iter() .filter(|candidate| candidate.offset >= 0x100000 && candidate.offset < 0x200000) .cloned(), ); preferred.extend(all.iter().filter(|candidate| candidate.offset < 0x100000).cloned()); preferred.dedup_by_key(|candidate| candidate.offset); preferred.truncate(4); Ok(preferred) } fn align_up(value: u32, align: u32) -> u32 { value.saturating_add(align - 1) & !(align - 1) } // --------------------------------------------------------------------------- // 烧录 // --------------------------------------------------------------------------- fn flash_impl( app: &AppHandle, state: &MaintenanceState, port: &str, bundle_path: &Path, backup_app: bool, ) -> Result { let started_at = now_iso(); let device = probe_and_reset(port)?; let bundle = load_bundle(bundle_path)?; check_bundle_against_device(&bundle, &device.snapshot)?; let (factory_index, _) = find_factory(&device.snapshot.partition_table) .ok_or_else(|| anyhow!("分区表里没有 factory App 分区"))?; let candidates = choose_candidates( &device.snapshot.partition_table, device.factory_offset, device.factory_size, bundle.info.app_partition_size, device.snapshot.flash_size, )?; let backup_dir = match prepare_backup(app, &device, &bundle, backup_app) { Ok(dir) => dir, Err(err) => { best_effort_reset(port); return Err(err); } }; let mut report = FlashReport { ok: false, port: port.to_string(), chip: device.snapshot.chip.clone(), mac: device.snapshot.mac.clone(), bundle_name: bundle.info.name.clone(), bundle_version: bundle.info.version.clone(), target_offset: 0, target_size: 0, app_size: bundle.info.app_size, app_sha256: bundle.info.app_sha256.clone(), table_sha_before: device.snapshot.table_sha256.clone(), table_sha_after: String::new(), attempts: Vec::new(), app_verified: false, table_verified: false, table_restored: false, needs_rollback: false, boot_ok: None, boot_log: String::new(), backup_dir: backup_dir.display().to_string(), note: None, error: None, started_at, finished_at: String::new(), }; let mut chosen: Option = None; for candidate in &candidates { if state.cancel_requested() { report.error = Some("已取消".to_string()); break; } emit( app, "写入应用", &format!("尝试槽位 0x{:x}", candidate.offset), 0, bundle.info.app_size as u64, ); let attempt = try_slot(Some(app), port, &bundle.app, candidate); let ok = attempt.verify_ok; report.attempts.push(attempt); if ok { chosen = Some(candidate.clone()); break; } } let Some(chosen) = chosen else { report.error = Some("所有候选槽位都没有通过写入/回读校验".to_string()); report.finished_at = now_iso(); save_report(&backup_dir, &report)?; best_effort_reset(port); return Ok(report); }; report.target_offset = chosen.offset; report.target_size = chosen.size; report.app_verified = true; if state.cancel_requested() { report.error = Some("已取消".to_string()); report.finished_at = now_iso(); save_report(&backup_dir, &report)?; best_effort_reset(port); return Ok(report); } let (patched, _patched_entries) = patch_partition_table( &device.table, factory_index, chosen.offset, chosen.size, device.snapshot.flash_size, bundle.info.app_size, )?; report.table_sha_after = sha256_hex(&patched); emit(app, "写入分区表", "切换 factory 到新槽位", 0, patched.len() as u64); let table_write_result = (|| -> Result<()> { let mut flasher = open_flasher(port, true)?; let mut progress = EmitProgress::new(Some(app.clone()), "写入分区表"); flasher.write_bin_to_flash(0x8000, &patched, &mut progress)?; Ok(()) })(); if let Err(err) = table_write_result { let restored = restore_table(port, &device.table).is_ok(); report.table_restored = restored; report.needs_rollback = !restored; report.error = Some(format!( "分区表写入失败:{err:#};{}", if restored { "已恢复旧分区表" } else { "旧表恢复失败,请用备份手动恢复" } )); report.finished_at = now_iso(); save_report(&backup_dir, &report)?; best_effort_reset(port); return Ok(report); } let table_check = (|| -> Result> { let mut flasher = open_flasher(port, true)?; let data = read_region(&mut flasher, 0x8000, 0xC00)?; let chip = flasher.chip(); let _ = flasher.connection().reset_after(true, chip); Ok(data) })(); let table_ok = match table_check { Ok(data) => data == patched, Err(err) => { report.error = Some(format!("分区表回读失败:{err:#}")); false } }; if !table_ok { let restored = restore_table(port, &device.table).is_ok(); report.table_restored = restored; report.needs_rollback = !restored; report.error = Some(format!( "分区表回读不一致;{}", if restored { "已恢复旧分区表" } else { "旧表恢复失败,请用备份手动恢复" } )); report.finished_at = now_iso(); save_report(&backup_dir, &report)?; best_effort_reset(port); return Ok(report); } report.table_verified = true; let boot_log = capture_boot_log(port).unwrap_or_default(); let lowered = boot_log.to_ascii_lowercase(); let explicit_failure = lowered.contains("invalid header") || lowered.contains("invalid app") || lowered.contains("image invalid") || lowered.contains("panic") || lowered.contains("boot failed") || lowered.contains("checksum error") || lowered.contains("image checksum"); let explicit_success = lowered.contains("loaded app from partition at offset") || lowered.contains("camera") || lowered.contains("wifi") || lowered.contains("app_init"); let boot_ok = if explicit_failure { Some(false) } else if explicit_success { Some(true) } else { None }; report.boot_ok = boot_ok; report.boot_log = boot_log; report.ok = report.app_verified && report.table_verified && boot_ok != Some(false); report.needs_rollback = !report.ok; if !report.ok { report.error = Some(format!( "分区表已切换,但启动日志显示异常;旧 App 仍在 0x{:x},可恢复旧分区表", device.factory_offset )); } else if boot_ok.is_none() { report.note = Some(format!( "写入与分区表校验通过;启动日志未捕获到明确成功标志(旧 App 仍保留在 0x{:x})", device.factory_offset )); } report.finished_at = now_iso(); save_report(&backup_dir, &report)?; Ok(report) } fn try_slot( app: Option<&AppHandle>, port: &str, image: &[u8], candidate: &SlotCandidate, ) -> SlotAttempt { let mut attempt = SlotAttempt { offset: candidate.offset, size: candidate.size, write_ok: false, verify_ok: false, mismatches: Vec::new(), bad_sectors: Vec::new(), error: None, }; let write_result = (|| -> Result<()> { let mut flasher = open_flasher(port, true)?; let mut progress = EmitProgress::new(app.cloned(), &format!("写入 0x{:x}", candidate.offset)); flasher.write_bin_to_flash(candidate.offset, image, &mut progress)?; Ok(()) })(); if let Err(err) = write_result { attempt.error = Some(format!("{err:#}")); return attempt; } attempt.write_ok = true; let read_result = (|| -> Result> { let mut flasher = open_flasher(port, true)?; let data = read_region(&mut flasher, candidate.offset, image.len() as u32)?; let chip = flasher.chip(); let _ = flasher.connection().reset_after(true, chip); Ok(data) })(); match read_result { Ok(data) => { let mut bad_sectors = Vec::new(); for (index, (expected, actual)) in image.iter().zip(data.iter()).enumerate() { if expected != actual { let absolute = candidate.offset.saturating_add(index as u32); if attempt.mismatches.len() < 32 { attempt.mismatches.push(Mismatch { offset: absolute, expected: *expected, actual: *actual, }); } let sector = (absolute / 0x1000) * 0x1000; if !bad_sectors.contains(§or) { bad_sectors.push(sector); } } } if data.len() != image.len() && attempt.mismatches.len() < 32 { attempt.mismatches.push(Mismatch { offset: candidate.offset.saturating_add(data.len() as u32), expected: 0, actual: 0, }); } attempt.verify_ok = data.len() == image.len() && attempt.mismatches.is_empty(); attempt.bad_sectors = bad_sectors; } Err(err) => { attempt.error = Some(format!("回读失败:{err:#}")); } } attempt } fn restore_table_command(port: &str, backup_dir: &Path) -> Result { let path = backup_dir.join("partition-table.bin"); let table = fs::read(&path).with_context(|| format!("读取 {}", path.display()))?; if table.len() != 0xC00 { bail!("备份分区表长度不是 0xC00"); } parse_partition_table(&table)?; restore_table(port, &table)?; Ok(format!("已恢复 {}", path.display())) } fn restore_table(port: &str, table: &[u8]) -> Result<()> { let mut flasher = open_flasher(port, true)?; let mut progress = EmitProgress::new_noop(); flasher.write_bin_to_flash(0x8000, table, &mut progress)?; Ok(()) } fn prepare_backup( app: &AppHandle, device: &DeviceData, bundle: &BundleLoaded, backup_app: bool, ) -> Result { let base = app .path() .app_data_dir() .unwrap_or_else(|_| std::env::temp_dir()); let mac = device .snapshot .mac .clone() .unwrap_or_else(|| "unknown".to_string()) .replace(':', ""); let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S").to_string(); let dir = base.join("backups").join(format!("{mac}-{stamp}")); fs::create_dir_all(&dir).with_context(|| format!("创建备份目录 {}", dir.display()))?; fs::write(dir.join("partition-table.bin"), &device.table) .with_context(|| "备份分区表失败")?; if backup_app { emit(app, "备份", "读取旧 App 分区", 0, device.factory_size as u64); let mut flasher = open_flasher(&device.snapshot.port, true)?; let old_app = read_region(&mut flasher, device.factory_offset, device.factory_size)?; let chip = flasher.chip(); let _ = flasher.connection().reset_after(true, chip); fs::write(dir.join("app-old.bin"), old_app).with_context(|| "备份旧 App 失败")?; } fs::write(dir.join("bundle.json"), &bundle.manifest_text) .with_context(|| "备份 bundle.json 失败")?; Ok(dir) } fn save_report(dir: &Path, report: &FlashReport) -> Result<()> { let json = serde_json::to_string_pretty(report)?; fs::write(dir.join("report.json"), json).with_context(|| "写入 report.json 失败")?; Ok(()) } fn capture_boot_log(port: &str) -> Result { std::thread::sleep(Duration::from_millis(300)); for _ in 0..12 { match serialport::new(port, 115_200) .timeout(Duration::from_millis(100)) .open() { Ok(mut serial) => { let start = Instant::now(); let mut data = Vec::new(); while start.elapsed() < Duration::from_secs(6) { let mut buffer = [0u8; 512]; match serial.read(&mut buffer) { Ok(0) => {} Ok(count) => data.extend_from_slice(&buffer[..count]), Err(_) => {} } } return Ok(String::from_utf8_lossy(&data).to_string()); } Err(_) => std::thread::sleep(Duration::from_millis(250)), } } Ok(String::new()) } fn read_region(flasher: &mut Flasher, offset: u32, size: u32) -> Result> { // espflash 的 read_flash 以 block_size 为粒度;0xC00 这种不足一个 block 的长度 // 在某些版本上会读多/读少。统一向上取整到 4 KiB,读回后再截断。 let read_size = align_up(size, 0x1000); let path = temp_path(); let result = flasher.read_flash(offset, read_size, 0x1000, 64, path.clone()); let data = fs::read(&path); let _ = fs::remove_file(&path); result.with_context(|| format!("读取 Flash 0x{offset:x}..0x{:x} 失败", offset.saturating_add(read_size)))?; let mut data = data.with_context(|| format!("读取临时文件 {} 失败", path.display()))?; if data.len() < size as usize { bail!( "读取 Flash 0x{offset:x} 返回 {} 字节,少于请求的 {size} 字节", data.len() ); } data.truncate(size as usize); Ok(data) } fn temp_path() -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_nanos()) .unwrap_or(0); let base = TEMP_DIR .get() .cloned() .unwrap_or_else(std::env::temp_dir); let _ = fs::create_dir_all(&base); base.join(format!( "cam-desktop-flash-{}-{nanos}.bin", std::process::id() )) } fn sha256_hex(data: &[u8]) -> String { hex::encode(Sha256::digest(data)) } fn now_iso() -> String { chrono::Local::now().to_rfc3339() } fn emit(app: &AppHandle, phase: &str, message: &str, current: u64, total: u64) { let _ = app.emit( "maintenance-progress", FlashProgress { phase: phase.to_string(), message: message.to_string(), current, total, }, ); } struct EmitProgress { app: Option, phase: String, total: u64, current: u64, last_emit: Instant, } impl EmitProgress { fn new(app: Option, phase: &str) -> Self { Self { app, phase: phase.to_string(), total: 0, current: 0, last_emit: Instant::now() - Duration::from_secs(1), } } fn new_noop() -> Self { Self::new(None, "") } fn emit(&mut self, force: bool) { if !force && self.last_emit.elapsed() < Duration::from_millis(120) { return; } self.last_emit = Instant::now(); if let Some(app) = &self.app { emit( app, &self.phase, &format!("{} / {}", human_size(self.current), human_size(self.total)), self.current, self.total, ); } } } impl ProgressCallbacks for EmitProgress { fn init(&mut self, _addr: u32, total: usize) { self.total = total as u64; self.current = 0; self.emit(true); } fn update(&mut self, current: usize) { self.current = current as u64; self.emit(false); } fn verifying(&mut self) { if let Some(app) = &self.app { emit(app, &self.phase, "回读校验中", self.current, self.total); } } fn finish(&mut self, _skipped: bool) { self.current = self.total; self.emit(true); } } fn human_size(value: u64) -> String { if value >= 1024 * 1024 { format!("{:.1} MiB", value as f64 / (1024.0 * 1024.0)) } else if value >= 1024 { format!("{:.1} KiB", value as f64 / 1024.0) } else { format!("{value} B") } } #[cfg(test)] mod tests { use super::*; use md5::{Digest, Md5}; fn entry(name: &str, kind: u8, subtype: u8, offset: u32, size: u32) -> [u8; 32] { let mut entry = [0u8; 32]; entry[0] = 0xAA; entry[1] = 0x50; entry[2] = kind; entry[3] = subtype; entry[4..8].copy_from_slice(&offset.to_le_bytes()); entry[8..12].copy_from_slice(&size.to_le_bytes()); let label = name.as_bytes(); let label = &label[..label.len().min(16)]; entry[12..12 + label.len()].copy_from_slice(label); entry } fn table(entries: &[(&str, u8, u8, u32, u32)]) -> Vec { let mut data = vec![0xFFu8; 0xC00]; for (index, (name, kind, subtype, offset, size)) in entries.iter().enumerate() { data[index * 32..(index + 1) * 32] .copy_from_slice(&entry(name, *kind, *subtype, *offset, *size)); } let end = entries.len() * 32; let digest = Md5::digest(&data[..end]); data[end..end + 16].copy_from_slice(&digest); data } fn real_layout() -> Vec { table(&[ ("nvs", 1, 2, 0x9000, 0x6000), ("phy_init", 1, 1, 0xF000, 0x1000), ("factory", 0, 0, 0x70000, 0x120000), ]) } #[test] fn parses_the_real_layout() { let parsed = parse_partition_table(&real_layout()).unwrap(); assert_eq!(parsed.entries.len(), 3); assert_eq!(parsed.entries[2].name, "factory"); assert_eq!(parsed.entries[2].offset, 0x70000); assert_eq!(parsed.entries[2].size, 0x120000); assert_eq!(parsed.md5_offset, 3 * 32); } #[test] fn patches_factory_and_recomputes_md5() { let data = real_layout(); let (patched, entries) = patch_partition_table( &data, 2, 0x200000, 0x140000, 16 * 1024 * 1024, 0x1172D0, ) .unwrap(); assert_eq!(entries[2].offset, 0x200000); assert_eq!(entries[2].size, 0x140000); // 重新解析会校验 MD5;MD5 不对时这里会失败。 let reparsed = parse_partition_table(&patched).unwrap(); assert_eq!(reparsed.entries[2].offset, 0x200000); assert_eq!(reparsed.entries[2].size, 0x140000); assert_eq!(reparsed.entries[0].offset, 0x9000); assert_eq!(reparsed.entries[1].offset, 0xF000); } #[test] fn rejects_overlapping_slots() { let data = real_layout(); let result = patch_partition_table( &data, 2, 0x8000, 0x140000, 16 * 1024 * 1024, 0x1172D0, ); assert!(result.is_err()); } #[test] fn picks_a_slot_after_the_old_app() { let parsed = parse_partition_table(&real_layout()).unwrap(); let candidates = choose_candidates( &parsed.entries, 0x70000, 0x120000, 0x140000, 16 * 1024 * 1024, ) .unwrap(); assert_eq!(candidates[0].offset, 0x200000); assert_eq!(candidates[0].size, 0x140000); assert!(candidates.iter().all(|candidate| candidate.offset >= 0x200000)); assert!(candidates.iter().all(|candidate| { candidate.offset + candidate.size <= 16 * 1024 * 1024 })); } #[test] fn parses_sizes() { assert_eq!(parse_size("0x140000").unwrap(), 0x140000); assert_eq!(parse_size("16mb").unwrap(), 16 * 1024 * 1024); assert_eq!(parse_size("1kb").unwrap(), 1024); assert_eq!(parse_size("1234").unwrap(), 1234); assert!(parse_size("nope").is_err()); } #[test] fn free_ranges_skip_bootloader_and_nvs() { let parsed = parse_partition_table(&real_layout()).unwrap(); let ranges = free_ranges(&parsed.entries, 16 * 1024 * 1024); assert_eq!(ranges[0].start, 0x10000); assert_eq!(ranges[0].size, 0x60000); assert_eq!(ranges[1].start, 0x190000); assert_eq!(ranges[1].size, 16 * 1024 * 1024 - 0x190000); } #[test] fn rejects_unsafe_zip_entry_names() { assert!(safe_zip_name("bundle.json")); assert!(safe_zip_name("esp32s3-app.bin")); assert!(!safe_zip_name("")); assert!(!safe_zip_name("../bundle.json")); assert!(!safe_zip_name("a/b.bin")); assert!(!safe_zip_name("a\\b.bin")); assert!(!safe_zip_name("C:bundle.json")); assert!(!safe_zip_name("..")); } }