- feat(client): 界面换成墨黑蓝底 + 单支红的矩形语言,切角全部去掉,仅装甲条保留平行四边形 - feat(client): 自托管 HarmonyOS Sans SC 与 JetBrains Mono,中文与拉丁数字逐字回退 - feat(client): 新增启动序列、首启门、用户端 HUD、裁判端控制台、设置抽屉与固件维护页 - feat(client): 裁判端中缝与下方控制台可拖,鼠标落在哪一路下方就看哪一路 - feat(client): 设置抽屉常驻实时预览,与画面共用同一份准星绘制代码 - feat(client): 动效改用 Material 3 的缓动曲线与时长,逐条替换掉自造的贝塞尔 - feat(version): 版本号与 CHANGELOG 由 scripts/version.mjs 从 git 历史算出,不再手写 - feat(firmware): Hello 报文携带语义化版本、构建号与自报协议号,负载向后兼容 - feat(client): 固件版本显示在绑定列表与裁判端每一格;协议不匹配时给出具体原因 - feat(firmware): 固件包版本与客户端同源,都从 version.json 读 - fix(client): getCurrentWindow() 在没有 Tauri 注入时同步抛异常,浏览器预览整棵树被卸载 - fix(client): 启动序列的淡出 effect 清掉了自己刚设的 setTimeout,界面永远停在启动页 - fix(client): 设置抽屉左侧导航的锚点没挂到 section 上,且用错了坐标系 - fix(protocol): Hello 改用宽松帧头解析,让协议不匹配的板子能被解释而不是静默消失 - fix(build): Windows 上 bindgen 加载不了 libclang 的同目录依赖,构建脚本补上搜索路径 - fix(build): esp-idf-sys 在长路径下拒绝构建,固件构建改用短的目标目录 - build(client): 队标图标,含光学尺寸分级与手工拼的多尺寸 ICO - build(ci): 守门 job 增加版本一致性检查,产物名改为 rmic-2026-* - docs: 补充界面语言、版本机制与协议通告布局的说明
1945 lines
61 KiB
Rust
1945 lines
61 KiB
Rust
//! 固件维护:串口探测、坏块规避、官方 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<u16>,
|
||
pub pid: Option<u16>,
|
||
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<String>,
|
||
pub flash_size: u32,
|
||
pub flash_size_text: String,
|
||
pub mac: Option<String>,
|
||
pub features: Vec<String>,
|
||
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<PartitionEntry>,
|
||
pub app: Option<AppInfo>,
|
||
pub free_ranges: Vec<FreeRange>,
|
||
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<String>,
|
||
pub entry_count: Option<u32>,
|
||
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<String>,
|
||
pub bootloader_sha256: Option<String>,
|
||
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<String>,
|
||
}
|
||
|
||
#[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<SlotCandidate>,
|
||
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<PartitionEntry>,
|
||
pub patched_table: Vec<PartitionEntry>,
|
||
pub table_sha_before: String,
|
||
pub table_sha_after: String,
|
||
pub old_app_preserved: bool,
|
||
pub warnings: Vec<String>,
|
||
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<Mismatch>,
|
||
pub bad_sectors: Vec<u32>,
|
||
pub error: Option<String>,
|
||
}
|
||
|
||
#[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<String>,
|
||
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<SlotAttempt>,
|
||
pub app_verified: bool,
|
||
pub table_verified: bool,
|
||
pub table_restored: bool,
|
||
pub needs_rollback: bool,
|
||
pub boot_ok: Option<bool>,
|
||
pub boot_log: String,
|
||
pub backup_dir: String,
|
||
pub note: Option<String>,
|
||
pub error: Option<String>,
|
||
pub started_at: String,
|
||
pub finished_at: String,
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 状态与命令
|
||
// ---------------------------------------------------------------------------
|
||
|
||
static TEMP_DIR: OnceLock<PathBuf> = 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<BusyGuard<'_>> {
|
||
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<T, F>(state: Arc<MaintenanceState>, f: F) -> Result<T, String>
|
||
where
|
||
T: Send + 'static,
|
||
F: FnOnce(&MaintenanceState) -> Result<T> + 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<Vec<PortInfo>, String> {
|
||
list_ports().map_err(|err| format!("{err:#}"))
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn maintenance_load_bundle(path: String) -> Result<BundleInfo, String> {
|
||
load_bundle(Path::new(&path))
|
||
.map(|bundle| bundle.info)
|
||
.map_err(|err| format!("{err:#}"))
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn maintenance_probe(
|
||
state: State<'_, Arc<MaintenanceState>>,
|
||
port: String,
|
||
) -> Result<DeviceSnapshot, String> {
|
||
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<MaintenanceState>>,
|
||
port: String,
|
||
bundle_path: String,
|
||
) -> Result<FlashPlan, String> {
|
||
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<MaintenanceState>>,
|
||
port: String,
|
||
bundle_path: String,
|
||
backup_app: Option<bool>,
|
||
) -> Result<FlashReport, String> {
|
||
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<MaintenanceState>>,
|
||
port: String,
|
||
backup_dir: String,
|
||
) -> Result<String, String> {
|
||
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<MaintenanceState>>) {
|
||
state.cancel.store(true, Ordering::SeqCst);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 串口与探测
|
||
// ---------------------------------------------------------------------------
|
||
|
||
fn list_ports() -> Result<Vec<PortInfo>> {
|
||
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::<Vec<_>>();
|
||
|
||
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<Flasher> {
|
||
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<Flasher> {
|
||
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<u8>,
|
||
pub factory_offset: u32,
|
||
pub factory_size: u32,
|
||
}
|
||
|
||
pub fn probe_snapshot(port: &str) -> Result<DeviceSnapshot> {
|
||
probe_and_reset(port).map(|data| data.snapshot)
|
||
}
|
||
|
||
fn probe_and_reset(port: &str) -> Result<DeviceData> {
|
||
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<DeviceData> {
|
||
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<Option<AppInfo>> {
|
||
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<PartitionEntry>,
|
||
md5_offset: usize,
|
||
}
|
||
|
||
fn parse_partition_table(data: &[u8]) -> Result<PartitionTable> {
|
||
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<FreeRange> {
|
||
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<FreeRange>, 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<u8>, Vec<PartitionEntry>)> {
|
||
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<String>,
|
||
#[serde(default)]
|
||
bootloader_sha256: Option<String>,
|
||
app_partition_size: serde_json::Value,
|
||
flash_size: String,
|
||
flash_mode: String,
|
||
flash_frequency: String,
|
||
#[serde(default)]
|
||
notes: Option<String>,
|
||
}
|
||
|
||
struct BundleLoaded {
|
||
info: BundleInfo,
|
||
app: Vec<u8>,
|
||
manifest_text: String,
|
||
}
|
||
|
||
fn load_bundle(path: &Path) -> Result<BundleLoaded> {
|
||
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<BundleLoaded> {
|
||
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<PathBuf> {
|
||
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<bool> {
|
||
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<BundleLoaded> {
|
||
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<BundleLoaded> {
|
||
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<File>, name: &str, max: u64) -> Result<Vec<u8>> {
|
||
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<String> {
|
||
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<u32> {
|
||
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<u32> {
|
||
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::<u32>()
|
||
.map(|value| value.saturating_mul(1024 * 1024))
|
||
.with_context(|| format!("无效大小 {text}"));
|
||
}
|
||
if let Some(value) = text.strip_suffix("kb") {
|
||
return value
|
||
.trim()
|
||
.parse::<u32>()
|
||
.map(|value| value.saturating_mul(1024))
|
||
.with_context(|| format!("无效大小 {text}"));
|
||
}
|
||
text.parse::<u32>().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<BundleInfo> {
|
||
load_bundle(bundle_path).map(|bundle| bundle.info)
|
||
}
|
||
|
||
pub fn plan_snapshot(port: &str, bundle_path: &Path) -> Result<FlashPlan> {
|
||
plan_impl(port, bundle_path)
|
||
}
|
||
|
||
fn plan_impl(port: &str, bundle_path: &Path) -> Result<FlashPlan> {
|
||
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<SlotAttempt> {
|
||
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<Vec<SlotCandidate>> {
|
||
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<FlashReport> {
|
||
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<SlotCandidate> = 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<Vec<u8>> {
|
||
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<Vec<u8>> {
|
||
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<String> {
|
||
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<PathBuf> {
|
||
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<String> {
|
||
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<Vec<u8>> {
|
||
// 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<AppHandle>,
|
||
phase: String,
|
||
total: u64,
|
||
current: u64,
|
||
last_emit: Instant,
|
||
}
|
||
|
||
impl EmitProgress {
|
||
fn new(app: Option<AppHandle>, 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<u8> {
|
||
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<u8> {
|
||
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(".."));
|
||
}
|
||
}
|