diff --git a/.gitignore b/.gitignore index 73f36f5bc5b78..be04ab3aaefaf 100644 --- a/.gitignore +++ b/.gitignore @@ -175,3 +175,9 @@ sphinx_*/ *.dtb* *.scr extboot* + +# DLCVCAM local build / test artifacts (not sources) +/bad_packages/ +*.img.sha256 +*.img.dlcvcam.json # legacy; no longer generated +/boot-rk3576-*.img diff --git "a/DLCVCAM\345\206\205\346\240\270\347\274\226\350\257\221\344\270\216\346\233\264\346\226\260\346\223\215\344\275\234\346\211\213\345\206\214.md" "b/DLCVCAM\345\206\205\346\240\270\347\274\226\350\257\221\344\270\216\346\233\264\346\226\260\346\223\215\344\275\234\346\211\213\345\206\214.md" index bafffd7c9b044..3e2ac3d1e88d9 100644 --- "a/DLCVCAM\345\206\205\346\240\270\347\274\226\350\257\221\344\270\216\346\233\264\346\226\260\346\223\215\344\275\234\346\211\213\345\206\214.md" +++ "b/DLCVCAM\345\206\205\346\240\270\347\274\226\350\257\221\344\270\216\346\233\264\346\226\260\346\223\215\344\275\234\346\211\213\345\206\214.md" @@ -113,36 +113,118 @@ sudo rkdeveloptool rd # 重启 --- -## 4. 完整编译更新脚本 +## 4. 完整编译更新脚本(推荐) -在编译主机上创建 `build_bootimg.sh`: +> **推荐日常使用仓库根目录的 `build_bootimg.sh`**(见下)。 +> 防砖(U-Boot FIT fallback / boot-try、recovery 金镜像、confirm 服务)**不在本仓库实现**: +> - U-Boot:https://github.com/dl-cv/u-boot/pull/1 (`tools/dlcvcam/` 含 confirm 脚本协议副本) +> - CamOS Admin:seed recovery、安装 boot-try / WDT、维护脚本 `dlcvcam_sync_boot_to_recovery.sh` + +### 4.1 一键编译 ```bash -#!/bin/bash -set -e +cd /path/to/kernel # 本仓库 -cd /home/ypw/kernel +./build_bootimg.sh -echo "[1/4] 增量编译内核..." -make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- Image dtbs modules -j8 +# 内存较小时降低并行度 +./build_bootimg.sh -j4 -echo "[2/4] 生成 Image.lz4..." -lz4 -f arch/arm64/boot/Image arch/arm64/boot/Image.lz4 +# 强制重新 defconfig + 合并裁剪配置 +./build_bootimg.sh --force-config -echo "[3/4] 打包 boot.img..." -BOOT_ITS=boot.its ./scripts/mkimg --dtb dlcvcam-rk3576.dtb +# 删除 .config 后走完整初次配置 +./build_bootimg.sh --clean-config -echo "[4/4] 提示:如需更新内核模块(如 WiFi/BT 驱动等),请执行:" -echo " sudo make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- modules_install INSTALL_MOD_PATH=/path/to/rootfs" +# 不自动改写 DLCVCAM_BUILD_VERSION(沿用文件当前值) +./build_bootimg.sh --no-bump +``` -echo "" -echo "完成!" -ls -lh boot.img +### 4.2 脚本会做什么 -echo "" -echo "下一步:通过 RKDevTool 或 rkdeveloptool 烧录 boot.img" +1. **构建编号**:按 `DLCVCAM_BUILD_VERSION`(`YYYYMMDDNN`)规则自动 bump(见 §2.1)。 +2. **配置**:无合适 `.config` 时 `lubancat_linux_rk3576_defconfig` + `dlcvcam_rk3576_kernel_cut.config`。 +3. **编译打包**:`Image` / `dtbs` / `modules` → `Image.lz4` → `boot.img`(FIT)。 +4. **交付文件名**(同时保留根目录 `boot.img`): + +```text +boot-rk3576-6.1.99--.img ``` +5. **完整性 sidecar(无签名,仅整包 hash)**:为 `boot.img` 与交付镜像各生成: + +```text +<镜像>.sha256 # 整包 SHA-256(sha256sum 格式) +``` + +交付物就是 **`.img` + `.sha256`**,不再生成 `.dlcvcam.json`。 + +FIT 镜像本身在打包时已写入各 image 的内嵌 `sha256`(见 `boot.its`)。校验时两层分工: + +| 检查 | 依据 | 作用 | +|------|------|------| +| 整包 SHA-256 | `.sha256` | 发现下载截断、拷贝损坏、**传错成另一份合法旧包** | +| FIT 内嵌 hash | boot.img 内 fdt/kernel/resource 的 sha256 节点 | 发现 payload 被改;**不依赖 sidecar** 也能查内容损坏 | + +`.sha256` **不能替代签名**,只作运维防呆。 + +### 4.3 编译完成后 + +```bash +ls -lh boot.img boot-rk3576-*.img boot-rk3576-*.img.sha256 +mkimage -l boot.img + +# 本地再验一次(打包脚本结束时已自检) +python3 scripts/dlcvcam_verify_bootimg.py verify boot-rk3576-*.img --require-sidecar +``` + +### 4.4 板卡上传后、烧录前校验(推荐) + +把**镜像 + `.sha256`(建议连同校验脚本)**一起拷到板子,例如 `/tmp`: + +```bash +# 在板卡上(需 python3) +python3 scripts/dlcvcam_verify_bootimg.py verify /tmp/boot-rk3576-....img \ + --require-sidecar +# 退出码 0 才允许烧录;非 0 则重新传输,不要 wl/dd + +# 无 python 时也可用: +# sha256sum -c /tmp/boot-rk3576-....img.sha256 +``` + +只带了镜像、没有 `.sha256` 时,仍可只验 FIT 内嵌 hash: + +```bash +python3 scripts/dlcvcam_verify_bootimg.py verify /tmp/boot-rk3576-....img +``` + +对已有镜像补生成 `.sha256`(开发机): + +```bash +python3 scripts/dlcvcam_verify_bootimg.py gen-sidecar boot-rk3576-....img +``` + +下一步:校验通过后,用第 3 节的 RKDevTool 或 `rkdeveloptool` 烧录(或经 **CamOS Admin** 内核升级面板)。 + +### 4.5 防砖与 recovery(外链,本仓不提供脚本) + +| 能力 | 仓库 / 位置 | +|------|-------------| +| boot FIT 坏 → 读 recovery | [u-boot#1](https://github.com/dl-cv/u-boot/pull/1) `fit.c` | +| 合法 FIT 起不来 → boot-try 计数 | 同上;misc@48KiB `DCBT` | +| multi-user 后清计数 | u-boot `tools/dlcvcam/` + **Admin** 安装 confirm unit | +| recovery 金镜像 status/sync/restore | **CamOS Admin** `dlcvcam_sync_boot_to_recovery.sh` | +| 硬挂复位 | Admin `10-dlcvcam-watchdog.conf`(`RuntimeWatchdogSec`) | +| 升级前 seed recovery / 只写 boot | Admin 内核升级逻辑 | + +板端完整说明(sync / boot-try / confirm / 看门狗 / 检查表):CamOS `docs/admin/DLCVCAM内核防砖与金镜像维护.md`。 + +**原则(产品路径):** + +- 日常升级**只写 boot**;recovery 为金镜像,稳定后才手工提升。 +- 不使用完整 userspace bootguard 状态机。 +- 板端安装与操作以 Admin 上述文档及 u-boot `tools/dlcvcam/README.md` 为准。 + --- ## 附录:常用排查命令 @@ -151,6 +233,9 @@ echo "下一步:通过 RKDevTool 或 rkdeveloptool 烧录 boot.img" # 查看 FIT 镜像内容 mkimage -l boot.img +# 完整性校验(整包 + FIT 内嵌 hash,无签名) +python3 scripts/dlcvcam_verify_bootimg.py verify boot.img --require-sidecar + # 查看 eMMC 分区表 fdisk -l /dev/mmcblk0 diff --git a/build_bootimg.sh b/build_bootimg.sh new file mode 100755 index 0000000000000..a928a6902eea8 --- /dev/null +++ b/build_bootimg.sh @@ -0,0 +1,290 @@ +#!/bin/bash +# DLCVCAM RK3576 内核自动编译脚本 +# - 自动判断初次配置 / 增量编译 +# - 若 DLCVCAM_BUILD_VERSION 对应提交与当前 HEAD 不同,则按当天日期自动 bump +# - 产出带构建编号与短提交的 boot 镜像:boot-rk3576-6.1.99-YYYYMMDDNN-.img +# - 同时生成整包完整性 sidecar(不使用签名,仅 .sha256): +# .sha256 整包 SHA-256(防传错包/截断;FIT 内嵌 hash 另由 verify 检查) +# 板端烧录前:python3 scripts/dlcvcam_verify_bootimg.py verify --require-sidecar +# +# 用法: +# ./build_bootimg.sh # 自动判断配置并编译 +# ./build_bootimg.sh --force-config # 强制重新 defconfig + merge +# ./build_bootimg.sh --no-bump # 不自动更新 DLCVCAM_BUILD_VERSION +# ./build_bootimg.sh -j4 # 指定并行度 +# ./build_bootimg.sh --clean-config # 删除 .config 后走初次配置 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "${SCRIPT_DIR}" + +ARCH=arm64 +CROSS_COMPILE=aarch64-linux-gnu- +DEFCONFIG=lubancat_linux_rk3576_defconfig +CUT_CONFIG=arch/arm64/configs/dlcvcam_rk3576_kernel_cut.config +DTB_NAME=dlcvcam-rk3576.dtb +BUILD_VERSION_FILE=DLCVCAM_BUILD_VERSION +KERNEL_VER_PREFIX="6.1.99" +JOBS="$(nproc 2>/dev/null || echo 8)" +FORCE_CONFIG=0 +NO_BUMP=0 +CLEAN_CONFIG=0 + +log() { printf '\033[1;34m[%s]\033[0m %s\n' "$(date '+%H:%M:%S')" "$*"; } +ok() { printf '\033[1;32m[%s]\033[0m %s\n' "$(date '+%H:%M:%S')" "$*"; } +warn() { printf '\033[1;33m[%s]\033[0m %s\n' "$(date '+%H:%M:%S')" "$*" >&2; } +die() { printf '\033[1;31m[%s]\033[0m %s\n' "$(date '+%H:%M:%S')" "$*" >&2; exit 1; } + +usage() { + sed -n '2,12p' "$0" | sed 's/^# \?//' + exit 0 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) usage ;; + --force-config) FORCE_CONFIG=1; shift ;; + --clean-config) CLEAN_CONFIG=1; shift ;; + --no-bump) NO_BUMP=1; shift ;; + -j*) + if [[ "$1" == "-j" ]]; then + JOBS="${2:?-j 需要并行数}" + shift 2 + else + JOBS="${1#-j}" + shift + fi + ;; + *) + die "未知参数: $1 (见 --help)" + ;; + esac +done + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || die "缺少命令: $1" +} + +need_cmd make +need_cmd lz4 +need_cmd git +need_cmd "${CROSS_COMPILE}gcc" + +# ---------- 构建编号:相对上次提交该文件的 commit 是否变化 ---------- +# 规则: +# 1. 若 DLCVCAM_BUILD_VERSION 相对 HEAD 有未提交修改,且内容已是合法编号,则沿用工作区值 +# 2. 否则看「最后一次提交该文件的 commit」是否等于当前 HEAD: +# - 相等:说明本提交就是 bump 提交,沿用文件内容 +# - 不等:说明 HEAD 相对上次 bump 又有新提交,按当天日期自动 bump(同日递增 NN) +bump_build_version_if_needed() { + local today current last_bump_commit head_commit date_part seq new_ver + + [[ -f "${BUILD_VERSION_FILE}" ]] || die "缺少 ${BUILD_VERSION_FILE}" + + current="$(tr -d '[:space:]' < "${BUILD_VERSION_FILE}")" + [[ "${current}" =~ ^[0-9]{8}(0[1-9]|[1-9][0-9])$ ]] \ + || die "${BUILD_VERSION_FILE} 格式非法: '${current}'(需要 YYYYMMDDNN,NN=01..99)" + + if [[ "${NO_BUMP}" -eq 1 ]]; then + warn "跳过版本自动 bump(--no-bump),使用: ${current}" + echo "${current}" + return + fi + + # 工作区已改过该文件:尊重用户/上次脚本写好的值 + if ! git diff --quiet -- "${BUILD_VERSION_FILE}" 2>/dev/null \ + || ! git diff --cached --quiet -- "${BUILD_VERSION_FILE}" 2>/dev/null; then + log "${BUILD_VERSION_FILE} 工作区已修改,沿用: ${current}" + echo "${current}" + return + fi + + head_commit="$(git rev-parse HEAD)" + last_bump_commit="$(git log -1 --format='%H' -- "${BUILD_VERSION_FILE}" 2>/dev/null || true)" + + if [[ -n "${last_bump_commit}" && "${last_bump_commit}" == "${head_commit}" ]]; then + log "${BUILD_VERSION_FILE} 已在当前 HEAD 提交,沿用: ${current}" + echo "${current}" + return + fi + + today="$(date +%Y%m%d)" + date_part="${current:0:8}" + seq="${current:8:2}" + # 去掉前导零做算术 + seq=$((10#${seq})) + + if [[ "${date_part}" == "${today}" ]]; then + seq=$((seq + 1)) + if [[ "${seq}" -gt 99 ]]; then + die "当天构建序号已超过 99,请手工处理 ${BUILD_VERSION_FILE}" + fi + new_ver="$(printf '%s%02d' "${today}" "${seq}")" + else + new_ver="${today}01" + fi + + printf '%s\n' "${new_ver}" > "${BUILD_VERSION_FILE}" + ok "HEAD 相对上次 ${BUILD_VERSION_FILE} 提交已变化,自动 bump: ${current} -> ${new_ver}" + echo "${new_ver}" +} + +# ---------- 初次配置 vs 增量 ---------- +need_initial_config() { + [[ "${FORCE_CONFIG}" -eq 1 ]] && return 0 + [[ "${CLEAN_CONFIG}" -eq 1 ]] && return 0 + [[ ! -f .config ]] && return 0 + + # 关键配置标记:本地版本与 ARM64,避免误用其它板级 .config + if ! grep -q '^CONFIG_ARM64=y' .config 2>/dev/null; then + warn ".config 不是 ARM64 配置,将重新配置" + return 0 + fi + if ! grep -q 'CONFIG_LOCALVERSION="-rk3576"' .config 2>/dev/null; then + # 裁剪配置合并后通常带 -rk3576;没有也不强制失败,仅提示 + warn ".config 未包含 CONFIG_LOCALVERSION=\"-rk3576\",仍按增量处理(可用 --force-config)" + fi + return 1 +} + +do_initial_config() { + log "初次配置:${DEFCONFIG} + ${CUT_CONFIG}" + if [[ "${CLEAN_CONFIG}" -eq 1 && -f .config ]]; then + rm -f .config .config.old + fi + + make ARCH="${ARCH}" CROSS_COMPILE="${CROSS_COMPILE}" "${DEFCONFIG}" + [[ -f "${CUT_CONFIG}" ]] || die "缺少裁剪配置: ${CUT_CONFIG}" + ./scripts/kconfig/merge_config.sh -m .config "${CUT_CONFIG}" + make ARCH="${ARCH}" CROSS_COMPILE="${CROSS_COMPILE}" olddefconfig + ok "配置完成" +} + +do_build() { + log "编译 Image / dtbs / modules (-j${JOBS})..." + make ARCH="${ARCH}" CROSS_COMPILE="${CROSS_COMPILE}" \ + Image dtbs modules -j"${JOBS}" + + log "压缩 Image -> Image.lz4 ..." + lz4 -f arch/arm64/boot/Image arch/arm64/boot/Image.lz4 + + log "打包 boot.img (DTB=${DTB_NAME})..." + BOOT_ITS=boot.its ./scripts/mkimg --dtb "${DTB_NAME}" + + [[ -f boot.img ]] || die "未生成 boot.img" +} + +is_worktree_dirty_except_build_version() { + # 忽略 DLCVCAM_BUILD_VERSION 本身(脚本可能刚 bump 过) + # 以及常见编译产物,避免误标 dirty + local line path + while IFS= read -r line; do + [[ -z "${line}" ]] && continue + path="${line:3}" + path="${path#\"}" + path="${path%\"}" + # rename: "old -> new" + if [[ "${path}" == *" -> "* ]]; then + path="${path##* -> }" + fi + case "${path}" in + "${BUILD_VERSION_FILE}") continue ;; + boot.img|resource.img|zboot.img|out|out/*) continue ;; + boot.img.sha256|boot.img.dlcvcam.json) continue ;; + boot-rk3576-*.img|boot-rk3576-*.img.sha256|boot-rk3576-*.img.dlcvcam.json) continue ;; + # legacy *.dlcvcam.json ignored if leftover from older builds + arch/arm64/boot/Image|arch/arm64/boot/Image.lz4) continue ;; + .config|.config.old) continue ;; + bad_packages|bad_packages/*) continue ;; + esac + return 0 + done < <(git status --porcelain --untracked-files=no 2>/dev/null || true) + return 1 +} + +write_bootimg_sha256_sidecar() { + # 只生成整包 .sha256;板端烧录前用 scripts/dlcvcam_verify_bootimg.py 做 + # 整包 SHA + FIT 内嵌 hash(无签名) + local img="$1" + local verify_py="${SCRIPT_DIR}/scripts/dlcvcam_verify_bootimg.py" + + [[ -f "${img}" ]] || die "write_bootimg_sha256_sidecar: 缺少 ${img}" + + # 清理旧版 JSON 清单(若存在),避免误当交付物 + rm -f "${img}.dlcvcam.json" + + if [[ -f "${verify_py}" ]] && command -v python3 >/dev/null 2>&1; then + python3 "${verify_py}" gen-sidecar "${img}" \ + || die "生成 .sha256 失败: ${img}" + # 自检:刚生成的包必须能通过 FIT 内嵌 hash + 整包 sha256 + python3 "${verify_py}" verify "${img}" --require-sidecar \ + || die "打包后自检失败(不应发生): ${img}" + return + fi + + # 兜底:无 python 时只写整包 sha256(无法做 FIT 自检) + warn "python3 或 scripts/dlcvcam_verify_bootimg.py 不可用,仅写入 ${img}.sha256(跳过 FIT 自检)" + need_cmd sha256sum + sha256sum "${img}" | awk -v n="$(basename "${img}")" '{print $1 " " n}' > "${img}.sha256" +} + +package_named_image() { + local build_ver short_sha out_name kernelrelease build_id git_desc + + build_ver="$(tr -d '[:space:]' < "${BUILD_VERSION_FILE}")" + short_sha="$(git rev-parse --short=8 HEAD)" + # 除版本文件/编译产物外还有改动时加 -dirty,避免与干净提交产物混淆 + if is_worktree_dirty_except_build_version; then + short_sha="${short_sha}-dirty" + fi + + out_name="boot-rk3576-${KERNEL_VER_PREFIX}-${build_ver}-${short_sha}.img" + cp -f boot.img "${out_name}" + + if command -v mkimage >/dev/null 2>&1; then + log "FIT 信息:" + mkimage -l boot.img || true + fi + + kernelrelease="$(make -s ARCH="${ARCH}" kernelrelease 2>/dev/null || true)" + build_id="$(make -s ARCH="${ARCH}" dlcvcam-build-version 2>/dev/null || echo "${build_ver}")" + git_desc="$(git rev-parse --short HEAD) ($(git rev-parse --abbrev-ref HEAD))" + + log "生成整包 .sha256 并自检(FIT 内嵌 hash + 整包 sha256)..." + write_bootimg_sha256_sidecar "boot.img" + write_bootimg_sha256_sidecar "${out_name}" + + ok "完成" + echo + echo " kernelrelease : ${kernelrelease:-n/a}" + echo " build version : ${build_id}" + echo " git : ${git_desc}" + echo " boot.img : $(ls -lh boot.img | awk '{print $5}')" + echo " 交付镜像 : ${out_name} ($(ls -lh "${out_name}" | awk '{print $5}'))" + echo " 整包校验 : ${out_name}.sha256" + echo + echo "烧录前在板卡上校验(不验签,仅 hash):" + echo " python3 scripts/dlcvcam_verify_bootimg.py verify ${out_name} --require-sidecar" + echo " # 或只验 FIT 内嵌 sha256(无 .sha256 时):" + echo " python3 scripts/dlcvcam_verify_bootimg.py verify ${out_name}" + echo + echo "下一步:校验通过后再用 RKDevTool / rkdeveloptool 烧录 ${out_name}" + echo " (或直接烧录 boot.img,内容相同)" +} + +# ---------- main ---------- +log "工作目录: ${SCRIPT_DIR}" +log "交叉编译: ${CROSS_COMPILE}gcc ($(${CROSS_COMPILE}gcc -dumpmachine 2>/dev/null || echo '?'))" + +BUILD_VER="$(bump_build_version_if_needed)" +log "本次 KBUILD_BUILD_VERSION / DLCVCAM_BUILD_VERSION = ${BUILD_VER}" + +if need_initial_config; then + do_initial_config +else + log "检测到已有 .config,走增量编译" +fi + +do_build +package_named_image diff --git a/scripts/dlcvcam_verify_bootimg.py b/scripts/dlcvcam_verify_bootimg.py new file mode 100755 index 0000000000000..c7c4dc9b29386 --- /dev/null +++ b/scripts/dlcvcam_verify_bootimg.py @@ -0,0 +1,498 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-2.0 +""" +DLCVCAM boot.img (FIT) integrity checker — hash only, no signature. + +Verifies: + 1) Optional whole-file SHA-256 against .sha256 or --expect-sha256 + 2) FIT/FDT structure parse + 3) Embedded per-image hash nodes (fdt / kernel / resource, ...) + +Delivery sidecar is only: + .sha256 whole-file SHA-256 (sha256sum format) + +Exit codes: + 0 OK + 1 verification failed + 2 usage / I/O / parse error +""" + +from __future__ import annotations + +import argparse +import hashlib +import hmac +import json +import struct +import sys +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + + +FDT_MAGIC = 0xD00DFEED +FDT_BEGIN_NODE = 1 +FDT_END_NODE = 2 +FDT_PROP = 3 +FDT_NOP = 4 +FDT_END = 9 + +SUPPORTED_HASH = { + "sha1": (hashlib.sha1, 20), + "sha256": (hashlib.sha256, 32), + "sha512": (hashlib.sha512, 64), +} + + +class VerifyError(Exception): + pass + + +def eprint(*args: object) -> None: + print(*args, file=sys.stderr) + + +def sha256_file(path: Path, chunk: int = 1024 * 1024) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + while True: + b = f.read(chunk) + if not b: + break + h.update(b) + return h.hexdigest() + + +def parse_sha256_sidecar(text: str) -> str: + """ + Accept: + + + * + """ + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + part = line.split()[0].strip().lower() + if len(part) == 64 and all(c in "0123456789abcdef" for c in part): + return part + raise VerifyError("sidecar 中未找到合法 sha256") + + +def align4(n: int) -> int: + return (n + 3) & ~3 + + +def parse_fdt_tree(data: bytes, fdt_offset: int = 0) -> Dict[str, Dict[str, bytes]]: + if len(data) < fdt_offset + 40: + raise VerifyError("文件过短,无法读取 FDT 头") + + magic, totalsize, off_dt_struct, off_dt_strings, _off_mem, version, _last, _cpu, size_dt_strings, size_dt_struct = struct.unpack_from( + ">10I", data, fdt_offset + ) + if magic != FDT_MAGIC: + raise VerifyError(f"非 FIT/FDT 魔数: 0x{magic:08x} (期望 0xd00dfeed)") + if version < 16: + raise VerifyError(f"不支持的 FDT version: {version}") + if totalsize < 40 or fdt_offset + totalsize > len(data): + # external-data FIT: totalsize 只覆盖 header tree,payload 在后面 —— 允许 totalsize 小于文件 + if totalsize < 40: + raise VerifyError(f"非法 FDT totalsize: {totalsize}") + + struct_base = fdt_offset + off_dt_struct + strings_base = fdt_offset + off_dt_strings + struct_end = struct_base + size_dt_struct + strings_end = strings_base + size_dt_strings + if struct_end > len(data) or strings_end > len(data): + raise VerifyError("FDT struct/strings 超出文件范围") + + def get_string(nameoff: int) -> str: + start = strings_base + nameoff + if start >= strings_end: + raise VerifyError("FDT 字符串偏移越界") + end = data.find(b"\x00", start, strings_end) + if end < 0: + raise VerifyError("FDT 字符串缺少 NUL") + return data[start:end].decode("ascii", "replace") + + pos = struct_base + depth = 0 + stack: List[str] = [] + props: Dict[str, Dict[str, bytes]] = {} + + while pos + 4 <= struct_end: + tag = struct.unpack_from(">I", data, pos)[0] + pos += 4 + if tag == FDT_BEGIN_NODE: + end = data.find(b"\x00", pos, struct_end) + if end < 0: + raise VerifyError("FDT 节点名缺少 NUL") + name = data[pos:end].decode("ascii", "replace") + pos = align4(end + 1) + depth += 1 + if depth == 1: + full = "/" + else: + parent = stack[-1] + full = f"{parent}{name}" if parent.endswith("/") else f"{parent}/{name}" + if parent == "/": + full = f"/{name}" + stack.append(full) + props.setdefault(full, {}) + elif tag == FDT_END_NODE: + if not stack: + raise VerifyError("FDT END_NODE 不匹配") + stack.pop() + depth -= 1 + elif tag == FDT_PROP: + if pos + 8 > struct_end: + raise VerifyError("FDT PROP 头截断") + plen, nameoff = struct.unpack_from(">II", data, pos) + pos += 8 + if pos + plen > struct_end: + raise VerifyError("FDT PROP 值截断") + pname = get_string(nameoff) + pval = data[pos : pos + plen] + pos = align4(pos + plen) + if not stack: + raise VerifyError("FDT PROP 无当前节点") + props[stack[-1]][pname] = pval + elif tag == FDT_NOP: + continue + elif tag == FDT_END: + break + else: + raise VerifyError(f"未知 FDT tag {tag} @ {pos - 4}") + + return props + + +def _cstr(val: bytes) -> str: + return val.split(b"\x00", 1)[0].decode("ascii", "replace") + + +def _u32(val: bytes) -> Optional[int]: + if len(val) != 4: + return None + return struct.unpack(">I", val)[0] + + +def collect_fit_images(props: Dict[str, Dict[str, bytes]]) -> List[Dict[str, Any]]: + images: List[Dict[str, Any]] = [] + for path, p in props.items(): + # image nodes: /images/ (exactly one name segment under /images) + if not path.startswith("/images/"): + continue + rest = path[len("/images/") :] + if not rest or "/" in rest: + continue + name = rest + img: Dict[str, Any] = { + "name": name, + "path": path, + "type": _cstr(p["type"]) if "type" in p else "", + "compression": _cstr(p["compression"]) if "compression" in p else "", + "data_position": _u32(p["data-position"]) if "data-position" in p else None, + "data_offset": _u32(p["data-offset"]) if "data-offset" in p else None, + "data_size": _u32(p["data-size"]) if "data-size" in p else None, + "hashes": [], + } + # inline data property (rare for our external FIT) + if "data" in p: + img["inline_data_len"] = len(p["data"]) + img["inline_data"] = p["data"] + + # hash subnodes: /images//hash or hash@1 ... + prefix = path + "/" + for hpath, hp in props.items(): + if not hpath.startswith(prefix): + continue + sub = hpath[len(prefix) :] + if "/" in sub: + continue + if not (sub == "hash" or sub.startswith("hash@") or sub.startswith("hash-")): + continue + if "algo" not in hp or "value" not in hp: + continue + img["hashes"].append( + { + "node": sub, + "algo": _cstr(hp["algo"]).lower(), + "value": hp["value"], + } + ) + images.append(img) + images.sort(key=lambda x: x["name"]) + return images + + +def resolve_image_blob(data: bytes, img: Dict[str, Any], fdt_totalsize_hint: int) -> bytes: + if "inline_data" in img: + return img["inline_data"] + + size = img.get("data_size") + if size is None: + raise VerifyError(f"镜像 {img['name']}: 缺少 data-size/data") + + pos = img.get("data_position") + if pos is not None: + end = pos + size + if end > len(data): + raise VerifyError( + f"镜像 {img['name']}: data-position/size 越界 ({pos}+{size} > {len(data)})" + ) + return data[pos:end] + + off = img.get("data_offset") + if off is not None: + # external data: offset relative to end of FIT header (aligned external area). + # Our images use absolute data-position; data-offset kept as fallback only. + pos2 = fdt_totalsize_hint + off + end = pos2 + size + if end > len(data): + raise VerifyError( + f"镜像 {img['name']}: data-offset/size 越界 ({pos2}+{size} > {len(data)})" + ) + return data[pos2:end] + + raise VerifyError(f"镜像 {img['name']}: 无 data / data-position / data-offset") + + +def verify_fit_hashes(data: bytes) -> Tuple[bool, List[str], List[Dict[str, Any]]]: + props = parse_fdt_tree(data, 0) + magic, totalsize = struct.unpack_from(">2I", data, 0) + assert magic == FDT_MAGIC + + images = collect_fit_images(props) + if not images: + raise VerifyError("FIT 中未找到 /images/* 节点") + + messages: List[str] = [] + details: List[Dict[str, Any]] = [] + all_ok = True + + for img in images: + entry: Dict[str, Any] = { + "name": img["name"], + "type": img["type"], + "data_size": img.get("data_size"), + "data_position": img.get("data_position"), + "hashes": [], + } + try: + blob = resolve_image_blob(data, img, totalsize) + except VerifyError as ex: + all_ok = False + messages.append(f"[FAIL] {img['name']}: {ex}") + entry["error"] = str(ex) + details.append(entry) + continue + + if not img["hashes"]: + all_ok = False + messages.append(f"[FAIL] {img['name']}: 无内嵌 hash 节点") + entry["error"] = "no hash node" + details.append(entry) + continue + + for h in img["hashes"]: + algo = h["algo"] + val = h["value"] + hinfo: Dict[str, Any] = { + "node": h["node"], + "algo": algo, + "expected": val.hex(), + } + if algo not in SUPPORTED_HASH: + all_ok = False + hinfo["ok"] = False + hinfo["error"] = f"不支持的 hash 算法: {algo}" + messages.append(f"[FAIL] {img['name']}/{h['node']}: 不支持算法 {algo}") + entry["hashes"].append(hinfo) + continue + factory, digest_len = SUPPORTED_HASH[algo] + if len(val) != digest_len: + # some FIT blobs store value with padding + if len(val) > digest_len and all(b == 0 for b in val[digest_len:]): + val = val[:digest_len] + hinfo["expected"] = val.hex() + else: + all_ok = False + hinfo["ok"] = False + hinfo["error"] = f"hash 长度 {len(val)} != {digest_len}" + messages.append( + f"[FAIL] {img['name']}/{h['node']}: hash 长度异常 {len(val)}" + ) + entry["hashes"].append(hinfo) + continue + actual = factory(blob).digest() + hinfo["actual"] = actual.hex() + ok = hmac.compare_digest(actual, val) + hinfo["ok"] = ok + if ok: + messages.append( + f"[OK] {img['name']}/{h['node']} {algo} ({len(blob)} bytes)" + ) + else: + all_ok = False + messages.append( + f"[FAIL] {img['name']}/{h['node']} {algo} mismatch\n" + f" expect {val.hex()}\n" + f" actual {actual.hex()}" + ) + entry["hashes"].append(hinfo) + details.append(entry) + + return all_ok, messages, details + + +def load_expect_sha256(img: Path, explicit: Optional[str], sidecar: Optional[Path]) -> Optional[str]: + if explicit: + v = explicit.strip().lower() + if len(v) != 64 or any(c not in "0123456789abcdef" for c in v): + raise VerifyError("--expect-sha256 不是 64 位 hex") + return v + candidates: List[Path] = [] + if sidecar: + candidates.append(sidecar) + # Preferred: .img.sha256 + candidates.append(Path(str(img) + ".sha256")) + for c in candidates: + if c.is_file(): + return parse_sha256_sidecar(c.read_text(encoding="utf-8", errors="replace")) + return None + + +def cmd_verify(args: argparse.Namespace) -> int: + img = Path(args.image) + if not img.is_file(): + eprint(f"文件不存在: {img}") + return 2 + + size = img.stat().st_size + if size <= 0: + eprint("[FAIL] 空文件") + return 1 + + data = img.read_bytes() + file_sha = hashlib.sha256(data).hexdigest() + print(f"file : {img}") + print(f"size : {size}") + print(f"sha256 : {file_sha}") + + failed = False + + # whole-file hash + try: + expect = load_expect_sha256( + img, args.expect_sha256, Path(args.sha256_file) if args.sha256_file else None + ) + except VerifyError as ex: + eprint(f"[ERR] {ex}") + return 2 + + if expect is None: + if args.require_sidecar: + eprint("[FAIL] 未提供/未找到整包 .sha256(指定了 --require-sidecar)") + return 1 + print("whole-file: (no sidecar / --expect-sha256, skip)") + else: + if hmac.compare_digest(bytes.fromhex(expect), bytes.fromhex(file_sha)): + print("[OK] whole-file sha256 matches") + else: + failed = True + print("[FAIL] whole-file sha256 mismatch") + print(f" expect {expect}") + print(f" actual {file_sha}") + + # FIT internal hashes + try: + ok, messages, details = verify_fit_hashes(data) + except VerifyError as ex: + eprint(f"[FAIL] FIT 解析/校验错误: {ex}") + return 1 + + print("fit-hash :") + for m in messages: + print(f" {m}") + if not ok: + failed = True + + if args.json_out: + Path(args.json_out).write_text( + json.dumps( + { + "file": str(img), + "size": size, + "sha256": file_sha, + "whole_file_ok": (expect is None) or (expect == file_sha), + "fit_ok": ok, + "fit_images": details, + }, + indent=2, + ensure_ascii=False, + ) + + "\n", + encoding="utf-8", + ) + + if failed: + print("RESULT : FAIL") + return 1 + print("RESULT : OK") + return 0 + + +def cmd_gen_sidecar(args: argparse.Namespace) -> int: + """Write whole-file .sha256 only (no JSON manifest).""" + img = Path(args.image) + if not img.is_file(): + eprint(f"文件不存在: {img}") + return 2 + + file_sha = sha256_file(img) + sha_path = Path(args.sha256_file) if args.sha256_file else Path(str(img) + ".sha256") + sha_path.write_text(f"{file_sha} {img.name}\n", encoding="utf-8") + + print(f"wrote {sha_path}") + print(f"sha256 {file_sha}") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(description="DLCVCAM boot.img hash verifier (no signature)") + sub = p.add_subparsers(dest="cmd", required=True) + + v = sub.add_parser("verify", help="校验 boot.img(整包 sha256 + FIT 内嵌 hash)") + v.add_argument("image", help="boot.img / boot-rk3576-*.img 路径") + v.add_argument("--expect-sha256", help="期望的整包 sha256 hex") + v.add_argument("--sha256-file", help="整包 sha256 sidecar 路径") + v.add_argument("--require-sidecar", action="store_true", help="必须存在整包 .sha256") + v.add_argument("--json-out", help="把详细校验结果写到 JSON(调试用,非交付物)") + v.set_defaults(func=cmd_verify) + + g = sub.add_parser("gen-sidecar", help="为 boot.img 生成 .sha256(整包 hash)") + g.add_argument("image", help="boot.img 路径") + g.add_argument("--sha256-file", help="输出 .sha256 路径(默认 .sha256)") + g.set_defaults(func=cmd_gen_sidecar) + + return p + + +def main(argv: Optional[List[str]] = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + try: + return int(args.func(args)) + except BrokenPipeError: + return 0 + except VerifyError as ex: + eprint(f"[ERR] {ex}") + return 2 + except OSError as ex: + eprint(f"[ERR] {ex}") + return 2 + + +if __name__ == "__main__": + sys.exit(main())