Files
firmware-lowlevel-development/skills/firmware-lowlevel-psp-development/scripts/psp/validate_partition_closure.py
T

277 lines
12 KiB
Python
Executable File

#!/usr/bin/env python3
"""Validate PSP partition geometry, Boot/Default mirrors, and DATA UBI capacity."""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import tempfile
from pathlib import Path
SIZE_RE = re.compile(r"^(\d+)([KMG])$", re.IGNORECASE)
MULTIPLIERS = {"K": 1024, "M": 1024**2, "G": 1024**3}
ALIASES = {
"boot": "fip", "env": "ENV", "yoc": "2nd", "kernel.A": "BOOT",
"rootfs.A": "ROOTFS", "firmware.A": "APP", "kernel.B": "BOOT2",
"rootfs.B": "ROOTFS2", "firmware.B": "APP2", "data": "DATA", "configs": "CONFIGS",
}
def size_bytes(value: object) -> int | None:
if value == "-":
return None
if not isinstance(value, str) or (match := SIZE_RE.fullmatch(value.strip())) is None:
raise ValueError(f"非法分区大小: {value!r}")
return int(match.group(1)) * MULTIPLIERS[match.group(2).upper()]
def parse_number(value: str) -> int:
value = value.strip()
match = SIZE_RE.fullmatch(value)
if match:
return int(match.group(1)) * MULTIPLIERS[match.group(2).upper()]
return int(value, 0)
def partition_rows(psp_json: Path) -> tuple[int, list[dict[str, object]]]:
payload = json.loads(psp_json.read_text(encoding="utf-8"))
partition = payload.get("partition")
if not isinstance(partition, dict):
raise ValueError("psp.json.partition 必须是对象")
flash = size_bytes(partition.get("flashImgSize"))
if flash is None:
raise ValueError("flashImgSize 不能是 -")
table = partition.get("partitionTable")
if not isinstance(table, list) or not table:
raise ValueError("partitionTable 必须是非空数组")
offset = 0
rows: list[dict[str, object]] = []
remainder_seen = False
for index, item in enumerate(table):
if not isinstance(item, dict) or not isinstance(item.get("name"), str):
raise ValueError(f"partitionTable[{index}] 非法")
raw_size = item.get("size")
value = size_bytes(raw_size)
if value is None:
if remainder_seen or index != len(table) - 1:
raise ValueError("size=- 只能出现一次且必须是最后一个分区")
remainder_seen = True
value = flash - offset
if value < 0 or offset + value > flash:
raise ValueError(f"分区 {item['name']} 超出 flashImgSize")
rows.append({"name": item["name"], "boot_name": ALIASES.get(item["name"], item["name"]), "offset_bytes": offset, "size_bytes": value})
offset += value
if offset != flash:
raise ValueError(f"分区总量 {offset} 未闭合到 flashImgSize {flash}")
return flash, rows
def assignments(text: str) -> dict[str, str]:
result: dict[str, str] = {}
for line in text.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
match = re.match(r"(?:export\s+)?([A-Za-z0-9_]+)\s*=\s*['\"]?([^'\"\s;]+)", stripped)
if match:
result[match.group(1).casefold()] = match.group(2)
return result
def check_mtdparts(
text: str, rows: list[dict[str, object]], errors: list[str]
) -> dict[str, object]:
candidates = sorted(
set(re.findall(r"mtdparts=[^:\s\"']+:([^\"'\s;]+)", text))
)
if not candidates:
return {"present": False, "checks": []}
if len(candidates) != 1:
errors.append("Boot/Default 中存在多个不同 mtdparts,无法唯一核对")
return {"present": True, "values": candidates, "checks": []}
tokens = candidates[0].split(",")
checks: list[dict[str, object]] = []
if len(tokens) != len(rows):
errors.append(f"mtdparts 分区数量不一致:期望 {len(rows)},实际 {len(tokens)}")
for index, row in enumerate(rows):
token = tokens[index] if index < len(tokens) else ""
match = re.fullmatch(r"([^()]+)\(([^)]+)\)", token)
raw_size, actual_name = (match.group(1), match.group(2)) if match else (None, None)
try:
actual_size = (
int(row["size_bytes"])
if raw_size == "-"
else parse_number(raw_size) if raw_size is not None else None
)
except ValueError:
actual_size = None
expected_name = str(row["boot_name"])
matched = (
actual_name is not None
and actual_name.casefold() == expected_name.casefold()
and actual_size == row["size_bytes"]
)
checks.append({"index": index, "token": token, "expected_name": expected_name, "actual_name": actual_name, "expected_size": row["size_bytes"], "actual_size": actual_size, "matched": matched})
if not matched:
errors.append(f"mtdparts[{index}] 未同步:期望 {expected_name}/{row['size_bytes']},实际 {token!r}")
return {"present": True, "value": candidates[0], "checks": checks}
def check_embedded_partition_json(
text: str, rows: list[dict[str, object]], errors: list[str]
) -> dict[str, object]:
payloads: list[dict[str, object]] = []
for line in text.splitlines():
if "partitionTable" not in line or "{" not in line:
continue
normalized_line = line.replace('\\"', '"')
start, end = normalized_line.find("{"), normalized_line.rfind("}")
if start < 0 or end <= start:
continue
try:
payload = json.loads(normalized_line[start : end + 1])
except json.JSONDecodeError:
errors.append("Boot/Default 内嵌 partition JSON 无法解析")
continue
if isinstance(payload, dict):
payloads.append(payload)
if not payloads:
return {"present": False, "checks": []}
if len(payloads) != 1:
errors.append("Boot/Default 中存在多个内嵌 partition JSON,无法唯一核对")
return {"present": True, "count": len(payloads), "checks": []}
table = payloads[0].get("partitionTable")
checks: list[dict[str, object]] = []
if not isinstance(table, list) or len(table) != len(rows):
errors.append("Boot/Default 内嵌 partition JSON 分区数量不一致")
return {"present": True, "checks": checks}
for index, row in enumerate(rows):
item = table[index]
actual_name = item.get("name") if isinstance(item, dict) else None
try:
actual_size = size_bytes(item.get("size")) if isinstance(item, dict) else None
if actual_size is None:
actual_size = row["size_bytes"]
except ValueError:
actual_size = None
matched = actual_name == row["name"] and actual_size == row["size_bytes"]
checks.append({"index": index, "expected_name": row["name"], "actual_name": actual_name, "expected_size": row["size_bytes"], "actual_size": actual_size, "matched": matched})
if not matched:
errors.append(f"内嵌 partition JSON[{index}] 未同步")
return {"present": True, "checks": checks}
def validate(
psp_json: Path,
boot_default: Path,
release_py: Path | None,
leb_size: int = 126976,
peb_size: int = 131072,
reserve_pebs: int = 8,
) -> dict[str, object]:
errors: list[str] = []
psp_json = psp_json.expanduser().resolve(strict=True)
boot_default = boot_default.expanduser().resolve(strict=True)
if psp_json.is_symlink() or boot_default.is_symlink():
raise ValueError("分区输入必须是普通文件,不能是软链接")
flash, rows = partition_rows(psp_json)
boot_text = boot_default.read_text(encoding="utf-8", errors="replace")
boot_values = assignments(boot_text)
mirror_checks: list[dict[str, object]] = []
for row in rows:
alias = str(row["boot_name"])
keys = {
"offset": f"{alias}_PART_OFFSET".casefold(),
"size": f"{alias}_PART_SIZE".casefold(),
}
for kind, key in keys.items():
expected = int(row[f"{kind}_bytes"])
raw = boot_values.get(key)
try:
actual = parse_number(raw) if raw is not None else None
except ValueError:
actual = None
matched = actual == expected
mirror_checks.append({"partition": row["name"], "field": kind, "key": key, "expected": expected, "actual": actual, "matched": matched})
if not matched:
errors.append(f"Boot/Default {key} 未同步:期望 {expected:#x},实际 {raw!r}")
data = next((row for row in rows if row["name"] == "data"), None)
mtdparts = check_mtdparts(boot_text, rows, errors)
embedded_partition = check_embedded_partition_json(boot_text, rows, errors)
data_check = None
if release_py is not None:
release_py = release_py.expanduser().resolve(strict=True)
calls: set[int] = set()
for line in release_py.read_text(encoding="utf-8", errors="replace").splitlines():
if line.lstrip().startswith("#"):
continue
calls.update(int(value) for value in re.findall(r"mkAiData\.py\s+(\d+)\b", line))
if len(calls) != 1:
errors.append("release.py 必须唯一确定一个有效 mkAiData.py 容量参数")
elif data is None:
errors.append("PSP 分区表缺少 data")
else:
count = next(iter(calls))
requested = count * leb_size
reserved = reserve_pebs * peb_size
capacity = int(data["size_bytes"])
passed = requested + reserved <= capacity
data_check = {"parameter": count, "leb_size": leb_size, "requested_bytes": requested, "reserve_pebs": reserve_pebs, "reserved_bytes": reserved, "partition_bytes": capacity, "passed": passed}
if not passed:
errors.append(f"mkAiData.py {count} 需要 {requested} 字节并保留 {reserved} 字节,超过 DATA {capacity} 字节")
return {
"schema_version": 1,
"passed": not errors,
"psp_json": str(psp_json),
"boot_default": str(boot_default),
"release_py": str(release_py) if release_py else None,
"flash_size_bytes": flash,
"partitions": rows,
"boot_mirror_checks": mirror_checks,
"mtdparts": mtdparts,
"embedded_partition_json": embedded_partition,
"data_capacity": data_check,
"errors": errors,
}
def write_json(path: Path, payload: dict[str, object]) -> None:
path = path.expanduser().resolve(strict=False)
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2)
handle.write("\n")
temporary = Path(handle.name)
os.replace(temporary, path)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--psp-json", type=Path, required=True)
parser.add_argument("--boot-default", type=Path, required=True)
parser.add_argument("--release-py", type=Path)
parser.add_argument("--leb-size", type=int, default=126976)
parser.add_argument("--peb-size", type=int, default=131072)
parser.add_argument("--reserve-pebs", type=int, default=8)
parser.add_argument("--output", type=Path)
args = parser.parse_args()
try:
result = validate(args.psp_json, args.boot_default, args.release_py, args.leb_size, args.peb_size, args.reserve_pebs)
if args.output:
write_json(args.output, result)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0 if result["passed"] else 2
except (OSError, UnicodeError, ValueError, json.JSONDecodeError) as exc:
print(json.dumps({"passed": False, "error": str(exc)}, ensure_ascii=False, indent=2))
return 2
if __name__ == "__main__":
raise SystemExit(main())