729 lines
35 KiB
Python
729 lines
35 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
fw_pack / gd32e230_firmware_tool v2 回归测试套件
|
|
================================================================
|
|
覆盖 UPGRADE_PLAN_v2.md 第五节回归基准 + 自建边界用例:
|
|
1. CRC-16/XMODEM 测试向量;
|
|
2. profile 解析/校验(内置 + 变体 + 非法配置);
|
|
3. 向量表识别 A/B/BL 与显式冲突;
|
|
4. A 包整页扩展规则(恰整页 / 余量<4 / 余量==4 / 超槽);
|
|
5. B 包固定满槽 + 记录(满槽输入 / 小输入 / 超槽-8);
|
|
6. FULL 拼装 + 逐项字节校验 + 回读复核(含篡改检测);
|
|
7. FACTORY 工厂镜像(BL+B+flag 无 A, 锚点擦除态) + 篡改检测;
|
|
8. 真机固件回归(CeramixNano 1.0.17/1.0.18/V1.0.12, 字节级复现
|
|
2026-10-22 验收包, 文件缺失时自动跳过);
|
|
9. CLI 冒烟(v2 / legacy / 错误路径)。
|
|
|
|
运行: python test_fw_pack.py [-v]
|
|
全部通过 = v2 升级验收通过。
|
|
"""
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
import fw_pack # noqa: E402
|
|
|
|
ROOT = os.path.dirname(os.path.abspath(__file__))
|
|
CLI = os.path.join(ROOT, 'gd32e230_firmware_tool.py')
|
|
|
|
A_BASE, B_BASE, BL_BASE = 0x08002000, 0x0800B000, 0x08000000
|
|
PAGE = 1024
|
|
SLOT_A_SIZE, SLOT_B_SIZE = 36 * PAGE, 19 * PAGE
|
|
|
|
# -- CeramixNano 真机固件(回归基准, 缺文件自动跳过) --
|
|
CAP = r'E:\Hulk-Coding\capsule_mb_camke_vscode\Build'
|
|
A17 = os.path.join(CAP, 'Release', 'CeramixNano-MB_V1.0.17_APP_2026-10-21.bin')
|
|
A18 = os.path.join(CAP, 'Release', 'CeramixNano-MB_V1.0.18_APP_2026-10-21.bin')
|
|
B12 = os.path.join(CAP, 'Release', 'CeramixNano-MB_V1.0.12_APP_2026-10-20.bin')
|
|
U17_REF = os.path.join(CAP, 'test', 'CeramixNano-MB_V1.0.17_APP_2026-10-21_UPDATE_V5.bin')
|
|
U18_REF = os.path.join(CAP, 'test', 'CeramixNano-MB_V1.0.18_APP_2026-10-21_UPDATE_V5.bin')
|
|
FULL_REF = os.path.join(CAP, 'test', 'acceptance_2026-10-22',
|
|
'CeramixNano-MB_FULL_A-V1.0.17_B-V1.0.12_2026-10-21.bin')
|
|
HAVE_REAL = all(os.path.isfile(p) for p in (A17, A18, B12, U17_REF, FULL_REF))
|
|
|
|
LDC_APP = (r'E:\Hulk-Coding\ldc1612_cmake_vscode\Build\codex-bootloader-test'
|
|
r'\LDC1612_V1.0.1_APP_2026-09-26.bin')
|
|
LDC_UPD_REF = os.path.join(ROOT, 'LDC1612_V1.0.1_APP_2026-09-26_update.bin')
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════
|
|
# 测试辅助: 独立参考实现 + 合成固件
|
|
# ═══════════════════════════════════════════════════════════
|
|
|
|
def ref_crc16(data):
|
|
"""独立抄写的 XMODEM CRC (与 fw_pack 互为交叉验证)"""
|
|
crc = 0x0000
|
|
for byte in data:
|
|
crc ^= byte << 8
|
|
for _ in range(8):
|
|
crc = ((crc << 1) ^ 0x1021) if crc & 0x8000 else (crc << 1)
|
|
crc &= 0xFFFF
|
|
return crc
|
|
|
|
|
|
def ref_a_pack(body):
|
|
"""按规格直译的 A 包参考实现 (不经 fw_pack 代码路径)"""
|
|
n = (len(body) + PAGE - 1) // PAGE
|
|
if len(body) % PAGE == 0 or n * PAGE - len(body) < 4:
|
|
n += 1
|
|
buf = bytearray(body) + b'\xFF' * (n * PAGE - len(body))
|
|
crc = ref_crc16(bytes(buf[:n * PAGE - 4]))
|
|
struct.pack_into('<HH', buf, n * PAGE - 4, n, crc)
|
|
return bytes(buf), n, crc
|
|
|
|
|
|
def synth_bin(base, size, rst_off=0x101):
|
|
"""合成一个向量表合法的固件 bin (SP=0x20000800, 复位向量=base+rst_off|1)"""
|
|
buf = bytearray(size)
|
|
struct.pack_into('<II', buf, 0, 0x20000800, (base + rst_off) | 1)
|
|
for i in range(8, size):
|
|
buf[i] = (i * 7 + 13) & 0xFF
|
|
return bytes(buf)
|
|
|
|
|
|
def ceramix_profile():
|
|
return fw_pack.load_profile('CeramixNano_MB_Dual_FW')
|
|
|
|
|
|
def variant_profile(slot_a_k, slot_b_k, with_b=True):
|
|
raw = json.loads(json.dumps(fw_pack.BUILTIN_PROFILES['CeramixNano_MB_Dual_FW']))
|
|
raw['slot_a']['size'] = f'{slot_a_k}K'
|
|
if with_b:
|
|
raw['slot_b']['size'] = f'{slot_b_k}K'
|
|
raw['slot_b']['addr'] = '0x%08X' % (A_BASE + slot_a_k * 1024) # B 紧跟 A
|
|
else:
|
|
raw['slot_b'] = None
|
|
return fw_pack.Profile(raw)
|
|
|
|
|
|
def run_cli(*argv, cwd=None):
|
|
env = dict(os.environ, PYTHONIOENCODING='utf-8', PYTHONUTF8='1')
|
|
p = subprocess.run([sys.executable, CLI, *argv], capture_output=True,
|
|
text=True, encoding='utf-8', errors='replace',
|
|
cwd=cwd or ROOT, env=env, timeout=120)
|
|
return p.returncode, p.stdout, p.stderr
|
|
|
|
|
|
def quiet():
|
|
return lambda *_a: None
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════
|
|
# 1. CRC 自检
|
|
# ═══════════════════════════════════════════════════════════
|
|
|
|
class TestCRC(unittest.TestCase):
|
|
def test_vectors(self):
|
|
self.assertEqual(fw_pack.crc16_xmodem(b'123456789'), 0x31C3)
|
|
self.assertEqual(ref_crc16(b'123456789'), 0x31C3)
|
|
self.assertEqual(fw_pack.crc16_xmodem(b''), 0x0000)
|
|
self.assertEqual(fw_pack.crc16_xmodem(b'\xFF' * 20476),
|
|
ref_crc16(b'\xFF' * 20476))
|
|
fw_pack.self_test() # 不抛即通过
|
|
|
|
def test_selftest_detects_broken_impl(self):
|
|
orig = fw_pack.crc16_xmodem
|
|
fw_pack.crc16_xmodem = lambda d: 0
|
|
try:
|
|
with self.assertRaises(fw_pack.FwPackError):
|
|
fw_pack.self_test()
|
|
finally:
|
|
fw_pack.crc16_xmodem = orig
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════
|
|
# 2. Profile 解析与校验
|
|
# ═══════════════════════════════════════════════════════════
|
|
|
|
class TestProfile(unittest.TestCase):
|
|
def test_builtin_ceramixnano(self):
|
|
p = ceramix_profile()
|
|
self.assertEqual((p.flash_base, p.flash_size, p.page_size),
|
|
(0x08000000, 64 * 1024, 1024))
|
|
self.assertEqual((p.bl_addr, p.bl_size), (0x08000000, 8 * 1024))
|
|
self.assertEqual((p.slot_a.addr, p.slot_a.size), (0x08002000, 36 * 1024))
|
|
self.assertEqual(p.slot_a.anchor_addr, 0x0800AFFC)
|
|
self.assertEqual((p.slot_b.addr, p.slot_b.size), (0x0800B000, 19 * 1024))
|
|
self.assertTrue(p.slot_b.fixed_slot)
|
|
self.assertEqual(p.slot_b.record_addr, 0x0800FBF8)
|
|
self.assertEqual(p.slot_b.record_area_len(1024), 8)
|
|
self.assertEqual((p.flag_addr, p.flag_value), (0x0800FFFC, 0xEEEE))
|
|
|
|
def test_builtin_single_slot(self):
|
|
p = fw_pack.load_profile('f8_single_slot')
|
|
self.assertIsNone(p.slot_b)
|
|
self.assertEqual(p.slot_a.size, 55 * 1024)
|
|
|
|
def test_variants_b8k_b28k_nob(self):
|
|
variant_profile(47, 8) # 保底槽 8K
|
|
variant_profile(27, 28) # 保底槽 28K
|
|
variant_profile(55, None, with_b=False) # 无保底槽
|
|
# 各变体都能完成一次 A 包打包
|
|
for a_k, b_k, with_b in ((47, 8, True), (27, 28, True), (55, None, False)):
|
|
p = variant_profile(a_k, b_k, with_b)
|
|
ap = fw_pack.build_a_pack(synth_bin(0x08002000, 5000), p)
|
|
self.assertEqual(ap.pages, 5)
|
|
|
|
def test_parse_num(self):
|
|
self.assertEqual(fw_pack.parse_num('0x08002000'), 0x08002000)
|
|
self.assertEqual(fw_pack.parse_num('64K'), 65536)
|
|
self.assertEqual(fw_pack.parse_num('19k'), 19456)
|
|
self.assertEqual(fw_pack.parse_num('1024'), 1024)
|
|
self.assertEqual(fw_pack.parse_num(2048), 2048)
|
|
with self.assertRaises(fw_pack.FwPackError):
|
|
fw_pack.parse_num('abc')
|
|
with self.assertRaises(fw_pack.FwPackError):
|
|
fw_pack.parse_num('')
|
|
|
|
def _invalid(self, mutate, needle):
|
|
raw = json.loads(json.dumps(fw_pack.BUILTIN_PROFILES['CeramixNano_MB_Dual_FW']))
|
|
mutate(raw)
|
|
with self.assertRaises(fw_pack.FwPackError) as cm:
|
|
fw_pack.Profile(raw)
|
|
self.assertIn(needle, str(cm.exception))
|
|
|
|
def test_invalid_layouts(self):
|
|
self._invalid(lambda r: r['slot_a'].__setitem__('size', '37K'),
|
|
'重叠') # A 槽压到 B 槽
|
|
self._invalid(lambda r: r['slot_a'].__setitem__('size', '35K'),
|
|
'总和') # 分区和不等于 flash
|
|
self._invalid(lambda r: r['slot_a'].__setitem__('addr', '0x08002040'),
|
|
'页对齐')
|
|
self._invalid(lambda r: r['flag'].__setitem__('addr', '0x0800F000'),
|
|
'末页')
|
|
self._invalid(lambda r: r.__setitem__('crc', 'crc32'), 'xmodem')
|
|
self._invalid(lambda r: r['slot_b'].__setitem__('record_addr', 'slot_end-4'),
|
|
'记录区') # 6B 记录塞不进 4B 区
|
|
self._invalid(lambda r: r['slot_a'].__setitem__('record', '{pages16@+0}'),
|
|
'crc')
|
|
|
|
def test_load_profile_path_and_errors(self):
|
|
with tempfile.TemporaryDirectory() as td:
|
|
path = os.path.join(td, 'p.json')
|
|
with open(path, 'w', encoding='utf-8') as f:
|
|
json.dump(fw_pack.BUILTIN_PROFILES['CeramixNano_MB_Dual_FW'], f)
|
|
p = fw_pack.load_profile(path)
|
|
self.assertEqual(p.name, 'CeramixNano_MB_Dual_FW')
|
|
bad = os.path.join(td, 'bad.json')
|
|
with open(bad, 'w', encoding='utf-8') as f:
|
|
f.write('{ not json')
|
|
with self.assertRaises(fw_pack.FwPackError):
|
|
fw_pack.load_profile(bad)
|
|
with self.assertRaises(fw_pack.FwPackError):
|
|
fw_pack.load_profile('no_such_profile')
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════
|
|
# 3. 向量表识别与硬门槛
|
|
# ═══════════════════════════════════════════════════════════
|
|
|
|
class TestDetect(unittest.TestCase):
|
|
def setUp(self):
|
|
self.p = ceramix_profile()
|
|
|
|
def test_detect_a_b(self):
|
|
self.assertEqual(fw_pack.detect_slot(synth_bin(A_BASE, 1000), self.p), 'a')
|
|
self.assertEqual(fw_pack.detect_slot(synth_bin(B_BASE, 1000), self.p), 'b')
|
|
|
|
def test_bl_input_rejected_with_hint(self):
|
|
with self.assertRaises(fw_pack.FwPackError) as cm:
|
|
fw_pack.detect_slot(synth_bin(BL_BASE, 1000), self.p)
|
|
self.assertIn('bootloader', str(cm.exception))
|
|
|
|
def test_bad_vector(self):
|
|
bad_sp = bytearray(synth_bin(A_BASE, 1000))
|
|
struct.pack_into('<I', bad_sp, 0, 0x30000000) # SP 不在 SRAM
|
|
with self.assertRaises(fw_pack.FwPackError):
|
|
fw_pack.detect_slot(bytes(bad_sp), self.p)
|
|
bad_thumb = bytearray(synth_bin(A_BASE, 1000))
|
|
struct.pack_into('<I', bad_thumb, 4, A_BASE + 0x100) # bit0=0, 非 Thumb
|
|
with self.assertRaises(fw_pack.FwPackError):
|
|
fw_pack.detect_slot(bytes(bad_thumb), self.p)
|
|
tiny = struct.pack('<II', 0x20000800, A_BASE | 1) # 只有向量表也够识别
|
|
self.assertEqual(fw_pack.detect_slot(tiny, self.p), 'a')
|
|
with self.assertRaises(fw_pack.FwPackError):
|
|
fw_pack.detect_slot(b'\x00\x01', self.p) # 太小
|
|
|
|
def test_validate_app_bin(self):
|
|
fw_pack.validate_app_bin(synth_bin(B_BASE, 1000), self.p, 'b')
|
|
with self.assertRaises(fw_pack.FwPackError):
|
|
fw_pack.validate_app_bin(synth_bin(B_BASE, 1000), self.p, 'a')
|
|
no_b = fw_pack.load_profile('f8_single_slot')
|
|
with self.assertRaises(fw_pack.FwPackError):
|
|
fw_pack.validate_app_bin(synth_bin(B_BASE, 1000), no_b, 'b')
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════
|
|
# 4. A 包: 整页扩展规则
|
|
# ═══════════════════════════════════════════════════════════
|
|
|
|
class TestAPack(unittest.TestCase):
|
|
def setUp(self):
|
|
self.p = ceramix_profile()
|
|
|
|
def _body(self, size):
|
|
return synth_bin(A_BASE, size)
|
|
|
|
def test_against_reference_impl(self):
|
|
for size in (8, 1024, 1025, 5000, 20476, 20477, 20480, 20481, 36860):
|
|
body = self._body(size)
|
|
got = fw_pack.build_a_pack(body, self.p)
|
|
exp, exp_n, exp_crc = ref_a_pack(body)
|
|
self.assertEqual(got.data, exp, f'size={size}')
|
|
self.assertEqual((got.pages, got.crc), (exp_n, exp_crc))
|
|
self.assertEqual(len(got.data), exp_n * PAGE)
|
|
self.assertEqual(got.trailer_value,
|
|
struct.unpack('<I', exp[-4:])[0])
|
|
|
|
def test_exact_page_expands(self):
|
|
"""恰整页(body=20480B) → 扩展为 21 页, trailer pages=21"""
|
|
ap = fw_pack.build_a_pack(self._body(20480), self.p)
|
|
self.assertEqual(ap.pages, 21)
|
|
self.assertEqual(len(ap.data), 21 * PAGE)
|
|
self.assertEqual(ap.data[-4:], struct.pack('<HH', 21, ap.crc))
|
|
# CRC 覆盖 21 页去掉末 4B (含 0xFF 填充)
|
|
self.assertEqual(ap.crc, ref_crc16(ap.data[:21 * PAGE - 4]))
|
|
|
|
def test_remainder_lt_record_expands(self):
|
|
"""余量<4B(body=20477B, 余量3) → 扩展为 21 页"""
|
|
ap = fw_pack.build_a_pack(self._body(20477), self.p)
|
|
self.assertEqual(ap.pages, 21)
|
|
self.assertEqual(ap.crc, ref_crc16(ap.data[:21 * PAGE - 4]))
|
|
|
|
def test_remainder_eq_record_no_expand(self):
|
|
"""余量恰好=4B(body=20476B) → 不扩展, 20 页"""
|
|
ap = fw_pack.build_a_pack(self._body(20476), self.p)
|
|
self.assertEqual(ap.pages, 20)
|
|
self.assertEqual(len(ap.data), 20480)
|
|
|
|
def test_overflow_rejected(self):
|
|
"""超槽: 恰 36 整页(36864B)扩 1 页即超 36K → 报错; 36861B 余量3扩页也超"""
|
|
for size in (36861, 36864, 36865, 40000):
|
|
with self.assertRaises(fw_pack.FwPackError, msg=f'size={size}'):
|
|
fw_pack.build_a_pack(self._body(size), self.p)
|
|
|
|
def test_max_fit(self):
|
|
"""36860B: 余量4, 36 页正好放下"""
|
|
ap = fw_pack.build_a_pack(self._body(36860), self.p)
|
|
self.assertEqual(ap.pages, 36)
|
|
self.assertEqual(len(ap.data), SLOT_A_SIZE)
|
|
|
|
def test_empty_rejected(self):
|
|
with self.assertRaises(fw_pack.FwPackError):
|
|
fw_pack.build_a_pack(b'', self.p)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════
|
|
# 5. B 包: 固定满槽 + 记录
|
|
# ═══════════════════════════════════════════════════════════
|
|
|
|
class TestBPack(unittest.TestCase):
|
|
def setUp(self):
|
|
self.p = ceramix_profile()
|
|
|
|
def test_full_slot_input_record_rewritten(self):
|
|
"""满槽输入(19456B, 尾部为旧格式 magic+FF) → 末 8B 重写为新记录"""
|
|
body = bytearray(synth_bin(B_BASE, SLOT_B_SIZE - 8))
|
|
body += b'\xde\xc0' + b'\xff' * 6 # 旧格式尾部
|
|
bp = fw_pack.build_b_pack(bytes(body), self.p)
|
|
self.assertEqual(len(bp.data), SLOT_B_SIZE)
|
|
self.assertEqual(bp.pages, 19)
|
|
self.assertEqual(bp.crc, ref_crc16(bp.data[:SLOT_B_SIZE - 8]))
|
|
rec = struct.pack('<HHH', 0xC0DE, 19, bp.crc) + b'\xff\xff'
|
|
self.assertEqual(bp.record_bytes, rec[:6])
|
|
self.assertEqual(bp.data[-8:], rec)
|
|
self.assertEqual(bp.record_addr, 0x0800FBF8)
|
|
# 记录之外的槽内容与输入一致
|
|
self.assertEqual(bp.data[:SLOT_B_SIZE - 8], bytes(body[:SLOT_B_SIZE - 8]))
|
|
|
|
def test_small_input_padded(self):
|
|
body = synth_bin(B_BASE, 10000)
|
|
bp = fw_pack.build_b_pack(body, self.p)
|
|
self.assertEqual(len(bp.data), SLOT_B_SIZE)
|
|
self.assertEqual(bp.data[:10000], body)
|
|
self.assertEqual(bp.data[10000:SLOT_B_SIZE - 8],
|
|
b'\xff' * (SLOT_B_SIZE - 8 - 10000))
|
|
self.assertEqual(bp.pages, 19)
|
|
self.assertEqual(bp.crc, ref_crc16(bp.data[:SLOT_B_SIZE - 8]))
|
|
|
|
def test_overflow_rejected(self):
|
|
"""B 固件 > 槽-8B 且非满槽镜像 → 报错(固定槽不可扩展)"""
|
|
for size in (SLOT_B_SIZE - 7, SLOT_B_SIZE - 1):
|
|
with self.assertRaises(fw_pack.FwPackError, msg=f'size={size}'):
|
|
fw_pack.build_b_pack(synth_bin(B_BASE, size), self.p)
|
|
|
|
def test_exact_body_fits(self):
|
|
"""恰好 19448B(槽-8B) → 正常"""
|
|
bp = fw_pack.build_b_pack(synth_bin(B_BASE, SLOT_B_SIZE - 8), self.p)
|
|
self.assertEqual(bp.pages, 19)
|
|
|
|
def test_no_slot_b_profile(self):
|
|
with self.assertRaises(fw_pack.FwPackError):
|
|
fw_pack.build_b_pack(synth_bin(B_BASE, 1000),
|
|
fw_pack.load_profile('f8_single_slot'))
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════
|
|
# 6. FULL 拼装 / 复核 / 篡改检测
|
|
# ═══════════════════════════════════════════════════════════
|
|
|
|
class TestFull(unittest.TestCase):
|
|
def setUp(self):
|
|
self.p = ceramix_profile()
|
|
self.a_body = synth_bin(A_BASE, 5000)
|
|
self.ap = fw_pack.build_a_pack(self.a_body, self.p)
|
|
self.b_body = synth_bin(B_BASE, SLOT_B_SIZE - 8)
|
|
self.bp = fw_pack.build_b_pack(self.b_body, self.p)
|
|
self.bl = synth_bin(BL_BASE, 4044, rst_off=0xD00)
|
|
|
|
def test_full_layout_bytes(self):
|
|
img = fw_pack.build_full(self.p, self.bl, self.ap, self.bp)
|
|
self.assertEqual(len(img), 64 * 1024)
|
|
base = 0x08000000
|
|
self.assertEqual(img[:4044], self.bl) # BL
|
|
self.assertTrue(all(b == 0xFF for b in img[4044:0x2000])) # BL 余量
|
|
off = A_BASE - base
|
|
self.assertEqual(img[off:off + len(self.ap.data)], self.ap.data)
|
|
self.assertEqual(img[0xAFFC:0xB000],
|
|
self.ap.record_bytes[:4]) # 槽尾锚点
|
|
self.assertTrue(all(b == 0xFF for b in
|
|
img[off + len(self.ap.data):0xAFFC])) # 空隙
|
|
self.assertEqual(img[0xB000:0xFC00], self.bp.data) # B 满槽
|
|
self.assertEqual(img[0xFFFC:0x10000], b'\xee\xee\x00\x00') # flag
|
|
self.assertTrue(all(b == 0xFF for b in img[0xFC00:0xFFFC]))
|
|
|
|
def test_full_without_bl_or_b(self):
|
|
img = fw_pack.build_full(self.p, None, self.ap, None)
|
|
self.assertTrue(all(b == 0xFF for b in img[:0x2000]))
|
|
self.assertTrue(all(b == 0xFF for b in img[0xB000:0xFC00]))
|
|
checks = fw_pack.verify_full_blob(img, self.p, None, self.a_body, None)
|
|
self.assertTrue(all(ok for _n, ok, _t in checks), checks)
|
|
|
|
def test_verify_full_all_pass(self):
|
|
img = fw_pack.build_full(self.p, self.bl, self.ap, self.bp)
|
|
checks = fw_pack.verify_full_blob(img, self.p, self.bl,
|
|
self.a_body, self.b_body)
|
|
self.assertGreaterEqual(len(checks), 17) # 规格要求 17 项
|
|
bad = [(n, t) for n, ok, t in checks if not ok]
|
|
self.assertEqual(bad, [], checks)
|
|
|
|
def test_verify_full_detects_corruption(self):
|
|
img = bytearray(fw_pack.build_full(self.p, self.bl, self.ap, self.bp))
|
|
img[0x2000 + 100] ^= 0xFF # 破坏 A 体
|
|
checks = fw_pack.verify_full_blob(bytes(img), self.p, self.bl,
|
|
self.a_body, self.b_body)
|
|
self.assertFalse(all(ok for _n, ok, _t in checks))
|
|
img2 = bytearray(fw_pack.build_full(self.p, self.bl, self.ap, self.bp))
|
|
img2[0xFFFC] = 0x00 # 破坏 flag
|
|
checks = fw_pack.verify_full_blob(bytes(img2), self.p, self.bl,
|
|
self.a_body, self.b_body)
|
|
self.assertFalse(all(ok for _n, ok, _t in checks))
|
|
|
|
def test_verify_blobs(self):
|
|
fw_pack.verify_a_blob(self.ap.data, self.p) # 正常不抛
|
|
fw_pack.verify_b_blob(self.bp.data, self.p)
|
|
with self.assertRaises(fw_pack.FwPackError):
|
|
fw_pack.verify_a_blob(self.ap.data[:-1] + b'\x00', self.p)
|
|
with self.assertRaises(fw_pack.FwPackError):
|
|
fw_pack.verify_b_blob(self.bp.data[:-1], self.p)
|
|
|
|
def test_bl_too_large(self):
|
|
with self.assertRaises(fw_pack.FwPackError):
|
|
fw_pack.build_full(self.p, synth_bin(BL_BASE, 8 * 1024 + 1),
|
|
self.ap, self.bp)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════
|
|
# 7. FACTORY 工厂镜像 (BL + B 满槽 + flag, 无 A)
|
|
# ═══════════════════════════════════════════════════════════
|
|
|
|
class TestFactory(unittest.TestCase):
|
|
def setUp(self):
|
|
self.p = ceramix_profile()
|
|
self.bl = synth_bin(BL_BASE, 4044, rst_off=0xD00)
|
|
self.b_body = synth_bin(B_BASE, SLOT_B_SIZE - 8)
|
|
self.bp = fw_pack.build_b_pack(self.b_body, self.p)
|
|
|
|
def test_factory_layout_bytes(self):
|
|
"""BL + B 满槽 + flag; A 槽(含锚点)整体擦除态 → 上电判定链落 B"""
|
|
img = fw_pack.build_factory(self.p, self.bl, self.bp)
|
|
self.assertEqual(len(img), 64 * 1024)
|
|
self.assertEqual(img[:4044], self.bl)
|
|
self.assertTrue(all(b == 0xFF for b in img[4044:0xB000])) # BL 余量 + 整个 A 槽
|
|
self.assertEqual(img[0xAFFC:0xB000], b'\xff' * 4) # 锚点擦除态
|
|
self.assertEqual(img[0xB000:0xFC00], self.bp.data)
|
|
self.assertEqual(img[0xFFFC:0x10000], b'\xee\xee\x00\x00')
|
|
|
|
def test_verify_factory_all_pass(self):
|
|
img = fw_pack.build_factory(self.p, self.bl, self.bp)
|
|
checks = fw_pack.verify_factory_blob(img, self.p, self.bl, self.b_body)
|
|
bad = [(n, t) for n, ok, t in checks if not ok]
|
|
self.assertEqual(bad, [], checks)
|
|
self.assertGreaterEqual(len(checks), 10)
|
|
|
|
def test_verify_factory_detects_corruption(self):
|
|
img = bytearray(fw_pack.build_factory(self.p, self.bl, self.bp))
|
|
img[0xB000 + 100] ^= 0xFF # 破坏 B 体
|
|
checks = fw_pack.verify_factory_blob(bytes(img), self.p, self.bl,
|
|
self.b_body)
|
|
self.assertFalse(all(ok for _n, ok, _t in checks))
|
|
img2 = bytearray(fw_pack.build_factory(self.p, self.bl, self.bp))
|
|
img2[0xAFFC] = 0x14 # A 槽出现锚点痕迹
|
|
checks = fw_pack.verify_factory_blob(bytes(img2), self.p, self.bl,
|
|
self.b_body)
|
|
self.assertFalse(all(ok for _n, ok, _t in checks))
|
|
|
|
def test_requires_b_pack(self):
|
|
with self.assertRaises(fw_pack.FwPackError):
|
|
fw_pack.build_factory(self.p, self.bl, None)
|
|
|
|
def test_suggest_factory_name(self):
|
|
self.assertEqual(
|
|
fw_pack.suggest_factory_name(
|
|
r'x\CeramixNano-MB_V1.0.12_APP_2026-10-20.bin'),
|
|
'CeramixNano-MB_FACTORY_B-V1.0.12_2026-10-20.bin')
|
|
self.assertEqual(fw_pack.suggest_factory_name(r'x\app_b.bin'),
|
|
'app_b_FACTORY.bin')
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════
|
|
# 8. 真机回归: CeramixNano v5.1 (字节级复现验收包)
|
|
# ═══════════════════════════════════════════════════════════
|
|
|
|
@unittest.skipUnless(HAVE_REAL, '需要 CeramixNano 固件(见 test_fw_pack.py 头部路径)')
|
|
class TestRegressionCeramixNano(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.p = ceramix_profile()
|
|
cls.a17 = open(A17, 'rb').read()
|
|
cls.a18 = open(A18, 'rb').read()
|
|
cls.b12 = open(B12, 'rb').read()
|
|
cls.ref_full = open(FULL_REF, 'rb').read()
|
|
cls.bl = cls.ref_full[:0x2000] # BL 区(4044B + 0xFF)
|
|
|
|
def test_1017_update(self):
|
|
"""1.0.17: 20 页/20480B, trailer=锚点=0xD30F0014, 与验收包逐字节一致"""
|
|
ap = fw_pack.build_a_pack(self.a17, self.p)
|
|
self.assertEqual((len(self.a17), ap.pages, len(ap.data)), (19580, 20, 20480))
|
|
self.assertEqual(ap.trailer_value, 0xD30F0014)
|
|
self.assertEqual(ap.crc, 0xD30F)
|
|
self.assertEqual(ap.data, open(U17_REF, 'rb').read())
|
|
|
|
def test_1018_update(self):
|
|
"""1.0.18: 20 页, trailer=0x6F380014 (CRC 0x6F38)"""
|
|
ap = fw_pack.build_a_pack(self.a18, self.p)
|
|
self.assertEqual((ap.pages, ap.crc, ap.trailer_value),
|
|
(20, 0x6F38, 0x6F380014))
|
|
if os.path.isfile(U18_REF):
|
|
self.assertEqual(ap.data, open(U18_REF, 'rb').read())
|
|
|
|
def test_b_record(self):
|
|
"""V1.0.12: 满槽 19456B, 记录 DE C0 13 00 6F 18 FF FF (pages=19, CRC=0x186F)"""
|
|
bp = fw_pack.build_b_pack(self.b12, self.p)
|
|
self.assertEqual(len(self.b12), 19456)
|
|
self.assertEqual(bp.data[-8:], bytes.fromhex('de c0 13 00 6f 18 ff ff'))
|
|
self.assertEqual((bp.pages, bp.crc), (19, 0x186F))
|
|
self.assertEqual(bp.crc, ref_crc16(bp.data[:19448]))
|
|
|
|
def test_full_byte_identical(self):
|
|
"""FULL 65,536B 与 2026-10-22 验收包逐字节一致 (MD5 f704b837...)"""
|
|
ap = fw_pack.build_a_pack(self.a17, self.p)
|
|
bp = fw_pack.build_b_pack(self.b12, self.p)
|
|
img = fw_pack.build_full(self.p, self.bl, ap, bp)
|
|
self.assertEqual(len(img), 65536)
|
|
self.assertEqual(hashlib.md5(img).hexdigest(),
|
|
'f704b8374ccaff7a0f0f385c59897577')
|
|
self.assertEqual(img, self.ref_full)
|
|
|
|
def test_detect_real_bins(self):
|
|
self.assertEqual(fw_pack.detect_slot(self.a17, self.p), 'a')
|
|
self.assertEqual(fw_pack.detect_slot(self.a18, self.p), 'a')
|
|
self.assertEqual(fw_pack.detect_slot(self.b12, self.p), 'b')
|
|
|
|
def test_pack_all_pipeline(self):
|
|
"""pack_all 一站式: 产物/MD5 清单/复核全链路 (临时目录)"""
|
|
with tempfile.TemporaryDirectory() as td:
|
|
bl_path = os.path.join(td, 'bl.bin')
|
|
open(bl_path, 'wb').write(self.bl)
|
|
out = fw_pack.pack_all('CeramixNano_MB_Dual_FW', A17, b_path=B12,
|
|
bl_path=bl_path, out_dir=td, progress=quiet())
|
|
upd = open(out.paths['update'], 'rb').read()
|
|
full = open(out.paths['full'], 'rb').read()
|
|
fac = open(out.paths['factory'], 'rb').read()
|
|
self.assertEqual(upd, open(U17_REF, 'rb').read())
|
|
self.assertEqual(full, self.ref_full)
|
|
# FACTORY = 参考 FULL 把 A 槽区(含锚点)整体置 0xFF
|
|
expect_fac = bytearray(self.ref_full)
|
|
expect_fac[0x2000:0xB000] = b'\xFF' * (0xB000 - 0x2000)
|
|
self.assertEqual(fac, bytes(expect_fac))
|
|
self.assertTrue(out.ok)
|
|
# 自动命名: FULL 与验收包一致, FACTORY 带 B 版本号
|
|
self.assertEqual(os.path.basename(out.paths['full']),
|
|
'CeramixNano-MB_FULL_A-V1.0.17_B-V1.0.12_2026-10-21.bin')
|
|
self.assertEqual(os.path.basename(out.paths['factory']),
|
|
'CeramixNano-MB_FACTORY_B-V1.0.12_2026-10-20.bin')
|
|
# MD5 清单挂在 FULL, 包含全部产物与正确摘要
|
|
mani = open(out.paths['md5'], encoding='utf-8').read()
|
|
for key in ('update', 'full', 'factory'):
|
|
blob = open(out.paths[key], 'rb').read()
|
|
self.assertIn(hashlib.md5(blob).hexdigest(), mani)
|
|
self.assertIn('CeramixNano_MB_Dual_FW', mani)
|
|
self.assertIn('0xD30F0014', mani)
|
|
self.assertIn('DE C0 13 00 6F 18 FF FF', mani)
|
|
|
|
def test_suggest_full_name(self):
|
|
self.assertEqual(
|
|
fw_pack.suggest_full_name(
|
|
r'x\CeramixNano-MB_V1.0.17_APP_2026-10-21.bin',
|
|
r'y\CeramixNano-MB_V1.0.12_APP_2026-10-20.bin'),
|
|
'CeramixNano-MB_FULL_A-V1.0.17_B-V1.0.12_2026-10-21.bin')
|
|
self.assertEqual(fw_pack.suggest_full_name(r'x\app.bin', None),
|
|
'app_FULL.bin')
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════
|
|
# 8. CLI 冒烟: v2 / legacy / 错误路径
|
|
# ═══════════════════════════════════════════════════════════
|
|
|
|
class TestCLI(unittest.TestCase):
|
|
def setUp(self):
|
|
self.td = tempfile.TemporaryDirectory()
|
|
self.dir = self.td.name
|
|
self.a = os.path.join(self.dir, 'app_a.bin')
|
|
self.b = os.path.join(self.dir, 'app_b.bin')
|
|
self.bl = os.path.join(self.dir, 'bl.bin')
|
|
open(self.a, 'wb').write(synth_bin(A_BASE, 19580))
|
|
open(self.b, 'wb').write(synth_bin(B_BASE, SLOT_B_SIZE))
|
|
open(self.bl, 'wb').write(synth_bin(BL_BASE, 4044, rst_off=0xD00))
|
|
|
|
def tearDown(self):
|
|
self.td.cleanup()
|
|
|
|
def test_v2_full_pipeline(self):
|
|
rc, out, err = run_cli(self.a, self.b, '-o', self.dir)
|
|
self.assertEqual(rc, 0, err or out)
|
|
for name in ('app_a_UPDATE.bin', 'app_a_FULL.bin', 'app_b_FACTORY.bin'):
|
|
self.assertTrue(os.path.isfile(os.path.join(self.dir, name)), name)
|
|
self.assertTrue(os.path.isfile(
|
|
os.path.join(self.dir, 'app_a_FULL.bin.md5.txt')))
|
|
full = open(os.path.join(self.dir, 'app_a_FULL.bin'), 'rb').read()
|
|
self.assertEqual(len(full), 65536)
|
|
fac = open(os.path.join(self.dir, 'app_b_FACTORY.bin'), 'rb').read()
|
|
self.assertEqual(fac[0x2000:0xB000], b'\xFF' * 0x9000) # A 槽擦除态
|
|
self.assertFalse([f for f in os.listdir(self.dir)
|
|
if f.endswith('_B.bin')]) # 不再单独出 B 包
|
|
self.assertIn('回读复核通过', out)
|
|
|
|
def test_v2_b_only_factory(self):
|
|
"""仅 B 输入 → 只出 *_FACTORY.bin(+MD5), 不拼 FULL"""
|
|
rc, out, err = run_cli(self.b, '-b', self.bl, '-o', self.dir)
|
|
self.assertEqual(rc, 0, err or out)
|
|
self.assertTrue(os.path.isfile(os.path.join(self.dir, 'app_b_FACTORY.bin')))
|
|
self.assertTrue(os.path.isfile(os.path.join(self.dir, 'app_b_FACTORY.bin.md5.txt')))
|
|
self.assertFalse([f for f in os.listdir(self.dir) if 'FULL' in f])
|
|
|
|
def test_v2_no_factory_flag(self):
|
|
rc, out, err = run_cli(self.a, self.b, '-o', self.dir, '--no-factory')
|
|
self.assertEqual(rc, 0, err or out)
|
|
self.assertFalse([f for f in os.listdir(self.dir) if 'FACTORY' in f])
|
|
|
|
def test_v2_explicit_conflict(self):
|
|
rc, out, err = run_cli('--b', self.a) # A 固件显式塞给 B 槽
|
|
self.assertNotEqual(rc, 0)
|
|
self.assertIn('复位向量', err + out)
|
|
|
|
def test_v2_positional_and_explicit_mix(self):
|
|
rc, _out, err = run_cli(self.a, '--b', self.b)
|
|
self.assertNotEqual(rc, 0)
|
|
self.assertIn('不能混用', err)
|
|
|
|
def test_v2_bl_region_input_rejected(self):
|
|
bl_like = os.path.join(self.dir, 'bl_like.bin')
|
|
open(bl_like, 'wb').write(synth_bin(BL_BASE, 4044, rst_off=0xD00))
|
|
rc, _out, err = run_cli(bl_like)
|
|
self.assertNotEqual(rc, 0)
|
|
self.assertIn('bootloader', err)
|
|
|
|
def test_v2_output_must_be_dir(self):
|
|
rc, _out, err = run_cli(self.a, '-o', os.path.join(self.dir, 'x.bin'))
|
|
self.assertNotEqual(rc, 0)
|
|
self.assertIn('输出目录', err)
|
|
|
|
def test_v2_duplicate_slot(self):
|
|
a2 = os.path.join(self.dir, 'app_a2.bin')
|
|
open(a2, 'wb').write(synth_bin(A_BASE, 9000))
|
|
rc, _out, err = run_cli(self.a, a2)
|
|
self.assertNotEqual(rc, 0)
|
|
self.assertIn('只能有一个输入', err)
|
|
|
|
@unittest.skipUnless(HAVE_REAL, '需要 CeramixNano 固件')
|
|
def test_v2_real_firmware(self):
|
|
with tempfile.TemporaryDirectory() as td:
|
|
bl_path = os.path.join(td, 'bl.bin')
|
|
open(bl_path, 'wb').write(open(FULL_REF, 'rb').read()[:0x2000])
|
|
rc, out, err = run_cli(A17, B12, '-b', bl_path, '-o', td)
|
|
self.assertEqual(rc, 0, err or out)
|
|
full = open(os.path.join(
|
|
td, 'CeramixNano-MB_FULL_A-V1.0.17_B-V1.0.12_2026-10-21.bin'),
|
|
'rb').read()
|
|
self.assertEqual(full, open(FULL_REF, 'rb').read())
|
|
expect_fac = bytearray(open(FULL_REF, 'rb').read())
|
|
expect_fac[0x2000:0xB000] = b'\xFF' * (0xB000 - 0x2000)
|
|
fac = open(os.path.join(td,
|
|
'CeramixNano-MB_FACTORY_B-V1.0.12_2026-10-20.bin'),
|
|
'rb').read()
|
|
self.assertEqual(fac, bytes(expect_fac))
|
|
self.assertIn('0xD30F0014', out)
|
|
self.assertIn('DE C0 13 00 6F 18 FF FF', out)
|
|
self.assertIn('FACTORY', out)
|
|
self.assertFalse([f for f in os.listdir(td) if f.endswith('_B.bin')])
|
|
|
|
def test_legacy_fill(self):
|
|
out = os.path.join(self.dir, 'legacy_update.bin')
|
|
rc, _o, err = run_cli('-f', self.a, '-o', out)
|
|
self.assertEqual(rc, 0, err)
|
|
blob = open(out, 'rb').read()
|
|
self.assertEqual(len(blob), 19580 + 900) # 纯 0xFF 补页, 无 trailer
|
|
self.assertEqual(blob[:19580], synth_bin(A_BASE, 19580))
|
|
self.assertEqual(blob[19580:], b'\xff' * 900)
|
|
|
|
def test_legacy_merge(self):
|
|
bl = os.path.join(self.dir, 'bl.bin')
|
|
open(bl, 'wb').write(synth_bin(BL_BASE, 3512, rst_off=0xD1C))
|
|
out = os.path.join(self.dir, 'legacy_bl.bin')
|
|
rc, _o, err = run_cli('-m', self.a, '-b', bl, '-o', out)
|
|
self.assertEqual(rc, 0, err)
|
|
blob = open(out, 'rb').read()
|
|
self.assertEqual(len(blob), 65536)
|
|
self.assertEqual(blob[:3512], synth_bin(BL_BASE, 3512, rst_off=0xD1C))
|
|
self.assertEqual(blob[0x2000:0x2000 + 19580], synth_bin(A_BASE, 19580))
|
|
self.assertEqual(blob[0xFFFC:], b'\xee\xee\x00\x00')
|
|
self.assertTrue(all(x == 0xFF for x in blob[0xF800:0xFC00])) # 校准页
|
|
|
|
def test_legacy_args_conflict(self):
|
|
rc, _o, err = run_cli('-f', self.a, '--profile', 'f8_single_slot')
|
|
self.assertNotEqual(rc, 0)
|
|
|
|
@unittest.skipUnless(os.path.isfile(LDC_APP) and os.path.isfile(LDC_UPD_REF),
|
|
'需要 LDC1612 原始 APP bin')
|
|
def test_legacy_ldc1612_reference(self):
|
|
"""对存量 LDC1612 包复现: -f 输出与 2026-09-26 的 update.bin 逐字节一致"""
|
|
with tempfile.TemporaryDirectory() as td:
|
|
out = os.path.join(td, 'u.bin')
|
|
rc, _o, err = run_cli('-f', LDC_APP, '-o', out)
|
|
self.assertEqual(rc, 0, err)
|
|
self.assertEqual(open(out, 'rb').read(), open(LDC_UPD_REF, 'rb').read())
|
|
|
|
|
|
if __name__ == '__main__':
|
|
print(f'fw_pack v2 回归测试 | 真机固件: {"可用" if HAVE_REAL else "缺失(跳过)"}')
|
|
unittest.main(verbosity=2)
|