feat(pack): FACTORY 工厂镜像 + 内置 profile 改名 CeramixNano_MB_Dual_FW

This commit is contained in:
2026-10-22 05:14:58 +08:00
parent 0ffa671656
commit 0a4095150b
10 changed files with 350 additions and 114 deletions
+100 -20
View File
@@ -10,9 +10,10 @@ fw_pack / gd32e230_firmware_tool v2 回归测试套件
4. A 包整页扩展规则(恰整页 / 余量<4 / 余量==4 / 超槽);
5. B 包固定满槽 + 记录(满槽输入 / 小输入 / 超槽-8);
6. FULL 拼装 + 逐项字节校验 + 回读复核(含篡改检测);
7. 真机固件回归(CeramixNano 1.0.17/1.0.18/V1.0.12, 字节级复现
7. FACTORY 工厂镜像(BL+B+flag 无 A, 锚点擦除态) + 篡改检测;
8. 真机固件回归(CeramixNano 1.0.17/1.0.18/V1.0.12, 字节级复现
2026-10-22 验收包, 文件缺失时自动跳过);
8. CLI 冒烟(v2 / legacy / 错误路径)。
9. CLI 冒烟(v2 / legacy / 错误路径)。
运行: python test_fw_pack.py [-v]
全部通过 = v2 升级验收通过。
@@ -89,11 +90,11 @@ def synth_bin(base, size, rst_off=0x101):
def ceramix_profile():
return fw_pack.load_profile('ceramixnano_mb_v51')
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_v51']))
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'
@@ -183,7 +184,7 @@ class TestProfile(unittest.TestCase):
fw_pack.parse_num('')
def _invalid(self, mutate, needle):
raw = json.loads(json.dumps(fw_pack.BUILTIN_PROFILES['ceramixnano_mb_v51']))
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)
@@ -208,9 +209,9 @@ class TestProfile(unittest.TestCase):
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_v51'], f)
json.dump(fw_pack.BUILTIN_PROFILES['CeramixNano_MB_Dual_FW'], f)
p = fw_pack.load_profile(path)
self.assertEqual(p.name, 'ceramixnano_mb_v51')
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')
@@ -441,7 +442,60 @@ class TestFull(unittest.TestCase):
# ═══════════════════════════════════════════════════════════
# 7. 真机回归: CeramixNano v5.1 (字节级复现验收包)
# 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 头部路径)')
@@ -499,25 +553,31 @@ class TestRegressionCeramixNano(unittest.TestCase):
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_v51', A17, b_path=B12,
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()
bpk = open(out.paths['b'], '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(bpk[-8:], bytes.fromhex('de c0 13 00 6f 18 ff ff'))
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 自动命名与验收包一致
# 自动命名: 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')
# MD5 清单包含全部产物与正确摘要
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', 'b', 'full'):
for key in ('update', 'full', 'factory'):
blob = open(out.paths[key], 'rb').read()
self.assertIn(hashlib.md5(blob).hexdigest(), mani)
self.assertIn('ceramixnano_mb_v51', 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(
@@ -539,8 +599,10 @@ class TestCLI(unittest.TestCase):
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()
@@ -548,21 +610,31 @@ class TestCLI(unittest.TestCase):
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_b_B.bin', 'app_a_FULL.bin'):
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_no_full(self):
"""仅 B 输入 → 只出 *_B.bin, 不拼 FULL (无 A 运行槽的整片镜像不可启动)"""
rc, out, err = run_cli(self.b, '-o', self.dir)
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_B.bin')))
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)
@@ -603,8 +675,16 @@ class TestCLI(unittest.TestCase):
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')