From 1641fac6310f7a54a4b5bbd526da5356e7f88644 Mon Sep 17 00:00:00 2001 From: tangfahong Date: Thu, 24 Sep 2026 11:52:22 +0800 Subject: [PATCH] feat: strengthen BSP and PSP validation and handoff --- .codex-plugin/plugin.json | 2 +- .../SKILL.md | 6 +- .../references/build-and-evidence.md | 18 +- .../references/cv181x-sdk-readonly-review.md | 32 ++ .../references/cv184x-sdk-readonly-review.md | 29 ++ .../references/scope-and-validation-modes.md | 6 + .../references/workflow.md | 5 +- .../scripts/bsp/bsp_preflight.py | 102 ++++++- .../scripts/bsp/inspect_cv181x_sdk.py | 182 ++++++++++++ .../scripts/bsp/inspect_cv184x_sdk.py | 210 +++++++++++++ .../scripts/bsp/record_build_handoff.py | 148 +++++++++- .../scripts/bsp/test_bsp_build_handoff.py | 80 +++++ .../scripts/bsp/test_cv181x_layout.py | 105 +++++++ .../scripts/bsp/test_cv184x_sdk_inspection.py | 81 +++++ .../references/bsp-psp-handoff.md | 9 +- .../SKILL.md | 19 +- .../references/configuration-boundaries.md | 10 + .../cv181x-media-ai-configuration.md | 36 +++ .../packaging-and-device-validation.md | 4 +- .../references/project-and-git.md | 5 + .../references/schemas.md | 68 +++++ .../references/workflow.md | 22 +- .../scripts/psp/analyze_psp_delta.py | 196 +++++++++++++ .../scripts/psp/audit_cv181x_media_ai.py | 263 +++++++++++++++++ .../scripts/psp/psp_project_guard.py | 56 ++-- .../scripts/psp/record_packaging_result.py | 16 +- .../scripts/psp/render_psp_handoff.py | 8 +- .../scripts/psp/test_analyze_psp_delta.py | 56 ++++ .../scripts/psp/test_audit_cv181x_media_ai.py | 52 ++++ .../scripts/psp/test_psp_project_guard.py | 16 + .../scripts/psp/test_render_psp_handoff.py | 8 + .../scripts/psp/test_validate_bsp_handoff.py | 85 +++++- .../psp/test_validate_hardware_topology.py | 53 ++++ .../psp/test_validate_partition_closure.py | 47 +++ .../scripts/psp/test_validate_psp.py | 36 ++- .../scripts/psp/validate_bsp_handoff.py | 181 +++++++++++- .../scripts/psp/validate_hardware_topology.py | 182 ++++++++++++ .../scripts/psp/validate_partition_closure.py | 276 ++++++++++++++++++ .../scripts/psp/validate_psp.py | 75 +++-- 39 files changed, 2695 insertions(+), 90 deletions(-) create mode 100644 skills/firmware-lowlevel-bsp-development/references/cv181x-sdk-readonly-review.md create mode 100644 skills/firmware-lowlevel-bsp-development/references/cv184x-sdk-readonly-review.md create mode 100644 skills/firmware-lowlevel-bsp-development/scripts/bsp/inspect_cv181x_sdk.py create mode 100644 skills/firmware-lowlevel-bsp-development/scripts/bsp/inspect_cv184x_sdk.py create mode 100644 skills/firmware-lowlevel-bsp-development/scripts/bsp/test_cv181x_layout.py create mode 100644 skills/firmware-lowlevel-bsp-development/scripts/bsp/test_cv184x_sdk_inspection.py create mode 100644 skills/firmware-lowlevel-psp-development/references/cv181x-media-ai-configuration.md create mode 100755 skills/firmware-lowlevel-psp-development/scripts/psp/analyze_psp_delta.py create mode 100644 skills/firmware-lowlevel-psp-development/scripts/psp/audit_cv181x_media_ai.py create mode 100755 skills/firmware-lowlevel-psp-development/scripts/psp/test_analyze_psp_delta.py create mode 100644 skills/firmware-lowlevel-psp-development/scripts/psp/test_audit_cv181x_media_ai.py create mode 100755 skills/firmware-lowlevel-psp-development/scripts/psp/test_validate_hardware_topology.py create mode 100755 skills/firmware-lowlevel-psp-development/scripts/psp/test_validate_partition_closure.py create mode 100755 skills/firmware-lowlevel-psp-development/scripts/psp/validate_hardware_topology.py create mode 100755 skills/firmware-lowlevel-psp-development/scripts/psp/validate_partition_closure.py diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index a2fb4af..29ab514 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "firmware-lowlevel-development", - "version": "1.0.0", + "version": "1.0.0+codex.20260924015901", "description": "\u9762\u5411\u56fa\u4ef6\u5e95\u5c42 BSP\u3001PSP\u3001AI \u6a21\u578b\u90e8\u7f72\u3001\u8054\u8c03\u4e0e\u6027\u80fd\u4f18\u5316\u7684\u786e\u8ba4\u95e8\u7981\u5f0f\u5f00\u53d1\u6280\u80fd\u5957\u4ef6\u3002", "author": { "name": "EAPIL" diff --git a/skills/firmware-lowlevel-bsp-development/SKILL.md b/skills/firmware-lowlevel-bsp-development/SKILL.md index 1e284a6..f38aa5f 100644 --- a/skills/firmware-lowlevel-bsp-development/SKILL.md +++ b/skills/firmware-lowlevel-bsp-development/SKILL.md @@ -16,6 +16,8 @@ description: "基于 EmxRes 参考项目创建和适配板级 BSP,处理 CV18 5. 涉及媒体内存或分区时,必须读取 [内存与分区规则](references/memory-and-partition.md)。 6. 涉及项目证据、SDK 同步、统一编译、imgs 路径或 PSP 交接时,必须读取 [构建、证据与交接规则](references/build-and-evidence.md)。 7. 只有从原理图提取或重新映射 Pinmux、需要审核表时,才读取 [Pinmux 证据工作流](references/pinmux.md);CSV 不是修改 BSP 的必需输入。 +8. `EP_CHIP` 为 CV181X、且参考项目使用 `configs/kernel/` 与 `configs/uboot/` 时,读取 [CV181X 分目录与 SDK 只读核对](references/cv181x-sdk-readonly-review.md)。需要访问 SDK 时还必须有用户明确给出的精确 SDK 路径;默认不访问。 +9. `EP_CHIP` 为 CV184X、且项目使用单板卡配置目录时,读取 [CV184X 板级配置与构建链](references/cv184x-sdk-readonly-review.md)。需要核对 SDK 当前状态时,必须由用户给出精确 SDK 根目录;默认不访问。 ## 默认工作模式 @@ -23,6 +25,7 @@ description: "基于 EmxRes 参考项目创建和适配板级 BSP,处理 CV18 - 默认调查范围只包含用户明确给出的附件、当前 Skill 及内置资料、参考项目、目标/staging 项目和 `EmxRes/tools` 中的精确构建入口;禁止扫描整个用户主目录、整个 `EapilBsp` 或整个 SDK。 - 未指定的 GPIO、内存、分区、板卡版本和其他配置继承参考项目;禁止以“清理”“顺手升级”扩大差异。 - 默认要求参考与目标的精确芯片、SDK、Flash 和单/双系统形态兼容。目录名只能提供线索,必须读取实际配置。 +- 按 `buildEnvConfig.sh` 的 `EP_CHIP` 识别配置布局:CV184X 沿用 `configs//` 与 `configs/EPSYNCSDKConfig.sh`;CV181X 分别核对 `configs/kernel/`、`configs/uboot/` 与两侧 defconfig。不得把两种布局合并或凭项目名猜测。 - 新项目以 `<芯片型号>-<项目型号>` 命名;双系统追加 `_dualos`。额外版本、日期、存储或优化后缀只在用户明确要求时使用。 - 开始持久化复制前,展示一次预检摘要、交付级别和完整 GPIO 实施计划并等待确认。确认覆盖本地复制、已列明差量、项目内文档、所选静态验证模式,以及任务需要构建产物时的标准全量编译;不覆盖设备、上传或发布。 - 使用 `scripts/bsp/project_guard.py` 执行确定性的复制、清单、差异白名单和最终原子写入;项目特定配置由 Agent 根据真实结构修改,不使用万能文本替换。 @@ -42,6 +45,7 @@ description: "基于 EmxRes 参考项目创建和适配板级 BSP,处理 CV18 - 原理图派生 GPIO 必须先写入并通过 `scripts/bsp/validate_gpio_plan.py`,再由用户确认整个实施表;`provisional` 或 `blocked` 行不能进入代码。 - 其他厂商芯片的媒体内存不自动修改;报告人工修改警告,但可继续独立的已确认工作。 - 任务需要构建产物时,`bsp_preflight.py build` 通过后自动执行标准全量编译:在固定 `EmxRes` 根目录加载 `tools/script_completion.sh`,再运行 `EMX2_BuildSDKTool <项目名> <构建版本> all`,不再单独请求确认。标准脚本内部的配置同步和 `git fetch origin` 视为该构建的一部分。直接运行 `ep_kernel.sh`、`ep_uboot.sh`、`EPSYNCSDKConfig.sh`,改用组件构建、覆盖已有输出或修改统一构建脚本不属于自动范围,出现这些需求时停止并请用户确认。 -- 编译成功后必须核对唯一 `kernel.bin`、`rootfs.bin` 与实际生效分区表;每个镜像相对其所有对应分区中的最小容量必须至少保留 512 KiB(524288 字节),否则不能记录 `build_passed`。通过后持久化检查证据、精确 imgs 版本目录、产物 hash 和 PSP 交接 manifest,并用 `project_guard.py audit-final` 复核正式项目。 +- CV184X 的标准构建有额外例外:`bsp_preflight.py build` 报告 `requires_patch_precheck=true` 时,`passed=true` 仍不足以自动构建。补丁环境检查失败会走 `--applypatch --force`,可能对多个 SDK 子仓库执行 `git reset --hard`。一次性构建确认不包括丢弃这些仓库的已有改动。构建前先按 CV184X 专页核对补丁门禁及相关仓库状态;无法证明不会进入强制重置分支时停止,明确列出风险和受影响路径,另请用户决定。不得用只读文件比对代替补丁环境检查通过的证据。 +- 编译成功后先对本次精确 imgs 版本路径自动执行并复核 `a+rX`:路径上的目录和输出内目录对所有用户可读、可进入,普通文件对所有用户可读,原本可执行的文件才补齐所有用户执行权限;不得跟随软链接、扩大到其他构建版本或使用 `chmod -R 777`。权限处理失败时不能记录 `build_passed`。随后核对唯一 `kernel.bin`、`rootfs.bin` 与实际生效分区表;每个镜像相对其所有对应分区中的最小容量必须至少保留 512 KiB(524288 字节),否则不能记录 `build_passed`。全部通过后持久化权限与余量证据、精确 imgs 版本目录、产物 hash 和 PSP 交接 manifest,并用 `project_guard.py audit-final` 复核正式项目。 - 本地配置检查通过只能声明项目已创建或 `local validation passed`;未执行同步、编译和板上验证时必须如实标注。 - 完成时按 [记录规范](../firmware-lowlevel-development/references/records.md) 和 [交付契约](../firmware-lowlevel-development/references/deliverable-contract.md) 报告;不执行 `git add`、提交或远端推送。 diff --git a/skills/firmware-lowlevel-bsp-development/references/build-and-evidence.md b/skills/firmware-lowlevel-bsp-development/references/build-and-evidence.md index 4b8a1d1..aa72d2d 100644 --- a/skills/firmware-lowlevel-bsp-development/references/build-and-evidence.md +++ b/skills/firmware-lowlevel-bsp-development/references/build-and-evidence.md @@ -57,7 +57,9 @@ python3 /scripts/bsp/bsp_preflight.py build \ --version ``` -只有 `passed=true` 才能自动执行。执行前把工具返回的精确工作目录、完整命令、项目与 SDK 路径、构建范围、最终 imgs 版本路径、工作区状态、配置同步副作用,以及统一脚本中的远端 `git fetch origin` 写入记录,不再请求第二次确认。不得从 `EmxRes` 的父目录启动,也不得依赖登录 Shell 的偶然当前目录。 +`passed=true` 只是构建的必要条件。执行前把工具返回的精确工作目录、完整命令、项目与 SDK 路径、构建范围、最终 imgs 版本路径、工作区状态、配置同步副作用,以及统一脚本中的远端 `git fetch origin` 写入记录。CV184X 还须按下文检查 `requires_patch_precheck`;不能把这一字段当成普通提示后继续自动构建。不得从 `EmxRes` 的父目录启动,也不得依赖登录 Shell 的偶然当前目录。 + +CV184X 还要执行 [CV184X 板级配置与构建链](cv184x-sdk-readonly-review.md)中的补丁门禁。其统一脚本在补丁检查失败后会尝试 `--applypatch --force`,可能重置多个 SDK 子仓库;这不属于上述自动构建授权。相关仓库存在未确认改动、补丁检查未通过或无法证明不会进入强制重置分支时,停止自动构建并向用户展示精确风险,不能静默运行或代替用户批准丢弃改动。CV181X 不套用这个补丁流程。 下列情况不自动处理:预检失败、统一脚本或路径与固定规则不符、输出版本已存在、参考或目标出现新的重叠改动、需要直接运行同步脚本、需要组件构建、需要改变构建参数、需要修改统一脚本,或者构建失败后需要清理和重试。此时停止并向用户说明异常、影响和可选处理方式。 @@ -75,6 +77,14 @@ python3 /scripts/bsp/bsp_preflight.py build \ 构建前记录新项目内同步脚本权限;统一脚本临时 `chmod 777` 后,构建结束恢复已记录的原权限并写入报告。只恢复新项目内的已知文件,不改 SDK 或其他项目权限。 +编译命令成功后,`record_build_handoff.py` 必须在生成交接文件前自动处理本次精确 imgs 版本路径: + +- 从 `--output-root` 到本次 `<项目名>/imgs/<构建版本>` 的路径节点都补齐 `a+rx`,保证所有用户都能读取并进入目录; +- 本次版本目录内递归执行等价于 `a+rX` 的受控处理:目录补齐 `a+rx`,普通文件补齐 `a+r`,只有原本带执行位的文件才补齐 `a+x`; +- 不跟随软链接,不改软链接目标,不处理其他项目或其他构建版本,也不使用 `chmod -R 777`; +- 不使用 `sudo`。当前用户无法修改权限、路径节点不是普通目录、输出包含特殊文件或复核仍不满足要求时立即停止,不能生成 `build_passed`; +- 将处理前后模式、变更数量和复核结果写入 `validation/output_permissions_<构建版本>.json`,并同时写入 PSP 交接 manifest。 + ## 构建证据与状态 成功必须同时满足: @@ -82,6 +92,7 @@ python3 /scripts/bsp/bsp_preflight.py build \ - 命令退出码为零; - 日志到达正常完成阶段; - 输出目录存在且含非空固件文件; +- 本次精确 imgs 版本路径已经通过 `a+rX` 权限处理与复核; - 当前项目实际生效的分区表可唯一确定,或已显式指定 `configs/` 内精确文件; - 最终 imgs 中唯一 `kernel.bin` 和 `rootfs.bin` 相对各自最小对应分区均保留至少 512 KiB(524288 字节); - 文件名、大小和 SHA-256 清单写入 `validation/`; @@ -113,12 +124,13 @@ python3 /scripts/bsp/project_guard.py audit-final \ - `validation/artifact_manifest_<构建版本>.sha256`; - `validation/image_partition_margin_<构建版本>.json`; +- `validation/output_permissions_<构建版本>.json`; - `handoff/psp_handoff.json`; - `status.json`。 `psp_handoff.json` 遵循统一入口的 [BSP→PSP 交接契约](../../firmware-lowlevel-development/references/bsp-psp-handoff.md)。`audit-final` 必须在这些文档写入后执行;它使用 prepare 时冻结的参考清单,而不是假定参考项目从未变化。 -成功时保存简明摘要、kernel/rootfs 分区余量证据、固件哈希清单和精确 imgs 版本路径;失败时保存完整日志、阶段和关键错误。后续同步或编译失败不删除已经通过静态门禁的正式项目,只更新状态。不得只记录 `imgs` 软链接、父目录或“latest”;交付和 PSP 依赖必须绑定具体构建版本目录。 +成功时保存简明摘要、输出路径权限证据、kernel/rootfs 分区余量证据、固件哈希清单和精确 imgs 版本路径;失败时保存完整日志、阶段和关键错误。后续同步或编译失败不删除已经通过静态门禁的正式项目,只更新状态。不得只记录 `imgs` 软链接、父目录或“latest”;交付和 PSP 依赖必须绑定具体构建版本目录。 没有同步和编译时写: @@ -130,4 +142,4 @@ python3 /scripts/bsp/project_guard.py audit-final \ ## 最终回复 -完成构建时必须显式输出:项目路径、精确 imgs 版本路径、kernel/rootfs 的镜像大小、最小对应分区大小和实际余量、余量证据路径、交接 manifest 路径、产物清单/hash 路径、`audit-final` 结果,以及硬件/负责人/发布状态。没有生成交接 manifest 时不能声称已完成 PSP 交接。 +完成构建时必须显式输出:项目路径、精确 imgs 版本路径、`a+rX` 权限复核结果及证据路径、kernel/rootfs 的镜像大小、最小对应分区大小和实际余量、余量证据路径、交接 manifest 路径、产物清单/hash 路径、`audit-final` 结果,以及硬件/负责人/发布状态。没有生成交接 manifest 时不能声称已完成 PSP 交接。 diff --git a/skills/firmware-lowlevel-bsp-development/references/cv181x-sdk-readonly-review.md b/skills/firmware-lowlevel-bsp-development/references/cv181x-sdk-readonly-review.md new file mode 100644 index 0000000..8226563 --- /dev/null +++ b/skills/firmware-lowlevel-bsp-development/references/cv181x-sdk-readonly-review.md @@ -0,0 +1,32 @@ +# CV181X 分目录与 SDK 只读核对 + +这份说明只适用于项目 `buildEnvConfig.sh` 指向 Cvitek CV181X、SPI NAND,并且板级配置分在 `configs/kernel/`、`configs/uboot/` 的情况。CV184X 仍按原来的单板卡目录和 `EPSYNCSDKConfig.sh` 工作,不能套用本页的路径。 + +## 先确定改哪里 + +- `configs/uboot/_defconfig` 确定板型;同目录的 `cvitek__defconfig` 和 `configs/kernel/` 中的对应 defconfig 必须成组存在。 +- U-Boot 板级初始化、分区表在 `configs/uboot/`;Kernel defconfig、DTS/DTSI、`memmap.py` 在 `configs/kernel/`。先从需求和生效引用找真正的源文件,再改项目配置;不要直接改 SDK 公共默认文件。 +- `CONFIG_ENABLE_FREERTOS=y` 只说明启用了 FreeRTOS 相关配置,不能单凭这一项认定交付形态是双系统。 +- 参考项目 `CV1815JA_F36` 只能作为已知实现的例子,不代表所有 CV181X 产品的分区、内存、GPIO 或传感器配置。 + +## 需要看 SDK 时 + +用户必须先给出精确 SDK 根目录。执行只读检查: + +```bash +python3 /scripts/bsp/inspect_cv181x_sdk.py \ + --project \ + --sdk-root <用户明确指定的 SDK 根目录绝对路径> +``` + +脚本先核对 `EP_CHIP`、SDK 版本、分目录布局和已知同步脚本,再比较项目文件与 SDK 对应文件。报告中的 `sdk_copy_differs` 表示当前内容不一致,不说明哪一份正确;`shared_sdk_target` 表示 SDK 板型文件实际是指向公共默认文件的软链接,后续复制可能影响别的板型;`generated_config_differs` 表示 SDK `build/.config` 与项目板级配置不一致,也可能只是 SDK 上次用于别的项目。对这些提示先确认使用的是哪个 SDK 和哪个构建版本,不擅自同步。 + +尤其要核对统一构建脚本里 `defconfig` 与 `Ep_Uboot_Config_Func`、`Ep_Kernel_Config_Func` 的先后。若先生成 `.config` 再同步项目配置,本次顶层配置可能仍是旧值;脚本会提示顺序风险,但不会代替真实构建。`ep_kernel.sh`、`ep_uboot.sh` 末尾若固定 `exit 0`,也不能只凭返回码宣称所有文件复制成功,应逐项核对源与 SDK 目标。 + +## 形成方案时要核对的关联项 + +- 改内存:从项目 `memmap.py` 追到 SDK 当前目标和生成结果,说明 ION/CMA/RTOS 占用及总量;不要把 F36 的数值复制给别的板。 +- 改分区:把项目 XML、SDK 当前表、U-Boot 分区参数、rootfs A/B 实际 MTD 编号和最终镜像大小逐项对齐。若脚本固定按 MTD4/7 操作,分区顺序变化可能导致升级写错位置;未经确认不能自动调整。 +- 改驱动或传感器:确认 Kernel DTS、GPIO 初始化、相关模块构建开关(例如 `BUILD_SDK_DRIVER`)、镜像是否打包驱动,以及用户态是否还依赖原传感器。只改一处不能声称功能完成。 + +只读核对的结论上限是“发现项目与当前 SDK 的差异或风险”。未运行统一构建时不能写“构建通过”;未上板时不能写“硬件功能正常”。SDK 同步、构建、设备操作和发布仍遵守原有审批与证据规则。 diff --git a/skills/firmware-lowlevel-bsp-development/references/cv184x-sdk-readonly-review.md b/skills/firmware-lowlevel-bsp-development/references/cv184x-sdk-readonly-review.md new file mode 100644 index 0000000..2e238b6 --- /dev/null +++ b/skills/firmware-lowlevel-bsp-development/references/cv184x-sdk-readonly-review.md @@ -0,0 +1,29 @@ +# CV184X 板级配置与构建链 + +适用于 `EP_CHIP=CV184*` 且项目采用 `configs//` 的 EmxRes 项目。CV181X 的 `configs/kernel/`、`configs/uboot/` 是另一条流程,不能混用。先看项目 `buildEnvConfig.sh`、板级目录和 `configs/EPSYNCSDKConfig.sh`,再从 `EmxRes/tools/EMX2_BuildSDKTool` 确认实际走的是 `BuildCvitek184XSDKTool.sh`;不要凭项目名猜板型。 + +## 配置如何生效 + +专用工具按 `EP_CHIP` 和 `EP_FLASH_TYPE` 选定 `CV_BUILD_TYPE`。`EPSYNCSDKConfig.sh` 将项目单板卡目录用 `rsync -av` 写到 SDK `build/boards/cv184x//`,然后工具执行 SDK `defconfig`、`clean_all` 和分项构建。先核对工具选出的 build type 与项目目录一致;若不一致,停止,不让同步脚本猜目标。同步脚本末尾固定 `exit 0`,单看脚本退出码不够,需比较每个映射文件及实际构建日志。 + +项目板卡目录至少应结合真实需求检查顶层 `_defconfig`、Kernel/U-Boot defconfig、`dts_arm/.dts`、`u-boot/cvi_board_init.c` 与分区 XML。顶层 defconfig 经 `build/common_functions.sh:defconfig` 生成 SDK `build/.config`;`build/envsetup_soc.sh` 根据该配置选实际生效的分区 XML。`CONFIG_DUAL_OS` 还影响 `build/Makefile` 选用哪组模块加载顺序。DTS 的 GPIO 使用要追到 `cv184x_base_arm.dtsi`、封装 DTSI、存储 DTSI、`cv184x_default_memmap.dtsi` 以及最终 DTB;只检查项目顶层 DTS 不足以证明引脚已释放。媒体内存还要看 `cvi_board_memmap.h` 的生成值、Kernel reserved-memory 和 RTOS 占用。 + +在当前专用工具里,`all` 会进入驱动分支;该分支直接调用 `build_osdrv`、`build_libsophon` 和 `pack_rootfs`,并没有依据项目 `BUILD_SDK_DRIVER=n` 跳过这些步骤。项目设置了这个变量也不能据此断言 SDK 驱动未构建。最终 rootfs 还会从 SDK 目录外的 `CV184X/rootfs//` 打包;排查镜像内容时需要把这条独立输入链单独核对。 + +## 用户给出 SDK 路径后的只读核对 + +```bash +python3 /scripts/bsp/inspect_cv184x_sdk.py \ + --project \ + --sdk-root <用户明确指定的 SDK 根目录绝对路径> +``` + +检查器核对工具实际选择的板型、同步顺序、项目到 SDK 的文件映射、当前 `build/.config`、SDK 子仓库的已跟踪文件状态,并报告补丁强制重置风险。`sdk_copy_differs` 只说明当前磁盘内容不同;SDK 可能上次构建了别的项目,不能自动判断谁应覆盖谁。检查器不运行同步、补丁检查、构建或设备操作,也不证明最终镜像用的就是当前项目配置。 + +例如 `CV1841CP-LY-SM-YT70_dualos` 使用 `cv1841cp_wevb_0015a_spinand`。截至本次分析,项目与 SDK 当前的顶层 defconfig、DTS、分区 XML、U-Boot 初始化文件内容不同;项目分区的 APP/APP2 为 32768 KiB,SDK 当前表为 19456 KiB 并另有 DATA 分区。这是“当前 SDK 快照不可当作该项目已同步基线”的证据,不是要把 SDK 改成项目值的授权。其他 CV184X 项目不能套用这组容量。 + +## 编译前的补丁门禁 + +`BuildCvitek184XSDKTool.sh` 在同步配置之前先运行 `cvitek_cv184x_patch/scripts/repos --check-env`;若失败,会自动调用 `--applypatch --force`。该补丁工具在部分仓库有未提交的已跟踪改动或提交不匹配时,可能运行 `git reset --hard`。SDK 顶层本身不是一个 Git 仓库,而是多个子仓库;需要逐个确认补丁作用范围、相关仓库状态和 `--check-env` 的真实结果。只读文件比对不能代替补丁检查。若检查不通过或会进入强制重置分支,停止自动构建,说明哪些改动可能丢失,再请用户决定;不要自行运行 `--applypatch --force`。 + +`EmxRes/tools/BuildGitCheck.sh` 会执行远端 `git fetch origin`,但对非 Git 顶层目录的错误不一定可靠传播,因此它显示“检查通过”不能单独证明所有 SDK 子仓库干净或版本一致。构建成功之后仍要核对实际生效分区表、`kernel.bin`/`rootfs.bin` 余量,以及 `fip.bin`、`boot.bin`、`yoc.bin`、驱动和 rootfs 是否按需求出现。单凭配置同步、命令零退出码或某个文件存在,不能声称 BSP 已通过板上验证。 diff --git a/skills/firmware-lowlevel-bsp-development/references/scope-and-validation-modes.md b/skills/firmware-lowlevel-bsp-development/references/scope-and-validation-modes.md index 7f205c3..c12d58e 100644 --- a/skills/firmware-lowlevel-bsp-development/references/scope-and-validation-modes.md +++ b/skills/firmware-lowlevel-bsp-development/references/scope-and-validation-modes.md @@ -24,6 +24,12 @@ 该模式不会因为禁止人工读取 SDK 而阻塞项目创建,但会降低 DTS 结论的验证等级。 +## CV181X 定点 SDK 只读核对(可选) + +仅当用户明确提供 CV181X SDK 的精确根目录时使用。按 [CV181X 分目录与 SDK 只读核对](cv181x-sdk-readonly-review.md)比较项目与 SDK 的已知文件映射、生成配置及软链接目标;只访问映射到的文件,不遍历或搜索整个 SDK。结论仅代表当前磁盘快照,不意味着同步、构建或板上验证通过。若同步脚本或布局不符合已知契约,停止检查并报告,不猜测映射。 + +CV184X 有独立的 [单目录 SDK 只读核对](cv184x-sdk-readonly-review.md)。同样需要用户明确给出 SDK 根目录;只读核对不会运行 `EPSYNCSDKConfig.sh`、补丁脚本或构建。 + ## 完整 DTS 审核模式 仅在用户授权读取精确 SDK 基线路径时使用: diff --git a/skills/firmware-lowlevel-bsp-development/references/workflow.md b/skills/firmware-lowlevel-bsp-development/references/workflow.md index 3a66ade..cd3646e 100644 --- a/skills/firmware-lowlevel-bsp-development/references/workflow.md +++ b/skills/firmware-lowlevel-bsp-development/references/workflow.md @@ -28,8 +28,11 @@ 1. 将 `~` 解析为执行任务的当前用户主目录,确认参考项目是 `EmxRes` 的直接子目录,目标也是新的直接子目录。 2. 检查目标正式目录和同名残留目录;正式目录已存在时停止,不覆盖。 3. 先运行 `scripts/bsp/bsp_preflight.py project`,再只读取报告定位的 `buildEnvConfig.sh`、配置同步脚本和实际配置目录,发现精确芯片、SDK、Flash、架构、单/双系统形态和板卡 build type。 + CV184X 保持单板卡目录;CV181X 必须分别检查 `configs/kernel/` 和 `configs/uboot/`,并以 U-Boot 板级 defconfig 及两侧同名 `cvitek_` defconfig 确定 build type。`CONFIG_ENABLE_FREERTOS=y` 不能单独当作双系统证据。结构不匹配时停止,不自动转换。 4. GPIO 任务按 [内置 GPIO 资料目录](builtin-gpio-pinout.md)从 `buildEnvConfig.sh` 的 `EP_CHIP` 自动选表并查询全部目标信号;将结果保存为输入证据。目录名与 `EP_CHIP` 冲突时以后者为芯片配置事实并报告命名风险,原理图芯片与 `EP_CHIP` 冲突时停止。 5. 按 [调查范围与验证模式](scope-and-validation-modes.md)执行 GPIO/DTS 检查。默认 EmxRes 配置模式不人工搜索 SDK;完整 DTS 审核模式只读取精确解析出的 include 链。 + 若用户另行指定精确 CV181X SDK 根目录,可按 [CV181X 分目录与 SDK 只读核对](cv181x-sdk-readonly-review.md)执行文件映射核对;该检查不授予同步或编译权限。 + CV184X 项目可按 [CV184X 板级配置与构建链](cv184x-sdk-readonly-review.md)核对单目录映射、真实 build type、生成配置和补丁副作用;用户未指定 SDK 路径时仅分析 EmxRes 和 tools。 6. 默认要求参考与目标使用相同精确芯片、SDK、Flash 和系统形态。不同芯片或布局转换不是少量适配,要求更接近的参考项目或新的工作包。 7. 只读检查 `EmxRes` Git 状态。参考项目自身包含已修改或未跟踪文件时,列出并要求确认复制当前磁盘状态;其他项目的改动只记录、不阻塞。 8. 扫描软链接。允许保留项目内相对链接;约定的 `imgs` 链接按第 6 节处理;其他指向 `EmxRes` 外部的链接要求确认。 @@ -139,4 +142,4 @@ python3 scripts/bsp/project_guard.py finalize \ 任务已要求构建产物时,正式项目静态检查通过后自动进入标准全量编译。预检失败、输出版本冲突、统一脚本缺失、工作区出现重叠改动、命令返回失败或产物检查失败时停止,不自动覆盖、清理或重试;记录证据后请用户决定如何处理。 -完整编译成功后还要按分区表验证唯一 `kernel.bin`、`rootfs.bin` 均至少保留 512 KiB 分区余量,再记录精确 imgs 版本目录和 BSP→PSP 交接 manifest;未执行或未通过该检查,以及仅有 `imgs` 软链接或“最新目录”,都不能作为下游基线。 +完整编译成功后先把本次精确 imgs 版本路径按 `a+rX` 处理并复核,确保其他用户和组能够读取、进入该路径;再按分区表验证唯一 `kernel.bin`、`rootfs.bin` 均至少保留 512 KiB 分区余量,最后记录精确 imgs 版本目录和 BSP→PSP 交接 manifest。权限或余量检查未执行、未通过,或者只有 `imgs` 软链接或“最新目录”,都不能作为下游基线。 diff --git a/skills/firmware-lowlevel-bsp-development/scripts/bsp/bsp_preflight.py b/skills/firmware-lowlevel-bsp-development/scripts/bsp/bsp_preflight.py index 05fd78e..c9e5bcd 100644 --- a/skills/firmware-lowlevel-bsp-development/scripts/bsp/bsp_preflight.py +++ b/skills/firmware-lowlevel-bsp-development/scripts/bsp/bsp_preflight.py @@ -49,6 +49,64 @@ def detects_dual_os(config_dir: Path) -> bool: return False +def resolve_board_layout(project: Path, chip: str | None = None) -> dict[str, object]: + """Keep the existing CV184X layout; recognize CV181X split configuration.""" + config_dirs = config_directories(project) + if chip and chip.upper().startswith("CV181"): + kernel = project / "configs" / "kernel" + uboot = project / "configs" / "uboot" + if {path.name for path in config_dirs} != {"kernel", "uboot"}: + return { + "board_build_type": None, + "system_form": None, + "config_directory": None, + "layout": "unknown", + "error": "CV181X 项目需要 configs/kernel 和 configs/uboot 两个配置目录", + } + candidates = sorted( + path for path in uboot.glob("*_defconfig") + if path.is_file() and not path.is_symlink() and not path.name.startswith("cvitek_") + ) + if len(candidates) == 1: + build_type = candidates[0].name.removesuffix("_defconfig") + sdk_config = uboot / f"cvitek_{build_type}_defconfig" + kernel_config = kernel / f"cvitek_{build_type}_defconfig" + if (sdk_config.is_file() and not sdk_config.is_symlink() + and kernel_config.is_file() and not kernel_config.is_symlink()): + return { + "board_build_type": build_type, + "system_form": "dual-os" if detects_dual_os(uboot) else "single-os", + "config_directory": str(uboot), + "layout": "split-kernel-uboot", + "error": None, + } + return { + "board_build_type": None, + "system_form": None, + "config_directory": None, + "layout": "split-kernel-uboot", + "error": "CV181X kernel/uboot 结构未能唯一定位完整的板级 defconfig 组", + } + + if len(config_dirs) == 1 and config_dirs[0].name not in {"kernel", "uboot"}: + config_dir = config_dirs[0] + return { + "board_build_type": config_dir.name, + "system_form": "dual-os" if detects_dual_os(config_dir) else "single-os", + "config_directory": str(config_dir), + "layout": "board-directory", + "error": None, + } + + return { + "board_build_type": None, + "system_form": None, + "config_directory": None, + "layout": "unknown", + "error": f"无法识别配置布局:configs/ 下有 {[path.name for path in config_dirs]}", + } + + def project_preflight(reference_name: str, target_name: str, root: Path) -> dict[str, object]: errors: list[str] = [] warnings: list[str] = [] @@ -64,13 +122,19 @@ def project_preflight(reference_name: str, target_name: str, root: Path) -> dict errors.append(f"缺少 buildEnvConfig.sh: {build_env}") else: assignments = parse_assignments(build_env) - config_dirs = config_directories(reference) - if len(config_dirs) != 1: - errors.append(f"参考项目必须唯一定位一个配置目录,实际为 {len(config_dirs)} 个") - config_dir = config_dirs[0] if len(config_dirs) == 1 else None - sync_script = reference / "configs" / "EPSYNCSDKConfig.sh" - if not sync_script.is_file(): - warnings.append(f"未找到项目同步脚本: {sync_script}") + layout = resolve_board_layout(reference, assignments.get("EP_CHIP")) + if layout["error"]: + errors.append(str(layout["error"])) + if layout["layout"] == "split-kernel-uboot": + sync_scripts = [reference / "configs" / part / script for part, script in ( + ("kernel", "ep_kernel.sh"), ("uboot", "ep_uboot.sh"))] + sync_script = None + else: + sync_script = reference / "configs" / "EPSYNCSDKConfig.sh" + sync_scripts = [sync_script] + for path in sync_scripts: + if not path.is_file() or path.is_symlink(): + warnings.append(f"未找到普通项目同步脚本: {path}") imgs = reference / "imgs" imgs_target = os.readlink(imgs) if imgs.is_symlink() else None @@ -94,13 +158,15 @@ def project_preflight(reference_name: str, target_name: str, root: Path) -> dict "architecture": assignments.get("EP_ARCH"), "flash": assignments.get("EP_FLASH_TYPE"), "nand_flash_id": assignments.get("NAND_FLASH_ID"), - "board_build_type": config_dir.name if config_dir else None, - "system_form": "dual-os" if config_dir and detects_dual_os(config_dir) else "single-os", + "board_build_type": layout["board_build_type"], + "system_form": layout["system_form"], + "config_layout": layout["layout"], }, "bounded_paths": { "build_env": str(build_env), - "config_directory": str(config_dir) if config_dir else None, - "sync_script": str(sync_script), + "config_directory": layout["config_directory"], + "sync_script": str(sync_script) if sync_script else None, + "sync_scripts": [str(path) for path in sync_scripts], "reference_imgs_target": imgs_target, "target_imgs_target": project_guard.expected_imgs_target(target_name), }, @@ -115,6 +181,14 @@ def build_preflight(project_name: str, version: str, root: Path, output_root: Pa if not VERSION_RE.fullmatch(version): errors.append("构建版本必须为 YYYYMMDD_N,且 N 为正整数") + build_env = project / "buildEnvConfig.sh" + assignments = parse_assignments(build_env) if build_env.is_file() and not build_env.is_symlink() else {} + if not assignments.get("EP_CHIP"): + errors.append(f"构建前无法确认 EP_CHIP: {build_env}") + layout = resolve_board_layout(project, assignments.get("EP_CHIP")) + if layout["error"]: + errors.append(str(layout["error"])) + completion_script = root / "tools" / "script_completion.sh" if not completion_script.is_file(): errors.append(f"统一构建入口不存在: {completion_script}") @@ -137,6 +211,9 @@ def build_preflight(project_name: str, version: str, root: Path, output_root: Pa f"EMX2_BuildSDKTool {project_name} {version} all" ) warnings.append("统一构建可能同步配置到项目外 SDK,并按工具实现执行 git fetch origin") + requires_patch_precheck = assignments.get("EP_CHIP", "").upper().startswith("CV184") + if requires_patch_precheck: + warnings.append("CV184X 构建补丁检查失败可能触发 --applypatch --force;本预检未验证补丁环境,不能单凭 passed=true 自动构建") return { "schema_version": 1, "kind": "build-preflight", @@ -145,6 +222,9 @@ def build_preflight(project_name: str, version: str, root: Path, output_root: Pa "warnings": warnings, "project": project_name, "project_path": str(project), + "config_layout": layout["layout"], + "board_build_type": layout["board_build_type"], + "requires_patch_precheck": requires_patch_precheck, "build_version": version, "working_directory": str(root), "completion_script": str(completion_script), diff --git a/skills/firmware-lowlevel-bsp-development/scripts/bsp/inspect_cv181x_sdk.py b/skills/firmware-lowlevel-bsp-development/scripts/bsp/inspect_cv181x_sdk.py new file mode 100644 index 0000000..582bb91 --- /dev/null +++ b/skills/firmware-lowlevel-bsp-development/scripts/bsp/inspect_cv181x_sdk.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""Read-only, bounded comparison of a CV181X EmxRes project and an explicit SDK tree.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path + +import bsp_preflight +import project_guard + + +CONFIG_RE = re.compile(r"^(CONFIG_[A-Za-z0-9_]+)=(.+)$", re.MULTILINE) +CONFIG_PREFIXES = ( + "CONFIG_CHIP_", "CONFIG_BOARD_", "CONFIG_DDR_CFG_", "CONFIG_TOOLCHAIN_", + "CONFIG_STORAGE_TYPE_", "CONFIG_NANDFLASH_", +) +CONFIG_KEYS = {"CONFIG_ARCH", "CONFIG_SDK_VER", "CONFIG_ENABLE_FREERTOS", "CONFIG_BOOT_IMAGE_SINGLE_DTB"} + + +def selected_config(path: Path) -> dict[str, str]: + if not path.is_file() or path.is_symlink(): + return {} + return { + key: value for key, value in CONFIG_RE.findall(path.read_text(encoding="utf-8")) + if key in CONFIG_KEYS or key.startswith(CONFIG_PREFIXES) + } + + +def sync_scripts_match(project: Path) -> bool: + kernel = project / "configs/kernel/ep_kernel.sh" + uboot = project / "configs/uboot/ep_uboot.sh" + if any(not path.is_file() or path.is_symlink() for path in (kernel, uboot)): + return False + kernel_lines = {line.strip() for line in kernel.read_text(encoding="utf-8").splitlines() + if line.strip() and not line.lstrip().startswith("#")} + uboot_lines = {line.strip() for line in uboot.read_text(encoding="utf-8").splitlines() + if line.strip() and not line.lstrip().startswith("#")} + return all(token in kernel_lines for token in ( + "EP_KERNEL_BUILD_TOP=build/boards/cv181x/$CV_BUILD_TYPE", + "EP_KERNEL_BASE_DTSI=build/boards/default/dts/cv181x", + "cp $EP_KERNEL_CONFIGS_PATH/memmap.py $EP_KERNEL_BUILD_TOP/memmap.py", + "cp $EP_KERNEL_CONFIGS_PATH/cvitek_$CV_BUILD_TYPE\"_defconfig\" $EP_KERNEL_BUILD_TOP/linux/cvitek_$CV_BUILD_TYPE\"_defconfig\"", + "cp $EP_KERNEL_CONFIGS_PATH/*.dts $EP_KERNEL_BUILD_TOP/dts_arm/", + "cp $EP_KERNEL_CONFIGS_PATH/*.dtsi $EP_KERNEL_BASE_DTSI/", + )) and all(token in uboot_lines for token in ( + "EP_UBOOT_BUILD_TOP=build/boards/cv181x/$CV_BUILD_TYPE", + "cp $EP_UBOOT_CONFIGS_PATH/$CV_BUILD_TYPE\"_defconfig\" $EP_UBOOT_BUILD_TOP/", + "cp $EP_UBOOT_CONFIGS_PATH/cvitek_$CV_BUILD_TYPE\"_defconfig\" $EP_UBOOT_BUILD_TOP/u-boot/cvitek_$CV_BUILD_TYPE\"_defconfig\"", + "cp $EP_UBOOT_CONFIGS_PATH/partition_spinand.xml $EP_UBOOT_BUILD_TOP/partition/partition_spinand.xml", + "cp $EP_UBOOT_CONFIGS_PATH/cvi_board_init.c $EP_UBOOT_BUILD_TOP/u-boot/cvi_board_init.c", + )) + + +def mapped_files(build_type: str, project: Path) -> list[tuple[Path, Path]]: + board = Path("build/boards/cv181x") / build_type + files = [ + (Path("configs/uboot") / f"{build_type}_defconfig", board / f"{build_type}_defconfig"), + (Path("configs/uboot") / f"cvitek_{build_type}_defconfig", board / "u-boot" / f"cvitek_{build_type}_defconfig"), + (Path("configs/uboot/cvi_board_init.c"), board / "u-boot/cvi_board_init.c"), + (Path("configs/uboot/partition_spinand.xml"), board / "partition/partition_spinand.xml"), + (Path("configs/kernel/memmap.py"), board / "memmap.py"), + (Path("configs/kernel") / f"cvitek_{build_type}_defconfig", board / "linux" / f"cvitek_{build_type}_defconfig"), + ] + for path in sorted((project / "configs/kernel").glob("*.dts")): + files.append((path.relative_to(project), board / "dts_arm" / path.name)) + for path in sorted((project / "configs/kernel").glob("*.dtsi")): + files.append((path.relative_to(project), Path("build/boards/default/dts/cv181x") / path.name)) + return files + + +def inspect(project: Path, sdk_root: Path) -> dict[str, object]: + if project.is_symlink() or not project.is_dir() or sdk_root.is_symlink() or not sdk_root.is_dir(): + raise ValueError("项目与 SDK 必须是存在的普通目录,不能是软链接") + root = project_guard.normalize_root(project.parent) + project = project_guard.direct_child(root, project.name, "project", True) + sdk_root = sdk_root.resolve() + env_path = project / "buildEnvConfig.sh" + if not env_path.is_file() or env_path.is_symlink(): + raise ValueError("项目缺少普通 buildEnvConfig.sh") + env = bsp_preflight.parse_assignments(env_path) + if not env.get("EP_CHIP", "").startswith("CV181"): + raise ValueError("本检查器仅适用于 EP_CHIP=CV181* 项目") + if env.get("EP_COMPANY") != "Cvitek" or env.get("EP_FLASH_TYPE") != "nand": + raise ValueError("本检查器目前只核对 Cvitek CV181X SPI NAND 构建链") + if not env.get("EP_SDK_VER") or sdk_root.name != env["EP_SDK_VER"]: + raise ValueError("指定 SDK 目录名与项目 EP_SDK_VER 不符") + layout = bsp_preflight.resolve_board_layout(project, env.get("EP_CHIP")) + if layout["error"] or layout["layout"] != "split-kernel-uboot": + raise ValueError(str(layout["error"] or "需要 kernel/uboot 分目录布局")) + if not sync_scripts_match(project): + raise ValueError("同步脚本与已知 CV181X 复制契约不符;不猜测 SDK 写入目标") + + build_type = str(layout["board_build_type"]) + alerts: list[dict[str, str]] = [] + if not build_type.startswith(env["EP_CHIP"].lower() + "_") or "spinand" not in build_type: + alerts.append({"code": "board_identity", "message": "板级配置名与芯片或 NAND 类型不一致,需人工核对"}) + mappings: list[dict[str, object]] = [] + for source_rel, dest_rel in mapped_files(build_type, project): + source = project / source_rel + dest = sdk_root / dest_rel + resolved = dest.resolve(strict=False) + entry: dict[str, object] = { + "project_file": str(source), + "sdk_path": str(dest), + "sdk_real_path": str(resolved), + "sdk_is_symlink": dest.is_symlink(), + "project_exists": source.is_file() and not source.is_symlink(), + "sdk_exists": dest.is_file(), + "same_content": None, + } + if not resolved.is_relative_to(sdk_root): + entry["sdk_exists"] = False + alerts.append({"code": "sdk_link_escape", "message": f"SDK 目标软链接越出指定 SDK: {dest_rel}"}) + elif source.is_file() and not source.is_symlink() and dest.is_file(): + entry["same_content"] = project_guard.sha256_file(source) == project_guard.sha256_file(dest) + if entry["sdk_is_symlink"]: + alerts.append({"code": "shared_sdk_target", "message": f"复制可能改写公共目标: {resolved}"}) + if entry["same_content"] is False: + alerts.append({"code": "sdk_copy_differs", "message": f"项目与 SDK 当前内容不同: {source_rel}"}) + else: + alerts.append({"code": "mapping_missing", "message": f"项目或 SDK 文件缺失: {source_rel}"}) + if entry["sdk_is_symlink"] and entry["same_content"] is None and resolved.is_relative_to(sdk_root): + alerts.append({"code": "shared_sdk_target", "message": f"复制可能改写公共目标: {resolved}"}) + mappings.append(entry) + + source_config = project / "configs/uboot" / f"{build_type}_defconfig" + expected = selected_config(source_config) + actual = selected_config(sdk_root / "build/.config") + differences = [ + {"key": key, "project": value, "sdk_generated": actual.get(key)} + for key, value in sorted(expected.items()) if actual.get(key) != value + ] if actual else [] + if not actual: + alerts.append({"code": "generated_config_missing", "message": "SDK build/.config 不存在,无法核对生成配置"}) + elif differences: + alerts.append({"code": "generated_config_differs", "message": "生成配置与项目板级配置存在差异;可能属于其他项目或未重新生成"}) + + tool = root / "tools/BuildCvitekSDKTool.sh" + order = "unknown" + if tool.is_file() and not tool.is_symlink(): + content = tool.read_text(encoding="utf-8") + positions = [content.find(s) for s in ("defconfig $CV_BUILD_TYPE", "Ep_Uboot_Config_Func", "Ep_Kernel_Config_Func")] + if -1 not in positions: + order = "sync-after-defconfig" if positions[0] < positions[1] and positions[0] < positions[2] else "other" + if order == "sync-after-defconfig": + alerts.append({"code": "sync_after_defconfig", "message": "工具先生成 .config 再同步项目配置;顶层配置变更可能不在当次生效"}) + + return { + "schema_version": 1, + "kind": "cv181x-sdk-readonly-inspection", + "project": str(project), + "sdk_root": str(sdk_root), + "chip": env["EP_CHIP"], + "sdk_version": env.get("EP_SDK_VER"), + "board_build_type": build_type, + "build_order": order, + "mappings": mappings, + "generated_config_differences": differences, + "alerts": alerts, + "note": "只读磁盘快照;未执行同步、编译或板上验证。映射以已识别的复制脚本为前提。", + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--project", required=True, type=Path) + parser.add_argument("--sdk-root", required=True, type=Path) + args = parser.parse_args() + try: + result = inspect(args.project, args.sdk_root) + except (ValueError, OSError, UnicodeDecodeError, project_guard.GuardError) as exc: + print(json.dumps({"passed": False, "error": str(exc)}, ensure_ascii=False, indent=2)) + return 2 + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/firmware-lowlevel-bsp-development/scripts/bsp/inspect_cv184x_sdk.py b/skills/firmware-lowlevel-bsp-development/scripts/bsp/inspect_cv184x_sdk.py new file mode 100644 index 0000000..b958f74 --- /dev/null +++ b/skills/firmware-lowlevel-bsp-development/scripts/bsp/inspect_cv184x_sdk.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""Read-only CV184X project-to-SDK snapshot review; never run sync or build.""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +from pathlib import Path + +import bsp_preflight +import project_guard + + +CONFIG_RE = re.compile(r"^(CONFIG_[A-Za-z0-9_]+)=(.+)$", re.MULTILINE) +NOT_SET_RE = re.compile(r"^# (CONFIG_[A-Za-z0-9_]+) is not set$", re.MULTILINE) +TRACKED_KEYS = {"CONFIG_DUAL_OS", "CONFIG_ION_SIZE", "CONFIG_RTOS_ION_SIZE", "CONFIG_RTOS_SYS_SIZE", + "CONFIG_DRAM_SIZE", "CONFIG_ENABLE_RTT", "CONFIG_RTOS_BUILD_IN_FIP", + "CONFIG_ROOTFS_B", "CONFIG_ROOTFS_RECOVERY", "CONFIG_VENDOR_PARTITION", "CONFIG_SKIP_UBOOT"} +TRACKED_PREFIXES = ("CONFIG_CHIP_", "CONFIG_BOARD_", "CONFIG_STORAGE_TYPE_", "CONFIG_SENSOR_") +CHIP_LINE = re.compile(r'\$EP_CHIP\s*==\s*"(CV184[^"]+)"') +FLASH_LINE = re.compile(r'\$EP_FLASH_TYPE\s*==\s*"(nor|nand|emmc)"') +BOARD_LINE = re.compile(r'^\s*CV_BUILD_TYPE=([a-z0-9_]+)\s*$') + + +def selected_config(path: Path) -> dict[str, str]: + if not path.is_file() or path.is_symlink(): + return {} + content = path.read_text(encoding="utf-8") + values = {key: "n" for key in NOT_SET_RE.findall(content) + if key in TRACKED_KEYS or key.startswith(TRACKED_PREFIXES)} + values.update({key: value for key, value in CONFIG_RE.findall(content) + if key in TRACKED_KEYS or key.startswith(TRACKED_PREFIXES)}) + return values + + +def partition_name(config: dict[str, str], flash: str) -> str: + if config.get("CONFIG_SKIP_UBOOT") == "y": + variant = "fastboot_" + elif config.get("CONFIG_VENDOR_PARTITION") == "y": + variant = "v_" + elif config.get("CONFIG_ROOTFS_B") == "y" and config.get("CONFIG_ROOTFS_RECOVERY") == "y": + variant = "abr_" + elif config.get("CONFIG_ROOTFS_B") == "y": + variant = "ab_" + elif config.get("CONFIG_ROOTFS_RECOVERY") == "y": + variant = "ar_" + else: + variant = "" + return f"partition_{variant}{'spinand' if flash == 'nand' else 'spinor' if flash == 'nor' else flash}.xml" + + +def build_type_from_tool(tool: Path, chip: str, flash: str) -> tuple[str | None, str]: + if not tool.is_file() or tool.is_symlink(): + return None, "unknown" + content = tool.read_text(encoding="utf-8") + found_chip: str | None = None + found_flash: str | None = None + build_type: str | None = None + for line in content.splitlines(): + chip_match = CHIP_LINE.search(line) + if chip_match: + found_chip, found_flash = chip_match.group(1), None + flash_match = FLASH_LINE.search(line) + if flash_match: + found_flash = flash_match.group(1) + board_match = BOARD_LINE.match(line) + if board_match and found_chip == chip and found_flash == flash: + build_type = board_match.group(1) + if "export CV_BUILD_TYPE" in line: + break + sync_match = re.search(r"(?m)^[ \t]*Ep_Sync_SDK_Config_Func[ \t]*$", content) + defconfig_match = re.search(r"(?m)^[ \t]*defconfig[ \t]+\$CV_BUILD_TYPE[ \t]*$", content) + order = "unknown" + if sync_match and defconfig_match: + order = "sync-before-defconfig" if sync_match.start() < defconfig_match.start() else "sync-after-defconfig" + return build_type, order + + +def tracked_git_state(sdk_root: Path) -> list[dict[str, object]]: + result = [] + for name in ("build", "cvitek_cv184x_patch", "linux_5.10", "u-boot-2021.10", "fsbl"): + path = sdk_root / name + if not (path / ".git").exists(): + result.append({"repo": name, "status": "not-a-repository"}) + continue + proc = subprocess.run(("git", "-C", str(path), "status", "--porcelain=v1", "--untracked-files=no"), + capture_output=True, text=True, check=False) + lines = [line for line in proc.stdout.splitlines() if line] + result.append({"repo": name, "status": "dirty" if lines else "clean" if proc.returncode == 0 else "unknown", + "tracked_change_count": len(lines), "sample": lines[:8]}) + return result + + +def inspect(project: Path, sdk_root: Path) -> dict[str, object]: + if project.is_symlink() or not project.is_dir() or sdk_root.is_symlink() or not sdk_root.is_dir(): + raise ValueError("项目与 SDK 必须是存在的普通目录,不能是软链接") + root = project_guard.normalize_root(project.parent) + project = project_guard.direct_child(root, project.name, "project", True) + sdk_root = sdk_root.resolve() + env_path = project / "buildEnvConfig.sh" + if not env_path.is_file() or env_path.is_symlink(): + raise ValueError("项目缺少普通 buildEnvConfig.sh") + env = bsp_preflight.parse_assignments(env_path) + chip, flash = env.get("EP_CHIP", ""), env.get("EP_FLASH_TYPE", "") + if not chip.startswith("CV184") or env.get("EP_COMPANY") != "Cvitek": + raise ValueError("本检查器只适用于 Cvitek CV184X 项目") + if not env.get("EP_SDK_VER") or sdk_root.name != env["EP_SDK_VER"]: + raise ValueError("指定 SDK 目录名与项目 EP_SDK_VER 不符") + layout = bsp_preflight.resolve_board_layout(project, chip) + if layout["error"] or layout["layout"] != "board-directory": + raise ValueError(str(layout["error"] or "CV184X 需要单板卡配置目录")) + board = str(layout["board_build_type"]) + sync = project / "configs/EPSYNCSDKConfig.sh" + if not sync.is_file() or sync.is_symlink(): + raise ValueError("缺少普通 EPSYNCSDKConfig.sh") + active = {line.split("#", 1)[0].strip() for line in sync.read_text(encoding="utf-8").splitlines() + if line.strip() and not line.lstrip().startswith("#")} + if not {"EP_UBOOT_BUILD_TOP=build/boards/cv184x/$CV_BUILD_TYPE", + f"rsync -av $EP_CONFIGS_PATH/{board}/. $EP_UBOOT_BUILD_TOP/"} <= active: + raise ValueError("同步脚本不符合已知 CV184X 映射;不猜测 SDK 目标") + expected, order = build_type_from_tool(root / "tools/BuildCvitek184XSDKTool.sh", chip, flash) + alerts: list[dict[str, str]] = [] + if expected != board: + alerts.append({"code": "build_type_mismatch", "message": f"统一脚本板型 {expected!r} 与项目目录 {board!r} 不一致"}) + if order != "sync-before-defconfig": + alerts.append({"code": "build_order_unknown", "message": "无法确认先同步项目配置再生成 SDK .config"}) + + board_dir = project / "configs" / board + sdk_board = sdk_root / "build/boards/cv184x" / board + mappings: list[dict[str, object]] = [] + for source in sorted(board_dir.rglob("*")): + if source.is_dir() and not source.is_symlink(): + continue + rel = source.relative_to(board_dir) + dest = sdk_board / rel + resolved = dest.resolve(strict=False) + inside = resolved.is_relative_to(sdk_root) + entry: dict[str, object] = {"project_file": str(source), "sdk_path": str(dest), + "sdk_real_path": str(resolved), "sdk_is_symlink": dest.is_symlink(), + "same_content": None} + if source.is_symlink() or not source.is_file(): + alerts.append({"code": "project_file_not_regular", "message": str(source)}) + elif not inside: + alerts.append({"code": "sdk_link_escape", "message": str(dest)}) + elif not dest.is_file(): + alerts.append({"code": "mapping_missing", "message": str(dest)}) + else: + entry["same_content"] = project_guard.sha256_file(source) == project_guard.sha256_file(dest) + if entry["same_content"] is False: + alerts.append({"code": "sdk_copy_differs", "message": str(rel)}) + if dest.is_symlink() and inside: + alerts.append({"code": "shared_sdk_target", "message": str(resolved)}) + mappings.append(entry) + + source_cfg = selected_config(board_dir / f"{board}_defconfig") + generated_cfg = selected_config(sdk_root / "build/.config") + differences = [{"key": key, "project": value, "sdk_generated": generated_cfg.get(key, "n")} + for key, value in sorted(source_cfg.items()) + if generated_cfg and generated_cfg.get(key, "n") != value] + if not generated_cfg: + alerts.append({"code": "generated_config_missing", "message": "SDK build/.config 缺失或不可读"}) + elif differences: + alerts.append({"code": "generated_config_differs", "message": "当前 SDK .config 与项目配置不同,需确认它属于哪次构建"}) + project_partition = partition_name(source_cfg, flash) + sdk_partition = partition_name(generated_cfg, flash) if generated_cfg else None + if not (board_dir / "partition" / project_partition).is_file(): + alerts.append({"code": "project_partition_missing", "message": f"项目配置选择的分区表不存在: {project_partition}"}) + if sdk_partition and not (sdk_board / "partition" / sdk_partition).is_file(): + alerts.append({"code": "sdk_partition_missing", "message": f"当前 SDK 配置选择的分区表不存在: {sdk_partition}"}) + + git_state = tracked_git_state(sdk_root) + if any(item["status"] == "dirty" for item in git_state): + alerts.append({"code": "dirty_sdk_repositories", "message": "关键 SDK 子仓库存在未提交的已跟踪文件改动"}) + patch_script = root / "tools/BuildCvitek184XSDKTool.sh" + patch_code = sdk_root / "cvitek_cv184x_patch/scripts/repos" + tool_text = patch_script.read_text(encoding="utf-8") if patch_script.is_file() else "" + patch_text = patch_code.read_text(encoding="utf-8") if patch_code.is_file() else "" + auto_reset = "--applypatch --force" in tool_text and '"git", "reset", "--hard"' in patch_text + if auto_reset: + alerts.append({"code": "patch_force_reset_possible", "message": "补丁检查失败时构建脚本可能强制重置 SDK 子仓库;需先确认补丁状态及影响"}) + if env.get("BUILD_SDK_DRIVER") == "n" and "build_osdrv" in tool_text and "BUILD_SDK_DRIVER" not in tool_text: + alerts.append({"code": "driver_switch_ignored", "message": "项目设置 BUILD_SDK_DRIVER=n,但当前 CV184X 专用工具未使用此变量,需按实际构建分支核对驱动"}) + + return {"schema_version": 1, "kind": "cv184x-sdk-readonly-inspection", + "project": str(project), "sdk_root": str(sdk_root), "chip": chip, "flash": flash, + "board_build_type": board, "tool_build_type": expected, "build_order": order, + "mappings": mappings, "generated_config_differences": differences, + "project_partition_candidate": project_partition, "sdk_partition_candidate": sdk_partition, + "inspected_git_repositories": git_state, "alerts": alerts, + "note": "只读磁盘快照;未执行同步、补丁检查、编译或板上验证。Git 状态仅覆盖列出的关键子仓库。"} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--project", required=True, type=Path) + parser.add_argument("--sdk-root", required=True, type=Path) + args = parser.parse_args() + try: + result = inspect(args.project, args.sdk_root) + except (ValueError, OSError, UnicodeDecodeError, project_guard.GuardError) as exc: + print(json.dumps({"passed": False, "error": str(exc)}, ensure_ascii=False, indent=2)) + return 2 + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/firmware-lowlevel-bsp-development/scripts/bsp/record_build_handoff.py b/skills/firmware-lowlevel-bsp-development/scripts/bsp/record_build_handoff.py index 4bf65a3..9fffccf 100644 --- a/skills/firmware-lowlevel-bsp-development/scripts/bsp/record_build_handoff.py +++ b/skills/firmware-lowlevel-bsp-development/scripts/bsp/record_build_handoff.py @@ -8,6 +8,7 @@ import hashlib import json import os import shlex +import stat import subprocess import tempfile import xml.etree.ElementTree as ET @@ -20,6 +21,9 @@ import project_guard REQUIRED_IMAGE_MARGIN_BYTES = 512 * 1024 REQUIRED_PARTITION_IMAGES = ("kernel.bin", "rootfs.bin") +REQUIRED_DIRECTORY_ACCESS_BITS = 0o555 +REQUIRED_FILE_READ_BITS = 0o444 +ALL_EXECUTE_BITS = 0o111 def now_utc() -> str: @@ -113,6 +117,119 @@ def artifact_inventory(output_directory: Path) -> tuple[list[dict[str, object]], return artifacts, warnings +def ensure_output_permissions( + output_root: Path, output_directory: Path +) -> dict[str, object]: + """Apply and verify a+rX on one build output path without following symlinks.""" + + output_root = output_root.expanduser().absolute() + if output_root.is_symlink(): + raise project_guard.GuardError(f"构建输出根目录是软链接: {output_root}") + output_root = output_root.resolve(strict=True) + output_directory = output_directory.expanduser().absolute() + if not output_root.is_dir() or output_root.is_symlink(): + raise project_guard.GuardError(f"构建输出根目录不是普通目录: {output_root}") + try: + relative_output = output_directory.relative_to(output_root) + except ValueError as exc: + raise project_guard.GuardError( + f"构建输出目录不在约定输出根目录内: {output_directory}" + ) from exc + + changes: list[dict[str, object]] = [] + checked = { + "path_directories": 0, + "tree_directories": 0, + "regular_files": 0, + "symlinks_skipped": 0, + } + + def apply_required_bits(path: Path, required_bits: int, kind: str) -> None: + if path.is_symlink(): + raise project_guard.GuardError(f"权限检查拒绝软链接路径节点: {path}") + try: + before = stat.S_IMODE(path.stat().st_mode) + requested = before | required_bits + if requested != before: + os.chmod(path, requested) + after = stat.S_IMODE(path.stat().st_mode) + except OSError as exc: + raise project_guard.GuardError(f"无法设置构建输出权限 {path}: {exc}") from exc + if after & required_bits != required_bits: + raise project_guard.GuardError( + f"构建输出权限不满足 {kind} 要求: {path}," + f"实际 {after:04o},至少需要 {required_bits:04o}" + ) + if after != before: + changes.append( + { + "path": str(path), + "kind": kind, + "before_mode": f"{before:04o}", + "after_mode": f"{after:04o}", + } + ) + + current = output_root + path_directories = [current] + for part in relative_output.parts: + current = current / part + path_directories.append(current) + for path in path_directories: + if not path.is_dir() or path.is_symlink(): + raise project_guard.GuardError(f"构建输出路径节点不是普通目录: {path}") + apply_required_bits(path, REQUIRED_DIRECTORY_ACCESS_BITS, "path_directory") + checked["path_directories"] += 1 + + def walk_error(exc: OSError) -> None: + raise project_guard.GuardError(f"无法遍历构建输出目录: {exc}") from exc + + for parent, directory_names, file_names in os.walk( + output_directory, followlinks=False, onerror=walk_error + ): + parent_path = Path(parent) + for name in list(directory_names): + path = parent_path / name + if path.is_symlink(): + directory_names.remove(name) + checked["symlinks_skipped"] += 1 + continue + if not path.is_dir(): + raise project_guard.GuardError(f"构建输出包含非普通目录: {path}") + apply_required_bits(path, REQUIRED_DIRECTORY_ACCESS_BITS, "tree_directory") + checked["tree_directories"] += 1 + for name in file_names: + path = parent_path / name + if path.is_symlink(): + checked["symlinks_skipped"] += 1 + continue + if not path.is_file(): + raise project_guard.GuardError(f"构建输出包含非普通文件: {path}") + before = stat.S_IMODE(path.stat().st_mode) + required = REQUIRED_FILE_READ_BITS + if before & ALL_EXECUTE_BITS: + required |= ALL_EXECUTE_BITS + apply_required_bits(path, required, "regular_file") + checked["regular_files"] += 1 + + return { + "schema_version": 1, + "passed": True, + "checked_at": now_utc(), + "policy": "a+rX", + "output_root": str(output_root), + "imgs_version_path": str(output_directory), + "checked": checked, + "changed_count": len(changes), + "changes": changes, + "notes": [ + "目录和输出路径节点必须对所有用户可读、可进入。", + "普通文件必须对所有用户可读;只有原本可执行的文件才向所有用户补执行权限。", + "不跟随软链接,也不修改软链接目标。", + ], + } + + def resolve_partition_table(project: Path, requested: Path | None) -> Path: configs = project / "configs" if not configs.is_dir() or configs.is_symlink(): @@ -258,10 +375,14 @@ def record_handoff( root = project_guard.normalize_root(root) project = project_guard.direct_child(root, project_name, "project", True) - output_root = output_root.expanduser().resolve(strict=False) + output_root = output_root.expanduser().absolute() + if output_root.is_symlink(): + raise project_guard.GuardError(f"构建输出根目录是软链接: {output_root}") + output_root = output_root.resolve(strict=False) output_directory = output_root / project_name / "imgs" / build_version if not output_directory.is_dir() or output_directory.is_symlink(): raise project_guard.GuardError(f"最终 imgs 版本目录不存在或不是普通目录: {output_directory}") + output_permission_check = ensure_output_permissions(output_root, output_directory) artifacts, warnings = artifact_inventory(output_directory) if not artifacts: raise project_guard.GuardError("最终 imgs 版本目录没有非空普通文件") @@ -272,13 +393,10 @@ def record_handoff( build_env = project / "buildEnvConfig.sh" assignments = bsp_preflight.parse_assignments(build_env) if build_env.is_file() else {} - config_dirs = bsp_preflight.config_directories(project) - config_dir = config_dirs[0] if len(config_dirs) == 1 else None - if len(config_dirs) != 1: - warnings.append(f"无法唯一确定 board build type:找到 {len(config_dirs)} 个配置目录") - system_form = ( - "dual-os" if config_dir is not None and bsp_preflight.detects_dual_os(config_dir) else "single-os" - ) + layout = bsp_preflight.resolve_board_layout(project, assignments.get("EP_CHIP")) + if layout["error"]: + raise project_guard.GuardError(str(layout["error"])) + system_form = layout["system_form"] command = build_command or ( f"cd {shlex.quote(str(root))} && source tools/script_completion.sh && " f"EMX2_BuildSDKTool {project_name} {build_version} all" @@ -288,6 +406,7 @@ def record_handoff( handoff_dir = project / "doc" / "bsp_adaptation" / "handoff" hash_path = validation_dir / f"artifact_manifest_{build_version}.sha256" margin_path = validation_dir / f"image_partition_margin_{build_version}.json" + permission_path = validation_dir / f"output_permissions_{build_version}.json" hash_lines = "".join( f"{item['sha256']} {item['relative_path']}\n" for item in artifacts ) @@ -295,6 +414,9 @@ def record_handoff( write_text_atomic( margin_path, json.dumps(image_partition_check, ensure_ascii=False, indent=2) + "\n" ) + write_text_atomic( + permission_path, json.dumps(output_permission_check, ensure_ascii=False, indent=2) + "\n" + ) manifest: dict[str, object] = { "schema_version": 1, @@ -312,7 +434,7 @@ def record_handoff( "sdk": assignments.get("EP_SDK_VER"), "architecture": assignments.get("EP_ARCH"), "flash": assignments.get("EP_FLASH_TYPE"), - "board_build_type": config_dir.name if config_dir else None, + "board_build_type": layout["board_build_type"], "system_form": system_form, }, "build": { @@ -326,11 +448,13 @@ def record_handoff( "imgs_version_path": str(output_directory), "artifact_manifest_path": str(hash_path), "image_partition_margin_path": str(margin_path), + "output_permissions_path": str(permission_path), }, "artifacts": artifacts, "validation": { "local_build": "passed", "image_partition_margin": image_partition_check, + "output_permissions": output_permission_check, "hardware": "pending", "owner_acceptance": "pending", "release": "not-authorized", @@ -342,7 +466,7 @@ def record_handoff( "immutable_artifacts": True, "verify_sha256_before_use": True, "select_latest_directory": False, - "baseline_change_rule": "Any project, build version, artifact path/size/hash, partition table/hash, or image-margin change invalidates downstream PSP validation.", + "baseline_change_rule": "Any project, build version, artifact path/size/hash, partition table/hash, output-permission evidence, or image-margin change invalidates downstream PSP validation.", }, } handoff_path = handoff_dir / "psp_handoff.json" @@ -358,6 +482,8 @@ def record_handoff( "psp_handoff_path": str(handoff_path), "image_partition_margin_path": str(margin_path), "image_partition_margin": image_partition_check, + "output_permissions_path": str(permission_path), + "output_permissions": output_permission_check, "hardware_validation": "pending", "owner_acceptance": "pending", "release": "not-authorized", @@ -371,6 +497,8 @@ def record_handoff( "artifact_manifest_path": str(hash_path), "image_partition_margin_path": str(margin_path), "image_partition_margin": image_partition_check, + "output_permissions_path": str(permission_path), + "output_permissions": output_permission_check, "psp_handoff_path": str(handoff_path), "status_path": str(status_path), "warnings": warnings, diff --git a/skills/firmware-lowlevel-bsp-development/scripts/bsp/test_bsp_build_handoff.py b/skills/firmware-lowlevel-bsp-development/scripts/bsp/test_bsp_build_handoff.py index b067b1b..66e9e7b 100644 --- a/skills/firmware-lowlevel-bsp-development/scripts/bsp/test_bsp_build_handoff.py +++ b/skills/firmware-lowlevel-bsp-development/scripts/bsp/test_bsp_build_handoff.py @@ -3,8 +3,11 @@ import hashlib import json +import os +import stat import tempfile from pathlib import Path +from unittest.mock import patch import bsp_preflight import project_guard @@ -72,6 +75,19 @@ def main() -> int: kernel.write_bytes(b"k" * (512 * 1024)) rootfs = output / "rootfs.bin" rootfs.write_bytes(b"r" * (256 * 1024)) + helper_dir = output / "tools" + helper_dir.mkdir() + helper = helper_dir / "inspect.sh" + helper.write_text("#!/bin/sh\n", encoding="utf-8") + os.chmod(output_root, 0o700) + os.chmod(output_root / reference.name, 0o700) + os.chmod(output_root / reference.name / "imgs", 0o700) + os.chmod(output, 0o700) + os.chmod(helper_dir, 0o700) + os.chmod(boot, 0o600) + os.chmod(kernel, 0o640) + os.chmod(rootfs, 0o600) + os.chmod(helper, 0o700) conflict = bsp_preflight.build_preflight( reference.name, "20260902_1", root, output_root ) @@ -95,6 +111,21 @@ def main() -> int: assert recorded["passed"] is True, recorded assert recorded["imgs_version_path"] == str(output.resolve()) assert recorded["image_partition_margin"]["passed"] is True + assert recorded["output_permissions"]["passed"] is True + assert recorded["output_permissions"]["policy"] == "a+rX" + assert recorded["output_permissions"]["changed_count"] >= 1 + for directory in ( + output_root, + output_root / reference.name, + output_root / reference.name / "imgs", + output, + helper_dir, + ): + assert stat.S_IMODE(directory.stat().st_mode) & 0o555 == 0o555 + for artifact in (boot, kernel, rootfs, helper): + assert stat.S_IMODE(artifact.stat().st_mode) & 0o444 == 0o444 + assert stat.S_IMODE(boot.stat().st_mode) & 0o111 == 0 + assert stat.S_IMODE(helper.stat().st_mode) & 0o111 == 0o111 handoff_path = Path(recorded["psp_handoff_path"]) handoff = json.loads(handoff_path.read_text(encoding="utf-8")) assert handoff["handoff_type"] == "bsp-to-psp" @@ -109,10 +140,24 @@ def main() -> int: assert kernel_check["actual_margin_bytes"] == 512 * 1024 assert kernel_check["effective_partition_size_bytes"] == 1024 * 1024 assert Path(recorded["image_partition_margin_path"]).is_file() + assert Path(recorded["output_permissions_path"]).is_file() + assert handoff["validation"]["output_permissions"]["passed"] is True + assert handoff["build"]["output_permissions_path"] == recorded["output_permissions_path"] assert handoff["consumer_contract"]["select_latest_directory"] is False assert handoff["artifacts"][0]["sha256"] == hashlib.sha256(boot.read_bytes()).hexdigest() assert handoff["validation"]["hardware"] == "pending" + linked_output_root = Path(tmp) / "linked-output" + linked_output_root.symlink_to(output_root, target_is_directory=True) + try: + record_build_handoff.record_handoff( + reference.name, "20260902_1", 0, True, root, linked_output_root, None, [] + ) + except project_guard.GuardError as exc: + assert "输出根目录是软链接" in str(exc) + else: + raise AssertionError("linked output root must not be granted permissions") + overflow_output = output_root / reference.name / "imgs" / "20260902_2" overflow_output.mkdir(parents=True) (overflow_output / "boot.bin").write_bytes(b"boot") @@ -141,6 +186,41 @@ def main() -> int: / "image_partition_margin_20260902_2.json" ).exists() + denied_output = output_root / reference.name / "imgs" / "20260902_3" + denied_output.mkdir(parents=True) + denied_boot = denied_output / "boot.bin" + denied_boot.write_bytes(b"boot") + (denied_output / "kernel.bin").write_bytes(b"k" * (512 * 1024)) + (denied_output / "rootfs.bin").write_bytes(b"r" * (256 * 1024)) + os.chmod(denied_boot, 0o600) + with patch.object( + record_build_handoff.os, + "chmod", + side_effect=PermissionError(13, "permission denied"), + ): + try: + record_build_handoff.record_handoff( + reference.name, + "20260902_3", + 0, + True, + root, + output_root, + None, + [], + ) + except project_guard.GuardError as exc: + assert "无法设置构建输出权限" in str(exc) + else: + raise AssertionError("permission failure must block BSP handoff") + assert not ( + reference + / "doc" + / "bsp_adaptation" + / "validation" + / "output_permissions_20260902_3.json" + ).exists() + original_partition = ( reference / "configs" diff --git a/skills/firmware-lowlevel-bsp-development/scripts/bsp/test_cv181x_layout.py b/skills/firmware-lowlevel-bsp-development/scripts/bsp/test_cv181x_layout.py new file mode 100644 index 0000000..1caaf46 --- /dev/null +++ b/skills/firmware-lowlevel-bsp-development/scripts/bsp/test_cv181x_layout.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Regression checks for CV181X split layout and opt-in read-only SDK mapping.""" + +from pathlib import Path +from tempfile import TemporaryDirectory + +import bsp_preflight +import inspect_cv181x_sdk + + +def write(path: Path, value: str = "test\n") -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(value, encoding="utf-8") + + +def main() -> int: + with TemporaryDirectory() as temporary: + root = Path(temporary) / "EmxRes" + root.mkdir() + board = "cv1815ja_wevb_0020a_spinand" + project = root / "CV1815JA-TEST" + project.mkdir() + write(project / "buildEnvConfig.sh", "EP_CHIP=CV1815JA\nEP_COMPANY=Cvitek\nEP_FLASH_TYPE=nand\nEP_SDK_VER=v4.1.6_beta\n") + write(project / "configs/uboot" / f"{board}_defconfig", "CONFIG_ENABLE_FREERTOS=y\nCONFIG_CHIP_CV1815JA=y\n") + write(project / "configs/uboot" / f"cvitek_{board}_defconfig") + write(project / "configs/kernel" / f"cvitek_{board}_defconfig") + write(project / "configs/uboot/cvi_board_init.c") + write(project / "configs/uboot/partition_spinand.xml") + write(project / "configs/kernel/memmap.py") + write(project / "configs/kernel" / f"{board}.dts") + write(project / "configs/kernel/cv181x_base.dtsi") + write(project / "configs/kernel/ep_kernel.sh", "\n".join(( + "EP_KERNEL_BUILD_TOP=build/boards/cv181x/$CV_BUILD_TYPE", + "EP_KERNEL_BASE_DTSI=build/boards/default/dts/cv181x", + "cp $EP_KERNEL_CONFIGS_PATH/memmap.py $EP_KERNEL_BUILD_TOP/memmap.py", + 'cp $EP_KERNEL_CONFIGS_PATH/cvitek_$CV_BUILD_TYPE"_defconfig" $EP_KERNEL_BUILD_TOP/linux/cvitek_$CV_BUILD_TYPE"_defconfig"', + "cp $EP_KERNEL_CONFIGS_PATH/*.dts $EP_KERNEL_BUILD_TOP/dts_arm/", + "cp $EP_KERNEL_CONFIGS_PATH/*.dtsi $EP_KERNEL_BASE_DTSI/", + ))) + write(project / "configs/uboot/ep_uboot.sh", "\n".join(( + "EP_UBOOT_BUILD_TOP=build/boards/cv181x/$CV_BUILD_TYPE", + 'cp $EP_UBOOT_CONFIGS_PATH/$CV_BUILD_TYPE"_defconfig" $EP_UBOOT_BUILD_TOP/', + 'cp $EP_UBOOT_CONFIGS_PATH/cvitek_$CV_BUILD_TYPE"_defconfig" $EP_UBOOT_BUILD_TOP/u-boot/cvitek_$CV_BUILD_TYPE"_defconfig"', + "cp $EP_UBOOT_CONFIGS_PATH/partition_spinand.xml $EP_UBOOT_BUILD_TOP/partition/partition_spinand.xml", + "cp $EP_UBOOT_CONFIGS_PATH/cvi_board_init.c $EP_UBOOT_BUILD_TOP/u-boot/cvi_board_init.c", + ))) + write(root / "tools/BuildCvitekSDKTool.sh", "defconfig $CV_BUILD_TYPE\nEp_Uboot_Config_Func\nEp_Kernel_Config_Func\n") + + layout = bsp_preflight.resolve_board_layout(project, "CV1815JA") + assert layout["layout"] == "split-kernel-uboot", layout + assert layout["board_build_type"] == board, layout + assert layout["system_form"] == "single-os", layout + report = bsp_preflight.project_preflight(project.name, "CV1815JA-NEW", root) + assert report["passed"] is True, report + assert report["facts"]["config_layout"] == "split-kernel-uboot", report + assert len(report["bounded_paths"]["sync_scripts"]) == 2, report + assert bsp_preflight.resolve_board_layout(project, "CV1841CP")["error"] + write(root / "tools/script_completion.sh") + project.joinpath("imgs").symlink_to(f"/epbuild/EMX2.0/EmxRes/{project.name}/imgs") + build_report = bsp_preflight.build_preflight(project.name, "20260923_1", root, Path(temporary) / "output") + assert build_report["passed"] is True, build_report + assert build_report["config_layout"] == "split-kernel-uboot", build_report + assert build_report["requires_patch_precheck"] is False, build_report + + sdk = Path(temporary) / "v4.1.6_beta" + for source_rel, dest_rel in inspect_cv181x_sdk.mapped_files(board, project): + write(sdk / dest_rel, (project / source_rel).read_text(encoding="utf-8")) + shared = sdk / "build/boards/default/memmap/shared.py" + write(shared, "test\n") + memmap = sdk / "build/boards/cv181x" / board / "memmap.py" + memmap.unlink() + memmap.symlink_to(shared) + write(sdk / "build/.config", "CONFIG_CHIP_CV1815JA=n\n") + result = inspect_cv181x_sdk.inspect(project, sdk) + codes = {item["code"] for item in result["alerts"]} + assert {"shared_sdk_target", "generated_config_differs", "sync_after_defconfig"} <= codes, codes + assert len(result["mappings"]) == 8, result + try: + inspect_cv181x_sdk.inspect(project, Path(temporary)) + except ValueError as exc: + assert "EP_SDK_VER" in str(exc) + else: + raise AssertionError("mismatched SDK root must fail") + + (project / "configs/kernel" / f"cvitek_{board}_defconfig").unlink() + assert bsp_preflight.resolve_board_layout(project, "CV1815JA")["error"] + assert bsp_preflight.build_preflight(project.name, "20260923_2", root, Path(temporary) / "output")["passed"] is False + + cv184 = root / "CV1841CP-TEST" + write(cv184 / "configs/cv1841cp_wevb_0015a_spinand/board_defconfig") + old = bsp_preflight.resolve_board_layout(cv184, "CV1841CP") + assert old["layout"] == "board-directory", old + assert old["board_build_type"] == "cv1841cp_wevb_0015a_spinand", old + assert bsp_preflight.resolve_board_layout(cv184, "CV1815JA")["error"] + write(cv184 / "buildEnvConfig.sh", "EP_CHIP=CV1841CP\n") + cv184.joinpath("imgs").symlink_to(f"/epbuild/EMX2.0/EmxRes/{cv184.name}/imgs") + cv184_build = bsp_preflight.build_preflight(cv184.name, "20260923_1", root, Path(temporary) / "output") + assert cv184_build["requires_patch_precheck"] is True, cv184_build + assert any("--applypatch --force" in item for item in cv184_build["warnings"]), cv184_build + print("CV181X layout and SDK read-only inspection tests passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/firmware-lowlevel-bsp-development/scripts/bsp/test_cv184x_sdk_inspection.py b/skills/firmware-lowlevel-bsp-development/scripts/bsp/test_cv184x_sdk_inspection.py new file mode 100644 index 0000000..c36f078 --- /dev/null +++ b/skills/firmware-lowlevel-bsp-development/scripts/bsp/test_cv184x_sdk_inspection.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Behavior tests for the bounded CV184X read-only SDK inspector.""" + +from pathlib import Path +from tempfile import TemporaryDirectory + +import inspect_cv184x_sdk + + +def write(path: Path, value: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(value, encoding="utf-8") + + +def main() -> int: + with TemporaryDirectory() as temporary: + root = Path(temporary) / "EmxRes" + board = "cv1841cp_wevb_0015a_spinand" + project = root / "CV1841CP-TEST" + sdk = Path(temporary) / "cv184x_v6.3.3" + write(project / "buildEnvConfig.sh", "EP_COMPANY=Cvitek\nEP_CHIP=CV1841CP\nEP_FLASH_TYPE=nand\nEP_SDK_VER=cv184x_v6.3.3\nBUILD_SDK_DRIVER=n\n") + write(project / "configs/EPSYNCSDKConfig.sh", + "EP_UBOOT_BUILD_TOP=build/boards/cv184x/$CV_BUILD_TYPE # known board\n" + f"rsync -av $EP_CONFIGS_PATH/{board}/. $EP_UBOOT_BUILD_TOP/\nexit 0\n") + source = project / "configs" / board + write(source / f"{board}_defconfig", "CONFIG_DUAL_OS=y\nCONFIG_ION_SIZE=0x600000\n") + write(source / "partition/partition_spinand.xml", "\n") + write(root / "tools/BuildCvitek184XSDKTool.sh", + 'if [[ $EP_CHIP == "CV1841CP" ]];then\n' + ' if [[ $EP_FLASH_TYPE == "nor" ]];then\n' + ' CV_BUILD_TYPE=cv1841cp_wevb_0015a_spinor\n' + ' elif [[ $EP_FLASH_TYPE == "nand" ]];then\n' + f' CV_BUILD_TYPE={board}\n' + ' fi\nfi\nexport CV_BUILD_TYPE\n' + 'Ep_Sync_SDK_Config_Func\ndefconfig $CV_BUILD_TYPE\n' + './cvitek_cv184x_patch/scripts/repos --applypatch --force\nbuild_osdrv\n') + write(sdk / "cvitek_cv184x_patch/scripts/repos", 'run_cmd(["git", "reset", "--hard", expected_commit])\n') + write(sdk / "build/boards/cv184x" / board / f"{board}_defconfig", "CONFIG_DUAL_OS=n\n") + write(sdk / "build/boards/cv184x" / board / "partition/partition_spinand.xml", "\n") + write(sdk / "build/.config", "CONFIG_DUAL_OS=n\nCONFIG_ION_SIZE=0x600000\n") + result = inspect_cv184x_sdk.inspect(project, sdk) + assert result["tool_build_type"] == board, result + assert result["build_order"] == "sync-before-defconfig", result + assert len(result["mappings"]) == 2, result + assert result["project_partition_candidate"] == "partition_spinand.xml", result + codes = {item["code"] for item in result["alerts"]} + assert {"sdk_copy_differs", "generated_config_differs", "patch_force_reset_possible", "driver_switch_ignored"} <= codes, codes + assert "build_type_mismatch" not in codes, codes + assert inspect_cv184x_sdk.partition_name({"CONFIG_ROOTFS_B": "y"}, "nand") == "partition_ab_spinand.xml" + assert inspect_cv184x_sdk.partition_name({"CONFIG_ROOTFS_B": "y", "CONFIG_ROOTFS_RECOVERY": "y"}, "nand") == "partition_abr_spinand.xml" + + write(root / "tools/BuildCvitek184XSDKTool.sh", + 'if [[ $EP_CHIP == "CV1841CP" ]];then\n' + ' if [[ $EP_FLASH_TYPE == "nand" ]];then\n' + ' CV_BUILD_TYPE=cv1841cp_other_spinand\n' + ' fi\nfi\nexport CV_BUILD_TYPE\n' + 'defconfig $CV_BUILD_TYPE\nEp_Sync_SDK_Config_Func\n') + mismatch = inspect_cv184x_sdk.inspect(project, sdk) + codes = {item["code"] for item in mismatch["alerts"]} + assert {"build_type_mismatch", "build_order_unknown"} <= codes, codes + + write(project / "configs/EPSYNCSDKConfig.sh", "rsync -av $EP_CONFIGS_PATH/unknown/. $EP_UBOOT_BUILD_TOP/\n") + try: + inspect_cv184x_sdk.inspect(project, sdk) + except ValueError as exc: + assert "同步脚本" in str(exc) + else: + raise AssertionError("unknown mapping must fail closed") + + try: + inspect_cv184x_sdk.inspect(project, Path(temporary)) + except ValueError as exc: + assert "EP_SDK_VER" in str(exc) + else: + raise AssertionError("wrong SDK root must fail") + print("CV184X read-only SDK inspection tests passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/firmware-lowlevel-development/references/bsp-psp-handoff.md b/skills/firmware-lowlevel-development/references/bsp-psp-handoff.md index 52e3a3d..7894d7e 100644 --- a/skills/firmware-lowlevel-development/references/bsp-psp-handoff.md +++ b/skills/firmware-lowlevel-development/references/bsp-psp-handoff.md @@ -14,13 +14,14 @@ manifest 的 `schema_version` 为 `1`、`handoff_type` 为 `bsp-to-psp`,并至 - 芯片、SDK、架构、Flash、board build type 和单/双系统形态; - 构建版本、状态、退出码、完成标记、工作目录和完整命令; - `imgs_root` 和精确到构建版本的 `imgs_version_path`; +- 本次 imgs 版本路径的 `a+rX` 处理结果、复核摘要和权限证据路径; - 每个非空产物的相对路径、绝对路径、大小和 SHA-256; - artifact hash 清单路径; - 实际分区表路径/hash,以及 `kernel.bin`、`rootfs.bin` 的实际大小、全部对应分区、最小容量、至少 512 KiB 的实际余量和检查证据路径; - 本地构建、硬件验证、负责人接受和发布状态; - 风险、警告和下游基线失效规则。 -只有退出码为零、正常完成标记存在、imgs 版本目录含非空产物,且 kernel/rootfs 相对最小对应分区都保留至少 512 KiB 时,才能写 `build.status=build_passed`。本地构建成功时,硬件验证仍默认为 `pending`,发布仍为 `not-authorized`。 +只有退出码为零、正常完成标记存在、imgs 版本目录含非空产物、本次输出路径通过 `a+rX` 权限复核,且 kernel/rootfs 相对最小对应分区都保留至少 512 KiB 时,才能写 `build.status=build_passed`。本地构建成功时,硬件验证仍默认为 `pending`,发布仍为 `not-authorized`。 ## PSP 消费者 @@ -28,7 +29,7 @@ PSP 接收 BSP 基线时: 1. 读取明确提供的 `psp_handoff.json`,不得扫描 imgs 根目录并自动选择最新版本。 2. 校验 schema、handoff type、项目/芯片/SDK/build type 与 PSP 目标一致。 -3. 要求 `build.status=build_passed`,确认精确 `imgs_version_path` 存在。 +3. 要求 `build.status=build_passed`,确认精确 `imgs_version_path` 存在,并核对 `validation.output_permissions.passed=true` 与项目内的权限证据文件。复核实际路径权限;若本次输出权限不足,在项目身份及证据一致时自动只对本次 imgs 路径补齐 `a+rX` 并复核,不回写 BSP handoff。证据缺失、路径不一致、无权修复或复核仍失败时停止,不把权限问题误报成产物不存在。 4. 对实际使用的每个 BSP 产物重新检查大小和 SHA-256;重新核对分区表 hash、外部余量证据,以及 kernel/rootfs 相对最小对应分区的 512 KiB 门禁;在 PSP 依赖闭包 manifest 中引用 BSP 交接文件自身的路径/hash。 5. 原样保留 BSP 的硬件、负责人和发布状态;PSP 组包不能替 BSP 升级这些状态。 6. 项目、构建版本、路径、大小或任一 hash 变化都会使相关 PSP 组包和验证失效,必须重新锁定基线。 @@ -40,9 +41,11 @@ PSP 使用以下只读校验器生成依赖证据: ```bash python3 /scripts/psp/validate_bsp_handoff.py \ /doc/bsp_adaptation/handoff/psp_handoff.json \ + --repair-output-permissions \ --chip <目标芯片> \ + --project <目标BSP项目> \ --sdk <目标SDK> \ --build-type <目标build type> ``` -交接 manifest 缺失或校验失败时,PSP 可以保留静态调查结果,但不能自行猜测 BSP 版本、形成可交付 candidate 或声明 packaged。 +不带 `--repair-output-permissions` 时命令只读检查;PSP 已确认的适配流程启用受控权限修复。交接 manifest 缺失或校验失败时,PSP 可以保留静态调查结果,但不能自行猜测 BSP 版本、形成可交付 candidate 或声明 packaged。 diff --git a/skills/firmware-lowlevel-psp-development/SKILL.md b/skills/firmware-lowlevel-psp-development/SKILL.md index a57b141..f9dde0a 100644 --- a/skills/firmware-lowlevel-psp-development/SKILL.md +++ b/skills/firmware-lowlevel-psp-development/SKILL.md @@ -1,19 +1,19 @@ --- name: firmware-lowlevel-psp-development -description: "基于相近板卡 PSP 创建、修改和验证项目级平台配置,锁定 BSP 固件、Media 与 AIPlugin 依赖,并通过固定打包环境形成可交给应用层的精确 Git 基线。用于 PSP 配置适配、组包证据、人工设备验证和 Etunel 交接;不用于 BSP/DTS/驱动实现、应用业务、公共组件开发或未经确认的烧录、上传与发布。" +description: "创建、修改和验证项目级 PSP 平台配置;有相近板卡 PSP 时受控继承,无参考项目时依据硬件/BSP 事实、应用接口与精确 Media/AIPlugin Release 默认配置逐字段建立。用于 CV181X Media/AI 通道和参数审计、PSP 适配、组包证据及交接;不用于 BSP/驱动、应用业务、公共组件或未经确认的设备/发布操作。" --- # 固件底层开发_PSP开发 -把每个硬件项目的 PSP 作为独立、轻量的配置仓库维护。PSP 固定工作区是当前用户的 `~/work/project/EMX2.1/AppTeam/Psp/`;项目目录由用户或团队预先在该根目录下创建。以用户指定的相近板卡 PSP 为参考,只做目标项目所需的最小差异;PSP 的唯一身份是完整 Git commit SHA。 +把每个硬件项目的 PSP 作为独立、轻量的配置仓库维护。PSP 固定工作区是当前用户的 `~/work/project/EMX2.1/AppTeam/Psp/`;项目目录由用户或团队预先在该根目录下创建。有参考 PSP 时只做最小差异;没有参考 PSP 时从已确认的硬件/BSP、应用接口和精确 Release 基线建立,不把任一 Release 当成完整 PSP。PSP 的唯一身份是完整 Git commit SHA。 ## 进入流程 1. 先读取并遵守统一的[流程文档写法](../firmware-lowlevel-development/references/chinese-process-docs.md),所有面向人的流程文档使用简体中文和直白表达。 2. 读取统一入口的[项目所有权契约](../firmware-lowlevel-development/references/ownership-contract.md)、[治理工作流](../firmware-lowlevel-development/references/governance.md)和[风险等级](../firmware-lowlevel-development/references/risk-levels.md)。 -3. 读取 [PSP 主工作流](references/workflow.md),确认目标项目目录是固定工作区的直接子目录,并确认参考 PSP、芯片/板卡/系统形态、BSP handoff、差异清单和负责人。目标目录不存在时停止,请用户或团队先创建;不得代为创建目录或初始化仓库。 +3. 读取 [PSP 主工作流](references/workflow.md),先形成带来源/hash 的硬件拓扑事实,再确认目标项目目录是固定工作区的直接子目录,并确认是否有参考 PSP、芯片/板卡/系统形态、BSP handoff、差异清单和负责人。目标目录不存在时停止,请用户或团队先创建;不得代为创建目录或初始化仓库。 4. 首次填充、迁移、提交或更新 PSP 仓库时,读取[项目与 Git 规则](references/project-and-git.md)。首次填充只对预创建的空独立 Git 仓库执行:先在 staging 中适配并审计,验证通过后自动导入并创建本地候选提交,同时保留 `.git`、分支和远端;不再为导入和提交重复询问。已有业务内容的项目转入现有项目更新流程,不得按参考复制覆盖。 -5. 修改 GPIO、BSP 引用、内存/分区结果、Sensor、Media 或 AIPlugin 配置时,读取[配置边界与检查](references/configuration-boundaries.md)。CV18 Sensor 默认按芯片系列使用 skill 内置枚举表,把规格书选型自动映射为配置名称或数值;无需临时查找 SDK 头文件。ISP/PQ 由用户更新。PSP 只消费 BSP 和公共组件成果,不越界修改其源码。 +5. 修改 GPIO、BSP 引用、内存/分区结果、Sensor、Media 或 AIPlugin 配置时,读取[配置边界与检查](references/configuration-boundaries.md)。CV181X Media/AI 配置另读[源码与 Release 取值指南](references/cv181x-media-ai-configuration.md),按每个字段的生效路径、代码行为和实际硬件需求取值。CV18 Sensor 默认按芯片系列使用 skill 内置枚举表;ISP/PQ 由用户更新。PSP 只消费 BSP 和公共组件成果,不越界修改其源码。 6. 使用 BSP 产物时,读取统一的 [BSP→PSP 交接契约](../firmware-lowlevel-development/references/bsp-psp-handoff.md),校验用户明确提供的 `psp_handoff.json`;禁止扫描目录选择最新构建。 7. 需要组包或设备验证时,读取[组包与设备验证](references/packaging-and-device-validation.md)。EMX2.1 的目录和命令按内置固定规则生成本地契约,再做打包准备检查;不用让用户重复提供已经固定的信息。 8. 需要跨角色交付或返工时,读取 [Etunel 交接](references/etunel-handoff.md);机器可读文件按[数据契约](references/schemas.md)生成。 @@ -26,9 +26,10 @@ PSP 仓库不保存固定打包框架、BSP 源码或 IMG、大型模型、公 ## 不可绕过的门禁 -- 新 PSP 必须有用户明确指定的参考 PSP;先核对芯片系列、系统形态、存储介质、Sensor、GPIO、媒体和 AIPlugin 能力。明显不兼容时停止,除非用户知情确认跨平台参考。 +- 新 PSP 优先使用用户明确指定的兼容参考 PSP;没有参考时允许走 `prepare-empty`,但必须独立证明硬件/BSP、应用 `psp.json` schema、Media/AI 精确 Release、运行配置、资源和分区闭包。仅凭 Release 默认值不能形成 candidate;跨平台参考明显不兼容时停止,除非用户知情确认风险。 +- 原理图、规格书、GPIO 表和 BSP handoff 必须先收敛成 `hardware-topology.json`。目标旧配置和参考 PSP(若有)只证明软件现状,不能覆盖硬件资料;资料冲突未解决时停止。 - 目标 PSP 必须位于当前用户的 `~/work/project/EMX2.1/AppTeam/Psp/<项目名>/`,是该固定根目录的普通直接子目录和独立 Git 仓库。目录缺失、软链接、嵌套仓库或根目录外路径均停止;不运行 `mkdir` 或 `git init` 补齐。 -- 默认继承参考 PSP,但必须分别展示修改项和关键继承项。能由参考兼容性、BSP handoff 和仓库证据证明的继承值不要求用户重复确认;GPIO、Sensor、BSP 固件、Media 或 AIPlugin 仍有冲突、缺项或多种合理解释时才阻止形成 `candidate`。 +- 有参考时分别展示修改项和关键继承项;无参考时展示 Release 默认→最终生效值的逐字段差异及每项来源。能由代码、Release、BSP handoff 和硬件资料证明的值不要求用户重复确认;GPIO、Sensor、BSP 固件、Media 或 AIPlugin 有冲突、缺项或多种合理解释时阻止形成 `candidate`。 - CV181X 的 `sensor_cfg.ini` 中 `name` 必须填写内置 `sensor_list.h` 的 `_SAMPLE_SNS_TYPE_E` 枚举名称;CV184X 必须填写内置 `sensor_cfg.h` 的 `_SNS_TYPE_E` 对应枚举值。规格选型不明确或内置表中无匹配项时停止,不得猜测;只有已证明 SDK 枚举不同才使用显式头文件覆盖。 - 保留团队统一环境中的绝对路径;检查全部 JSON 和配置/脚本文本。允许公共构建根路径;个人目录和临时目录必须警告,正式交付仅在用户显式批准精确根路径后放行。 - `psp.json`、BSP handoff、配置接口或依赖基线不匹配时,不通过寻找近似文件、选择最新目录、静默覆盖或降低检查来继续。 @@ -42,7 +43,11 @@ PSP 仓库不保存固定打包框架、BSP 源码或 IMG、大型模型、公 ## 确定性辅助工具 - `scripts/psp/psp_project_guard.py`:用 `inspect` 只读校验固定 PSP 根目录、项目路径和独立仓库并区分空仓库/现有项目;首次填充时受控复制到 staging,检查差异白名单与旧项目残留,验证通过后自动导入内容、复核最终目录且证明 Git 分支/远端未改变;不创建目录、不初始化仓库。 -- `scripts/psp/validate_bsp_handoff.py`:校验 BSP handoff、精确 imgs 路径和产物 hash。 +- `scripts/psp/validate_hardware_topology.py`:校验证据文件/hash、存储和每路 Sensor 物理事实,并用显式 `config_expectations` 对照唯一 `sensor_cfg.ini`。 +- `scripts/psp/analyze_psp_delta.py`:同时比较目标 HEAD、目标 worktree、参考 HEAD 和参考 worktree,区分稳定参考、参考未提交改动、目标特有改动和未分类冲突。 +- `scripts/psp/audit_cv181x_media_ai.py`:只读枚举 CV181X 精确 Media/AI Release 的全部 JSON 叶子字段、默认值/hash、源码读取位置线索,核对 SDK/ABI 标识、VPSS 图和 PSP 候选差异;不把代码命中或静态通过当成硬件正确性证明。 +- `scripts/psp/validate_bsp_handoff.py`:校验 BSP handoff、权限证据、精确 imgs 路径和产物 hash。直接运行默认只读;PSP 已确认的适配流程自动在证据和项目身份一致时修复本次输出的 `a+rX` 权限并复核,失败则阻止 candidate。 - `scripts/psp/validate_psp.py`:校验 PSP 结构、配置语法、路径、脚本、CV18 Sensor 枚举映射和应用/PSP 覆盖关系;根据 `--expected-chip` 自动选择内置 CV181X/CV184X 头文件,通常无需 `--sensor-enum-header`。 +- `scripts/psp/validate_partition_closure.py`:从 `psp.json` 推导完整分区 offset/size,逐项核对 Boot/Default 镜像值,并按 UBI LEB/PEB 与保留块校验 `mkAiData.py` 参数。 - `scripts/psp/record_packaging_result.py`:用 `contract` 根据 EMX2.1 固定目录和命令生成本地契约;用 `preflight` 在候选提交前和正式组包前检查资源来源、DATA 工具、分区字段、BSP 分区余量与应用脚本;用 `snapshot/record` 证明本轮唯一新增的 `output/<版本>/` 及指定烧录包并记录路径、大小和 hash。工具不执行打包命令。 - `scripts/psp/render_psp_handoff.py`:把同一 commit 的验证证据汇总为供用户确认或 Etunel Hub 使用的交接文件,并可只读核对远端 commit;不发送消息。 diff --git a/skills/firmware-lowlevel-psp-development/references/configuration-boundaries.md b/skills/firmware-lowlevel-psp-development/references/configuration-boundaries.md index 5581f2e..f4723b9 100644 --- a/skills/firmware-lowlevel-psp-development/references/configuration-boundaries.md +++ b/skills/firmware-lowlevel-psp-development/references/configuration-boundaries.md @@ -37,6 +37,8 @@ CV18 系列以 PSP 内唯一的 `sensor_cfg.ini` 为主要 Sensor 配置。文 先从原理图或 GPIO list 确认 Sensor 型号、数量、I2C bus/address、MIPI device/lane、P/N swap、MCLK、reset/power GPIO 等实际字段,再用规格书中的 Sensor 类型锁定枚举标识符。不得只凭相近命名、参考板旧值或枚举数字猜选型。每个 `[sensor]`、`[sensor2]` 等实际启用 section 都要分别给出规格书选定的枚举名称和依据。 +Sensor 拓扑变更的依赖闭包至少包含:唯一 `sensor_cfg.ini`、Media profile/config/param、ISP/PQ 文件引用和 hash、AIPlugin 通道/输入配置、pinmux/reset/power/MCLK/PWM 脚本,以及与通道数有关的资源拷贝映射。只改 `sensor_cfg.ini` 不算闭环;不适用项也要写明原因。 + Skill 已内置本流程确认的两份枚举基线,运行时不得再到 SDK 目录或附件目录中搜索“最新”或相近文件: - [CV181X `sensor_list.h`](sensor-enums/cv181x/sensor_list.h),用于 `_SAMPLE_SNS_TYPE_E`; @@ -55,12 +57,20 @@ Skill 已内置本流程确认的两份枚举基线,运行时不得再到 SDK ISP 和 PQ 文件由用户更新,Skill 不生成、不选择、不修改,也不把参考值自动声明为适配完成。若参考 PSP 已包含这些文件,保留其目录和内容,把状态记录为 `USER_OWNED/PENDING`;用户提供更新后记录精确文件/hash 和 `USER_REPORTED` 确认。ISP/PQ 未更新不阻止完成 PSP 的其他静态检查,但相关设备检查保持未执行或风险项,不能据此声称图像效果已经通过。 +ISP/PQ 清单必须同时证明配置中的实际引用、文件存在性和 SHA-256。缺文件、引用旧文件、目录里有文件但没有任何生效引用,均不能标为已更新;Sensor 型号、数量、lane 或 ISP profile 变化后,旧确认自动失效。 + ## Media 与 AIPlugin +CV181X 的新项目或 Media/AI 变更先读[源码与 Release 取值指南](cv181x-media-ai-configuration.md),并用 `audit_cv181x_media_ai.py` 按精确 Release/profile 枚举全部字段、默认值、代码读取线索、最终 PSP 差异和 VPSS 通道图。`source_hits_hint_only` 不是语义证明;每个拟修改字段仍要从代码、当前 SDK 枚举/范围、产品需求或硬件事实确认。没有参考 PSP 时不得把 Release 配置直接当成完整 PSP,也不能因为字段目录存在就认为系统可运行。 + 至少核对:Media 版本、Sensor 型号/数量/总线、用户负责的 ISP/PQ 状态、AIPlugin 版本、AIPlugin profile 的 SDK、模型平台和运行库目录。版本冲突默认阻塞;只有有实际负责人确认的兼容例外才能继续,并写入最终结果。 未声明的参考配置可以继承,但必须把关键继承项列给用户确认。Media 或 AIPlugin 未提供差异不等于自动禁用,也不等于其真实兼容。 +Media 的运行库或驱动列表为空时默认阻塞。只有存在 `metadata/media-runtime-exception.json`,明确替代来源、目标路径、文件/hash、适用 Media 版本、原因、负责人和确认时间,并在当前应用工程验证替代来源真实存在,才能按例外继续。空列表不能只靠口头说明或“系统里已有”放行。 + +BSP build 变化的依赖闭包至少包含:`psp.json` 全部 BSP/IMG 资源路径、`metadata/bsp.lock.json`、驱动/模块资源映射、Boot/Default、旧 build ID/旧路径残留。分区变化的依赖闭包至少包含:`psp.json` 分区顺序/大小/file、Boot/Default 的 offset/size/mtdparts/内嵌 partition JSON、所有 MTD 索引、DATA 工具映射、`release.py` 的 `mkAiData.py` 参数和最终分区 CSV/摘要。 + ## `psp.json` 和绝对路径 应用打包代码已依赖参考 `psp.json`,因此默认保持文件名、层级和字段。新增 BSP lock、允许覆盖和治理信息使用 `metadata/` sidecar;变更 `psp.json` schema、文件名、目录、启动调用或应用可见 GPIO 语义时,先通过 Etunel 取得技术负责人和应用层确认。 diff --git a/skills/firmware-lowlevel-psp-development/references/cv181x-media-ai-configuration.md b/skills/firmware-lowlevel-psp-development/references/cv181x-media-ai-configuration.md new file mode 100644 index 0000000..480241b --- /dev/null +++ b/skills/firmware-lowlevel-psp-development/references/cv181x-media-ai-configuration.md @@ -0,0 +1,36 @@ +# CV181X Media / AIPlugin 配置取值指南 + +这份指南用于**没有相近板卡 PSP** 的新项目,也用于审计已有 PSP。它只提供代码和 Release 能证明的软件约束;Sensor 电气连接、镜头方向、屏幕、音频器件、内存余量、模型效果和 ISP/PQ 仍须由各自证据确认。 + +## 先找真正生效的文件 + +1. 从项目需求、BSP handoff 和应用工程锁定芯片、架构、libc、系统形态、SDK 版本、屏幕/音频/AI 能力,再指定**精确**的 `EmxMedia-<版本>/` 与 `EmxAiPlugin-<版本>/`。不得按日期挑最新,也不得用源码 profile 冒充 Release。检查两个 `configs.json` / `profile.json` 中的 `ChipSerial`、`ChipSdkVersion`、`EmxSdkVersion`、`Cross`;AI 的 `AiPlugin.json` 还应与目录版本/profile 一致。源码工作区脏时,以所选 Release 的文件/hash 为默认值,源码仅用于解释逻辑;源码与 Release 漂移必须单列。 +2. `PspProductionBase/scripts/product.py` 的 `build_media` 从 Release `configs.json` 读取 SDK、运行库和驱动列表;`psp/mediaConfigs.json` 若存在会覆盖该入口。`build_configs` 先复制应用 `orgConfigs`,再复制 PSP `configs`,因此 PSP 同名文件最后生效。`build_ai_plugin` 先复制 PSP `AiPlugin`,再从 Release 取插件 so;PSP 中有 `AiPlugin/profile.json` 时会替代 Release profile 决定模型与运行库来源。审计必须记录每个最终文件的来源和 hash。 +3. Release `configs/*.json` 是**默认配置样本**,不是 `product.py` 自动复制到 `configs/media/scene/default/` 的保证。若新项目没有这一套运行配置,按应用配置加载路径和选定 Release 建立;不能因为 Release 有文件就认为 PSP 已具备运行配置。AI 的 `configs/*.json` 也要核对最终 `AiPlugin/configs` 或 `configs_ro` 的实际生效路径。 +4. 用 `scripts/psp/audit_cv181x_media_ai.py --media-release-profile <精确目录> --ai-release-profile <精确目录> --media-source --ai-source [--psp <目标PSP>]` 输出全部 JSON 叶子字段、Release 默认值、hash、所选 profile 的编译源码读取位置线索、候选差异、启用算法的模型引用和部分静态通道检查。字段目录覆盖所有字段,但 `source_hits_hint_only` 只是定位线索,**不是**字段语义或安全取值的证明。变更字段必须再读调用链、枚举定义/SDK 限制及板级证据;无证据的字段维持默认并标记待确认,不把数字当通用模板。源码 Git commit/脏路径会一并记录;源码脏改动不能被冒充为 Release 二进制的实现。 + +## 字段如何定值 + +| 文件 / 字段 | 代码里做什么 | 新项目取值依据与风险 | +| --- | --- | --- | +| `configs.json`: `EmxSdkVersion`、`ChipSdkVersion`、`Cross`、`runtime.lib/drivers` | 应用 `build_media` 用它们定位 3dr 运行库/驱动并校验 SDK 版本;空列表的复制函数直接返回 | 与 BSP/应用 SDK、ABI、驱动实际路径逐项核对。空列表不是“系统自带”的证明;需既有例外契约和打包验证。 | +| `system.json`: `buffMode`、`wrapBufferSize`、`ARExtraLine`、`disEnableSbm` | `DefaultSystem::OnCreate` 设置视频 buffer 模式/SBM;`ARExtraLine` 又写入驱动参数 | 保留精确 profile 默认值,只有确认编码链路与 BSP 内存条件后修改;不要把示例 10/256 当硬件定值。 | +| `system.json`: `vb[]` 的 `width/height/format/bitWidth/compress/align/cnt` | `InitVb` 计算每块字节数并初始化公共 VB pool | 按所有 VI/VPSS/VENC/AI/VO 的实际尺寸、像素格式和并发需求算容量,核对 ION/CMA 和 BSP handoff;增大 `cnt` 会增内存压力,过小会丢帧/建池失败。`format` 等枚举整数必须查当前 SDK 头文件。 | +| `system.json`: `vi.ViVpssMode`、`sensorCfg`、`pipeAttr[]`、`defIsp[]`、`frameRate[]`、`rotate[]`、`bind[]` | `InitVi` 设置 VI 模式、读取 Sensor INI、创建 pipe、指定 ISP bin 与帧率;若 `/root/configs/sensor_cfg.ini` 存在,代码优先使用它 | Sensor 枚举、总线/MIPI、最大分辨率和帧率依据原理图/规格/BSP;核对实际生效 Sensor 文件。`defIsp.bin` 只是引用,PQ 由用户提供并校验 hash。 | +| `system.json`: `vpss.ViVpssMode/enMode/aenInput/ViPipe/group[]/bindViVpss/bindVpssVpss` | `InitVpss` 建组、通道、绑定与缓冲池;`VpssPramParse` 使用 `u32MaxW/H`、通道宽高、格式、帧率、镜像、深度、normalize、aspect | 先画出 VI→VPSS→编码/抓拍/AI/显示的组/通道图,再给每个消费者分配存在的组和通道;`attachPool.pool` 必须落在 `vb[]` 范围。`s32DstFrameRate=-1` 在代码中有特殊处理,不是普通负帧率。 | +| `vi.json`: `ViPipe/ViChn/flip/mirror` | `DefaultVi::SetMirrorFlip` 用运行参数与出厂 `flip/mirror` 做异或式比较后调用 VI 接口 | 出厂方向和镜头安装方向需实拍/硬件确认;直接复制他板布尔值可能得到相反画面。 | +| `isp.json`: `pipe/wdrMode/pq.day/night/nightWithLight/fps/dynamicSetIsp` | `DefaultIsp` 读取 pipe/WDR/PQ 并导入 bin;`dynamicSetIsp` 控制动态调参。代码中的 `fps` JSON 读取目前被注释,实际运行帧率另有路径 | WDR 与 Sensor 模式、BSP 和 bin 一致;PQ 文件由用户交付。不要仅改 `fps` 字段就声称运行帧率已变化。 | +| `venc.json`: `attr.ViPipe/VpssGrp/VpssChn/VencChn/bind`、`u32BufSize`、GOP/QP/RC 字段 | `DefaultVenc::VencStart/CreateChn` 从 VPSS 通道取最大尺寸、按当前编码参数建 VENC;`bind` 决定 VPSS→VENC 连接 | 通道必须存在,编码宽高不超过输入;buffer/GOP/QP 等按编码规格、码率和 SDK 限制核算。`modSizeKb`/`defFps` 是否实际读取要逐字段查代码,不能只因配置里有就认定生效。 | +| `snap.json`、`osd.json`、`draw.json`、`vo.json` | 各模块取 VPSS 组/通道;显示还涉及屏幕大小、接口、panel GPIO | 与通道图和真实屏幕/板级 Pinmux 核对。无屏板不得沿用 ScreenOutput/panel GPIO。 | +| `aenc.json`、`adec.json`、`aiVqe.json`、`aoVqe.json` | 音频设备、采样率、帧大小、增益和 VQE 参数 | 有无 codec、I2S/模拟路由、采样率及声道要对照原理图/BSP/产品音频需求;不用音频时应确认模块是否创建而非随意删键。 | +| `vencSmart.json`、`vdec*.json`、`*Server.json`、`qr*.json` | 智能编码、解码、服务缓存与扫码组件各自读取 | 未启用模块保持 Release 默认;要开启时查对应 `Default*.cpp` 和应用入口,不凭文件存在判断功能已开。 | +| AI `profile.json`: `ChipAiVersion/runtime.lib/file` | `build_ai_plugin` 从 3dr AI 目录复制库、模型并生成 `AiPlugin.json` 文件清单 | 按精确模型平台和算法需求验证每个文件存在/hash/架构;运行库顺序需遵守依赖。`AiPlugin.json` 的 `files` 不等于模型清单,可由打包过程生成。 | +| AI `configs/config.json`、各算法 `vpssGrp/vpssChn/modelPath/threshold` | 算法 Create 按名称从 `AiPlugin/configs/<算法>.json` 取通道和模型;`config.json` 是默认启用项,运行参数还可覆盖 | 仅对启用算法核对 VPSS 通道、模型路径/格式/能力和阈值。`PersonDetection` 当前代码把 `modelId` 固定为 `MOBILEDETV2_PEDESTRIAN`,配置里的 `modelId` 不应被当成可自由切换。 | + +## 没有参考 PSP 的门槛 + +允许从**预创建的空独立 PSP Git 仓库**和空 staging 开始,按应用 `product.py` / `release.py` 的实际 schema、BSP handoff 的分区/镜像、硬件拓扑和本指南所述 Release 基线逐项建立。`psp_project_guard.py prepare-empty` 只建立受控暂存,不会生成 `psp.json`、Sensor、GPIO、分区或 ISP/PQ;这些值来源缺失时保持 `partial`/`blocked`,不能候选提交。四视图参考差异报告在此分支标为 `N/A(无参考)`,改用“Release 默认→目标实际生效值”逐字段差异、hash、来源、确认人与交叉校验。即使脚本静态检查通过,也不等于设备成像、AI 模型效果或 Media/AI ABI 已验证。 + +## 本次阅读得到的边界示例 + +以 Media `EmxMedia-20260917-1/cv181xArmMuslDefault` 和 AI `EmxAiPlugin-20260921-1/cv181xArmMusl` 为例,Media 的 `EmxSdkVersion` 为 `EmxSdk-20260917-1`,AI profile 为 `EmxSdk-20260110-1`。这不是已验证的兼容组合。若换成 AI `EmxAiPlugin-20260917-1/cv181xArmMusl`,静态 SDK/通道核对通过,但当前 AI 源码的 `profile.json` 与该旧 Release 的同名文件已有差异;静态通过仍不等于 ABI、模型或设备验证通过。不得自动配对;只能按项目锁定的精确 Release 组合和证据继续。以上只是展示审计方法,不是新项目推荐版本。 diff --git a/skills/firmware-lowlevel-psp-development/references/packaging-and-device-validation.md b/skills/firmware-lowlevel-psp-development/references/packaging-and-device-validation.md index 95d8e7a..e6f3ef5 100644 --- a/skills/firmware-lowlevel-psp-development/references/packaging-and-device-validation.md +++ b/skills/firmware-lowlevel-psp-development/references/packaging-and-device-validation.md @@ -40,12 +40,14 @@ PSP 的日常开发仓库位于 `~/work/project/EMX2.1/AppTeam/Psp/<项目名>/` - `scripts/product.py`、`scripts/release.py` 和 `psp.json` 是普通文件; - `EmxResToProduct` 每个 `src` 都有明确且存在的来源,`dst` 是安全的工程内相对路径且不重复; - `data` 分区有 `file`,`release.py` 使用 DATA 组包时,`mkAiData.py` 和 `data_ubinize.cfg` 都由 PSP 显式映射,不能依赖应用工作区里的旧文件; -- `mkAiData.py` 的容量参数不超过 `data` 分区; +- `mkAiData.py` 的容量参数按 `参数 × UBI LEB` 换算实际请求空间,并加上明确的 PEB 保留量后不超过 `data` 分区;默认基线是 LEB=126976、PEB=131072、至少保留 8 PEB,项目工具参数不允许沿用无来源的固定常数; - PSP 的 kernel/rootfs 分区名称、文件和容量与 `metadata/bsp.lock.json` 中已经通过的 BSP 分区证据一致,余量仍不少于 512 KiB; - PSP 是预期 commit 且工作区干净。 工具能证明的是静态关系。还要只读检查 `product.py` 使用的 Media、AIPlugin、运行库和驱动清单;空目录、空列表以及“脚本打印错误但退出码仍为 0”的路径都要在执行前说明。该问题属于应用打包框架时,未经明确授权不得修改应用脚本。 +分区发生变化时,先运行 `validate_partition_closure.py --psp-json --boot-default --release-py <应用/scripts/release.py>`。报告必须证明每个分区的推导 offset/size 与 Boot/Default 一致,以及 DATA 请求空间加保留块后仍可容纳。`release.py` 属于应用工程;工具只读校验,修改仍需要应用侧授权,不能因为 PSP 分区变化就越界改脚本。 + 完成上述检查后自动继续: 1. 从项目已有规则读取“哪个文件是实际烧录包”的精确相对路径。无法唯一发现时视为异常,先请用户指定;`output/<版本>/` 可能包含多个 `.bin`,不能按扩展名或修改时间自行挑选。 diff --git a/skills/firmware-lowlevel-psp-development/references/project-and-git.md b/skills/firmware-lowlevel-psp-development/references/project-and-git.md index e1d4f69..763f0c9 100644 --- a/skills/firmware-lowlevel-psp-development/references/project-and-git.md +++ b/skills/firmware-lowlevel-psp-development/references/project-and-git.md @@ -7,11 +7,15 @@ - 多个应用只有在硬件、GPIO、内存/分区、BSP 固件组合、Sensor/Media/ISP/PQ 和 AIPlugin 平台配置完全一致时才共用同一 PSP。 - 保留预创建仓库的默认分支。经用户确认需要任务分支时,Etunel 任务建议使用 `candidate/`,普通任务使用 `candidate/<简短任务名>`;分支名不是版本。 - 应用仓库通过 Git submodule 把 PSP 固定在 `psp/`,由 submodule gitlink 锁定精确 commit;应用侧 `psp.lock.json` 只提供可读交接和一致性检查。 +- 固定工作区中的独立 PSP 仓库是唯一生产者和发布来源;应用工程中的 `psp/` 只是消费者。仓库链接、push 结果和交付 commit 只能从独立 PSP 仓库的远端与 HEAD 取得,禁止从应用父仓库或 `PspProductionBase` 根仓库生成“PSP 仓库链接”。 ## 受控复制 +没有参考 PSP 的首次填充,不执行本节的参考复制步骤 2–5;先用 `psp_project_guard.py inspect` 确认预创建空独立仓库,再运行 `prepare-empty --target <固定根下项目> --manifest <仓库外新路径>` 建立空 staging。按应用 `product.py`/`release.py` 接口、BSP handoff、硬件事实、精确 Media/AI Release 和配置取值指南建立内容。对 staging 的每个新路径使用 `verify --allow-path <精确相对路径>`,通过全部原有静态/组包准备门禁后才 `finalize` 和 `audit-final`;`source_inventory` 为空,不存在参考提交或参考残留。`prepare-empty` 不会替用户写缺乏依据的 `psp.json`、Sensor、分区、GPIO、ISP/PQ;材料不足就停止,不以空 staging 的审计通过代替 PSP 完整性。 + 1. 展开当前用户的 `~`,先运行 `psp_project_guard.py inspect --target ~/work/project/EMX2.1/AppTeam/Psp/<项目名>`,确认目标 PSP 是固定根目录的普通直接子目录、其 `.git` 是普通目录,并记录分支、HEAD、远端指纹和配置指纹。首次填充要求结果是 `precreated-empty-repository`;结果是 `existing-project` 时停止本节,转入“现有项目更新”。 2. 检查参考 PSP 是普通目录、Git worktree 还是 submodule,记录精确来源。参考 worktree 脏时默认使用可追踪 HEAD;复制未提交内容必须让用户确认并标记来源不能由单一 commit 表示。 + 无论是否复制脏内容,都先用 `analyze_psp_delta.py` 分离参考 HEAD 与 worktree;差异报告不能只做一次普通目录对比。 3. 使用 `psp_project_guard.py prepare --target ~/work/project/EMX2.1/AppTeam/Psp/<项目名>` 复制到同一根目录下的唯一 staging,拒绝软链接、疑似凭据、缺失目标、目标根目录逃逸及非空目标。 4. 在 staging 中修改;不接触参考 PSP 和目标 PSP。 5. 使用 `verify` 按精确路径白名单检查差异和旧标识残留。 @@ -33,6 +37,7 @@ - 用户提供共享仓库地址或明确授权仓库创建流程。PSP skill 不自行选择 Git 服务、组织或远端。 - 本地路径不能作为正式 Etunel 应用交付地址。commit 可从团队地址获取并验证后,才能声称应用可消费。 - Git push、Etunel candidate 交接、accepted/revoked tag 创建与 push、设备烧录和产品发布分别确认;一次确认不自动覆盖后续动作。 +- push 或返回仓库链接前重新执行 `psp_project_guard.py inspect`,确认当前目录仍是固定 PSP 根目录的直接子仓库、HEAD 干净且远端属于该独立 PSP。当前仓库根是应用工程或 `PspProductionBase` 时停止,切换到独立 PSP 生产仓库后再继续。 - 不改写已经提供给打包、设备或应用验证的 commit。修正产生新 commit,并记录其替代的旧 candidate。 ## 状态和 tag diff --git a/skills/firmware-lowlevel-psp-development/references/schemas.md b/skills/firmware-lowlevel-psp-development/references/schemas.md index 239a7ed..ca50037 100644 --- a/skills/firmware-lowlevel-psp-development/references/schemas.md +++ b/skills/firmware-lowlevel-psp-development/references/schemas.md @@ -2,6 +2,41 @@ 所有 SHA 使用 64 位小写 SHA-256;Git commit 使用仓库返回的完整 SHA。状态必须引用实际证据,不允许人工填写 `passed` 掩盖未执行步骤。 +## 外部 `hardware-topology.json` + +硬件事实文件保存在 PSP 仓库外,避免把附件绝对路径和调查状态写入正式配置。最小结构如下: + +```json +{ + "schema_version": 1, + "project": "LY-SM-YT42-AI", + "chip": "CV1841CP", + "system_mode": "dualos", + "storage": {"type": "spinand", "capacity_bytes": 134217728}, + "sources": [{"kind": "schematic", "path": "/absolute/file.pdf", "sha256": ""}], + "conflicts": [], + "sensors": [{ + "section": "sensor", + "model": "MIS40C1", + "enum_name": "<规格对应枚举名>", + "i2c_bus": 2, + "i2c_address": "0x30", + "mipi_device": 1, + "lane_count": 1, + "evidence_sources": [0], + "config_expectations": {"bus_id": 2, "sns_i2c_addr": "0x30", "devno": 1} + }] +} +``` + +`config_expectations` 使用目标 `sensor_cfg.ini` 的真实字段名,不由工具猜测别名。证据冲突未解决时 `conflicts` 保留明细且验证失败。Flash 型号未提供时可以省略 `storage.model`,但容量和介质类型不能省略。 + +## 外部三方差异报告 + +`analyze_psp_delta.py` 输出目标 HEAD/worktree 与参考 HEAD/worktree 四视图,记录两个完整 commit、双方 dirty 状态、参考 dirty paths,以及 `matches_reference_head`、`matches_reference_dirty_worktree`、`target_unchanged`、`target_specific`、`unclassified_conflict` 分类。报告保存在仓库外;引用参考脏内容时必须明确它不能由参考 commit 单独复现。 + +无参考 PSP 时四视图报告为 `N/A`,不伪造参考。`psp_project_guard.py prepare-empty` 的 manifest 使用 `reference: null`、`reference_baseline: "empty"`、空 `source_inventory`;外部 CV181X Media/AI 审计报告保存精确 Release 路径、`configs.json` / `profile.json` hash、全部字段默认值及实际差异。`render_psp_handoff.py` 传 `--no-reference`,交接中 `reference_psp.source` 和 `commit` 均为 `null`;不得用 `"none"` 之类假路径。 + ## `metadata/bsp.lock.json` PSP 仓库内保存 BSP handoff 的稳定摘要,不保存自身 commit: @@ -17,6 +52,13 @@ PSP 仓库内保存 BSP handoff 的稳定摘要,不保存自身 commit: "chip": "CV1841CP", "build_id": "20260901_1", "imgs_version_path": "/epbuild/EMX2.0/EmxRes/.../imgs/20260901_1", + "output_permissions": { + "policy": "a+rX", + "evidence_path": "/absolute/BSP/doc/bsp_adaptation/validation/output_permissions_20260901_1.json", + "output_root": "/epbuild/EMX2.0/EmxRes", + "imgs_version_path": "/epbuild/EMX2.0/EmxRes/.../imgs/20260901_1", + "passed": true + }, "image_partition_margin": { "partition_table": "/absolute/BSP/configs/.../partition_spinand.xml", "required_margin_bytes": 524288, @@ -74,6 +116,30 @@ PSP 仓库内保存 BSP handoff 的稳定摘要,不保存自身 commit: 路径必须是仓库内精确相对路径,不允许绝对路径、`..` 或通配符。 +## `metadata/media-runtime-exception.json` + +仅在 Media profile 的运行库或驱动列表为空、但应用工程有已验证替代来源时使用: + +```json +{ + "schema_version": 1, + "media_version": "<精确版本>", + "reason": "<为何 PSP 列表为空>", + "alternatives": [ + { + "kind": "userspace_or_driver", + "source": "Public/EmxRes/<精确相对路径>", + "destination": "<组包目标相对路径>", + "sha256": "<实际文件hash>" + } + ], + "confirmed_by": "<负责人>", + "confirmed_at": "" +} +``` + +正式 preflight 必须在当前应用工程中重新验证每个 source/hash 和目标映射。文件缺失、hash 漂移、Media 版本变化或 destination 未被实际打包时例外失效。 + ## 应用侧 `psp.lock.json` 由应用层创建,不放进 PSP 仓库: @@ -122,6 +188,8 @@ submodule gitlink 是实际锁定依据;lock 用于人和工具核对。两者 `record_packaging_result.py preflight` 生成不可覆盖的 `schema_version: 1` 检查结果。候选提交前可以用 `--psp-source ` 检查待导入内容;正式组包前必须使用应用工程内 `psp`、完整 commit 和 `--formal`。结果至少记录 PSP/脚本路径、资源来源映射、分区、BSP 分区比对、错误和警告。 +`validate_partition_closure.py` 输出外部 `schema_version: 1` 分区检查报告,包含从 `psp.json` 推导的每个分区 offset/size、Boot/Default 逐字段比对,以及 `mkAiData.py` 参数、LEB 请求字节、PEB 保留字节和 DATA 容量。该报告通过后,才允许把分区相关修改标为检查通过。 + `record_packaging_result.py snapshot` 生成不可覆盖的 `schema_version: 3` 快照,至少锁定契约路径/hash/id、打包前 PSP Git 状态、应用工程 Git 状态和既有 `output` 直接版本目录及目录树 hash。 `record_packaging_result.py record` 生成 `schema_version: 2` 的 `packaging-result.json`,至少包含 `passed`、契约路径/hash/id、芯片、PSP 仓库/commit/dirty、BSP build ID、应用工程路径/commit/dirty、执行目录、两阶段精确命令/退出码/日志证据、clean rebuild 确认状态、本轮新增版本目录绝对路径和完整文件清单,以及用户/项目指定烧录包的绝对路径、大小、mtime 和 SHA-256。日志证据只保存外部日志路径/hash/大小以及失败或警告的行号和行 hash,不复制日志正文。 diff --git a/skills/firmware-lowlevel-psp-development/references/workflow.md b/skills/firmware-lowlevel-psp-development/references/workflow.md index 2576889..672e397 100644 --- a/skills/firmware-lowlevel-psp-development/references/workflow.md +++ b/skills/firmware-lowlevel-psp-development/references/workflow.md @@ -5,7 +5,7 @@ 开始前从项目和安全只读检查中确认: - 目标应用项目路径、新项目名称,以及固定根目录 `~/work/project/EMX2.1/AppTeam/Psp/` 下由用户或团队预创建的同名 PSP 项目目录; -- 用户指定的相近板卡 PSP 路径或精确 commit; +- 用户指定的相近板卡 PSP 路径或精确 commit;没有时记录“无参考”,不索取不存在的项目; - 芯片、硬件 revision、单/双系统、存储介质和目标 BSP build type; - BSP `doc/bsp_adaptation/handoff/psp_handoff.json`; - 相对参考 PSP 的已知 GPIO、内存/分区、Media、Sensor 和 AIPlugin 差异,以及由用户更新的 ISP/PQ 状态; @@ -14,12 +14,16 @@ - 禁止修改的目录及可用的 Git/Etunel 上下文。 - 用户指定的 EMX2.1 应用工程绝对路径;工程已有固定 `scripts/product.py`、`scripts/release.py` 和 `output/` 约定时,Skill 自行读取,不再向用户追问命令。 -缺失可从仓库发现的事实时先调查,不要求用户重复。参考 PSP、目标差异、负责人决定或跨平台风险属于用户决定,不能推断。同一缺项连续出现时只更新缺项列表,不重复完整问卷,也不在没有新输入时反复生成同一结论。 +先把原理图、规格书、GPIO 表和已经确认的 BSP 事实收敛为仓库外 `hardware-topology.json`,运行 `validate_hardware_topology.py`。它至少记录芯片、系统形态、存储类型/容量、每路 Sensor 型号、I2C bus/address、MIPI device、lane 数、枚举名、证据源路径/hash,以及对 `sensor_cfg.ini` 的显式字段期望。器件型号未给出时只能按用户允许的范围记录容量,不猜型号。目标旧配置和参考 PSP 属于待核对的软件现状,不能作为覆盖硬件资料的证据;任何未解决冲突都阻止适配。 + +缺失可从仓库发现的事实时先调查,不要求用户重复。是否有参考 PSP、负责人决定或跨平台风险属于用户决定,不能推断;用户明确没有参考时启用下述“无参考”分支。同一缺项连续出现时只更新缺项列表,不重复完整问卷,也不在没有新输入时反复生成同一结论。 把 `~` 展开为运行 skill 的当前用户目录,并记录规范化后的绝对 PSP 根路径。目标项目必须是该根目录的普通直接子目录和独立 Git 仓库;保留已有分支、远端和仓库配置。目标目录不存在时停止并请用户或团队创建,禁止由 skill 建目录、运行 `git init` 或替用户配置远端。预创建目录已有业务内容时视为现有 PSP,只走基于 Git 差异的更新流程,禁止再次从参考 PSP 整体导入。 ## 2. 参考兼容性 +无参考 PSP 时本节兼容性和四视图报告标记为 `N/A(无参考)`,不得伪造一个参考。先锁定精确 Media/AI Release 版本/profile、硬件事实、BSP handoff 和应用打包接口,依[CV181X Media/AI 取值指南](cv181x-media-ai-configuration.md)生成全字段默认目录和生效配置差异。即使 Release 与源码完全相同,Media/AI 的 SDK、ABI、通道、模型、驱动与最终 PSP 值仍须单独验证。 + 比较参考和目标的芯片系列、板卡形态、单/双系统、存储介质、Sensor 数量/型号、GPIO/Pinmux 结构、媒体能力和 AIPlugin 能力。列出: - 可直接继承; @@ -29,13 +33,15 @@ 明显不兼容时默认停止。用户明确接受跨平台参考风险后仍须把每个未验证项留在结果中。 +参考或目标是 Git 仓库时运行 `analyze_psp_delta.py`,固定四个视图:目标 HEAD、目标 worktree、参考 HEAD、参考 worktree。后续差异必须归入“匹配参考稳定 HEAD”“匹配参考未提交 worktree”“目标特有”“未分类冲突”;不能把参考仓库的脏文件冒充其 commit 内容。未分类项必须逐项解决,工具报告和两个完整 commit 一并进入外部证据。 + ## 3. 受控适配 -对于尚无业务内容的预创建项目仓库,按[项目与 Git 规则](project-and-git.md)在同一固定根目录的唯一 staging 中复制参考 PSP,排除参考仓库身份、构建输出、缓存、日志、临时文件、本机生成物和旧的应用侧 `psp.lock.json`。保持参考 PSP 的有效目录、配置、脚本、小型资源和精简文档。最终仅把已验证业务内容导入目标,目标自身 `.git` 不进入 staging、也不被替换。 +对于尚无业务内容的预创建项目仓库,有参考时按[项目与 Git 规则](project-and-git.md)在同一固定根目录的唯一 staging 中复制参考 PSP,排除参考仓库身份、构建输出、缓存、日志、临时文件、本机生成物和旧的应用侧 `psp.lock.json`。无参考时使用 `psp_project_guard.py prepare-empty` 建立空 staging,依据应用接口、BSP handoff、硬件拓扑和精确 Release 默认值逐项建立业务文件;工具不会生成 `psp.json`、分区、Sensor、GPIO 或 PQ,缺少这些依据时只能保持 partial/blocked。最终仅把已验证业务内容导入目标,目标自身 `.git` 不进入 staging、也不被替换。 对于已有业务内容的目标项目,先证明它是固定根目录下的预期独立仓库,记录 HEAD、分支、远端和工作区状态;只修改任务明确授权的文件,并以修改前 commit/worktree 为差异基线。不得为了套用参考项目而清空、覆盖或重新初始化目标。 -只修改确认的目标差异。适配后生成两张清单: +只修改确认的目标差异。无参考时“修改”指相对选定 Release 默认/应用接口新建或改变的字段,不是相对另一个板卡。适配后生成两张清单: 1. 修改项:文件、关键字段、旧值、新值、依据和影响; 2. 继承项:GPIO、Sensor、BSP、Media、AIPlugin 等关键配置及确认状态。 @@ -44,21 +50,25 @@ ## 4. 先处理 BSP 阻塞 -使用 `validate_bsp_handoff.py` 校验精确 BSP 输入,并把稳定摘要写入 `metadata/bsp.lock.json`;不要修改 BSP handoff。调用 `validate_psp.py` 时必须显式提供目标 PSP 项目名,以及期望的 BSP 项目、芯片、SDK、build type 和单/双系统形态,任一身份缺失或不一致都阻止 candidate。关键 IMG 缺失或 hash 不一致时同样阻止 candidate。 +使用 `validate_bsp_handoff.py` 校验精确 BSP 输入、生产者的权限证据和当前路径权限,并把稳定摘要写入 `metadata/bsp.lock.json`;不要修改 BSP handoff。调用 `validate_psp.py` 时必须显式提供目标 PSP 项目名,以及期望的 BSP 项目、芯片、SDK、build type 和单/双系统形态;项目身份和权限证据一致但当前输出权限不足时,自动只补齐本次 imgs 路径的 `a+rX` 并复核。任一身份缺失或不一致、权限证据缺失或无权修复都阻止 candidate。关键 IMG 缺失或 hash 不一致时同样阻止 candidate。 如果 BSP handoff 缺失、镜像 hash 不符或 kernel/rootfs 的 512 KiB 余量检查失败,只报告这一组 BSP 问题和精确失败值,先交 BSP 处理。不要同时要求用户补 Sensor、ISP/PQ 或应用打包信息。能从现有 BSP 产物重新生成的证据应先做只读检查,再只申请一次明确的 BSP 操作确认。 按[配置边界与检查](configuration-boundaries.md)核对 GPIO、内存/分区、Sensor、Media、AIPlugin 及应用覆盖关系。CV18 根据规格书确定枚举名称后调用 `validate_psp.py --expected-sensor sensor=ENUM_NAME`,工具根据芯片自动使用内置枚举表:CV181X 验证配置中的枚举名称,CV184X 验证该名称对应的枚举数值。无需搜索或传入 SDK 头文件;只有已确认的枚举版本差异才显式覆盖。ISP/PQ 仅记录用户负责的状态,不由 Skill 修改。`psp.json` 保持参考格式;新增治理数据写入 sidecar metadata,避免未经协调改变应用打包接口。 +任何分区变化必须运行 `validate_partition_closure.py`,从 `psp.json` 推导几何并核对 PSP 内实际生效的 Boot/Default 和应用 `release.py`。任何 Sensor、BSP build、Media/AIPlugin 变化都按配置依赖闭包逐项检查,不能只改最显眼的一个文件后进入 candidate。 + ## 5. 静态验证和打包准备检查 最低静态检查包括: - 受控复制和差异白名单; - PSP 目录、JSON/INI 和 shell 语法; +- 文本编码、软链接和不可访问路径;运行时目录软链接不跟随扫描,但必须由资源映射和打包检查单独证明; - 旧项目残留; - BSP handoff、路径、大小和 hash; - GPIO、Media、Sensor 枚举映射、AIPlugin、SDK 和模型引用的一致性,以及 ISP/PQ 的用户负责状态; +- 硬件拓扑证据、四视图差异分类(无参考时为 N/A 并附 Release→生效值目录)、配置依赖闭包和分区闭包; - 应用与 PSP 的同名配置覆盖清单; - 未决、警告和批准例外。 @@ -98,6 +108,8 @@ candidate -> packaged -> device_verified 按 [Etunel 交接](etunel-handoff.md)把 packaged candidate 提供给 Hub。应用问题必须携带 PSP commit、应用 commit、BSP build、复现步骤、期望/实际、日志和阻塞影响,经 Hub 创建新的底层任务。 +无参考项目时渲染交接用 `render_psp_handoff.py --no-reference`,并附精确 Release/profile/hash 和逐字段审计报告;不可把 Release 目录或其他板卡虚构成参考 PSP。 + 职责内问题形成新 candidate commit;跨 BSP、应用、硬件或公共组件的问题只提交证据和归属建议,由 Hub 路由。不得 force-push 已供打包、设备或应用验证的 commit。 ## 9. 完成状态 diff --git a/skills/firmware-lowlevel-psp-development/scripts/psp/analyze_psp_delta.py b/skills/firmware-lowlevel-psp-development/scripts/psp/analyze_psp_delta.py new file mode 100755 index 0000000..104fabb --- /dev/null +++ b/skills/firmware-lowlevel-psp-development/scripts/psp/analyze_psp_delta.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +"""Classify a target PSP against target HEAD, reference HEAD, and reference worktree.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import subprocess +import sys +import tempfile +from pathlib import Path + + +class DeltaError(RuntimeError): + pass + + +def run_git(repo: Path, *args: str, binary: bool = False): + return subprocess.run( + ["git", "-C", str(repo), *args], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=not binary, + check=False, + ) + + +def repository(path: Path, field: str) -> Path: + path = path.expanduser().resolve(strict=True) + if not path.is_dir() or path.is_symlink(): + raise DeltaError(f"{field} 必须是普通目录: {path}") + top = run_git(path, "rev-parse", "--show-toplevel") + if top.returncode != 0 or Path(top.stdout.strip()).resolve() != path: + raise DeltaError(f"{field} 必须是独立 Git 仓库根目录: {path}") + head = run_git(path, "rev-parse", "--verify", "HEAD") + if head.returncode != 0: + raise DeltaError(f"{field} 缺少可追踪 HEAD") + return path + + +def digest(kind: str, mode: str, data: bytes) -> str: + value = hashlib.sha256() + value.update(kind.encode("ascii") + b"\0" + mode.encode("ascii") + b"\0" + data) + return value.hexdigest() + + +def head_snapshot(repo: Path) -> dict[str, str]: + listing = run_git(repo, "ls-tree", "-r", "-z", "--full-tree", "HEAD", binary=True) + if listing.returncode != 0: + raise DeltaError(listing.stderr.decode(errors="replace")) + result: dict[str, str] = {} + for raw in listing.stdout.split(b"\0"): + if not raw: + continue + metadata, raw_path = raw.split(b"\t", 1) + mode, kind, _object_id = metadata.decode("ascii").split(" ", 2) + path = raw_path.decode("utf-8") + if kind != "blob" or mode == "160000": + raise DeltaError(f"仓库包含不支持的特殊条目: {path}") + content = run_git(repo, "show", f"HEAD:{path}", binary=True) + if content.returncode != 0: + raise DeltaError(f"无法读取 HEAD 文件: {path}") + result[path] = digest("symlink" if mode == "120000" else "file", mode, content.stdout) + return result + + +def worktree_snapshot(repo: Path) -> dict[str, str]: + listing = run_git( + repo, "ls-files", "-z", "--cached", "--others", "--exclude-standard", binary=True + ) + if listing.returncode != 0: + raise DeltaError(listing.stderr.decode(errors="replace")) + result: dict[str, str] = {} + for raw in listing.stdout.split(b"\0"): + if not raw: + continue + relative = raw.decode("utf-8") + path = repo / relative + if path.is_symlink(): + result[relative] = digest("symlink", "120000", os.readlink(path).encode()) + elif path.is_file(): + mode = "100755" if os.access(path, os.X_OK) else "100644" + result[relative] = digest("file", mode, path.read_bytes()) + return result + + +def classify( + target_head: dict[str, str], + target_worktree: dict[str, str], + reference_head: dict[str, str], + reference_worktree: dict[str, str], +) -> dict[str, object]: + categories: dict[str, list[str]] = { + "matches_reference_head": [], + "matches_reference_dirty_worktree": [], + "target_unchanged": [], + "target_specific": [], + "unclassified_conflict": [], + } + rows: list[dict[str, object]] = [] + all_paths = sorted( + set(target_head) | set(target_worktree) | set(reference_head) | set(reference_worktree) + ) + for path in all_paths: + th = target_head.get(path) + tw = target_worktree.get(path) + rh = reference_head.get(path) + rw = reference_worktree.get(path) + if tw == rh and (path in target_worktree or path in reference_head): + category = "matches_reference_head" + elif tw == rw and rw != rh and ( + path in target_head or path in reference_head or path in target_worktree or path in reference_worktree + ): + category = "matches_reference_dirty_worktree" + elif tw == th: + category = "target_unchanged" + elif tw not in {rh, rw}: + category = "target_specific" + else: + category = "unclassified_conflict" + categories[category].append(path) + rows.append( + { + "path": path, + "category": category, + "present": { + "target_head": th is not None, + "target_worktree": tw is not None, + "reference_head": rh is not None, + "reference_worktree": rw is not None, + }, + } + ) + return {"categories": categories, "paths": rows} + + +def analyze(target: Path, reference: Path) -> dict[str, object]: + target = repository(target, "target") + reference = repository(reference, "reference") + target_head = head_snapshot(target) + target_worktree = worktree_snapshot(target) + reference_head = head_snapshot(reference) + reference_worktree = worktree_snapshot(reference) + analysis = classify(target_head, target_worktree, reference_head, reference_worktree) + target_commit = run_git(target, "rev-parse", "HEAD").stdout.strip() + reference_commit = run_git(reference, "rev-parse", "HEAD").stdout.strip() + reference_dirty = reference_head != reference_worktree + return { + "schema_version": 1, + "passed": True, + "target": {"path": str(target), "head": target_commit, "dirty": target_head != target_worktree}, + "reference": { + "path": str(reference), + "head": reference_commit, + "dirty": reference_dirty, + "dirty_paths": sorted( + path + for path in set(reference_head) | set(reference_worktree) + if reference_head.get(path) != reference_worktree.get(path) + ), + }, + **analysis, + } + + +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("--target", type=Path, required=True) + parser.add_argument("--reference", type=Path, required=True) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + try: + result = analyze(args.target, args.reference) + if args.output: + write_json(args.output, result) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + except (DeltaError, OSError, UnicodeError) as exc: + print(json.dumps({"passed": False, "error": str(exc)}, ensure_ascii=False, indent=2)) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/firmware-lowlevel-psp-development/scripts/psp/audit_cv181x_media_ai.py b/skills/firmware-lowlevel-psp-development/scripts/psp/audit_cv181x_media_ai.py new file mode 100644 index 0000000..02b5e10 --- /dev/null +++ b/skills/firmware-lowlevel-psp-development/scripts/psp/audit_cv181x_media_ai.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +"""Read-only CV181X Release/default/PSP configuration inventory and graph audit.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import subprocess +from functools import lru_cache +from pathlib import Path + + +def digest(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def git_provenance(root: Path | None): + if root is None: + return None + def git(*args): + return subprocess.run(["git", "-C", str(root), *args], capture_output=True, + text=True, check=True).stdout.strip() + return {"path": str(root.resolve()), "commit": git("rev-parse", "HEAD"), + "dirty_paths": git("status", "--short", "--untracked-files=all").splitlines()} + + +def load(path: Path): + if not path.is_file() or path.is_symlink(): + raise ValueError(f"需要普通 JSON 文件: {path}") + return json.loads(path.read_text(encoding="utf-8")) + + +def leaves(value, prefix=""): + if isinstance(value, dict): + for key, item in value.items(): + yield from leaves(item, f"{prefix}.{key}" if prefix else key) + elif isinstance(value, list): + for index, item in enumerate(value): + yield from leaves(item, f"{prefix}[{index}]") + else: + yield prefix, value + + +def config_files(profile: Path, root_name: str): + paths = [profile / root_name, *sorted((profile / "configs").glob("*.json"))] + if root_name == "profile.json": + paths.append(profile / "AiPlugin.json") + if not paths[0].is_file() or not (profile / "configs").is_dir(): + raise ValueError(f"Release profile 不完整: {profile}") + return {path.relative_to(profile).as_posix(): path for path in paths if path.is_file()} + + +@lru_cache(maxsize=2) +def source_index(source_root: Path, profile_name: str) -> dict[str, list[str]]: + index: dict[str, list[str]] = {} + pattern = re.compile(r'\[\s*"([^"]+)"\s*\]') + chip_root = source_root / "chip/cvitek/cv181x" + build = source_root / "profile" / profile_name / "build.cmake" + selected = set() + if build.is_file(): + build_text = build.read_text(encoding="utf-8", errors="replace") + for variant in re.findall(r'aux_source_directory\([^\n]*/chip/\$\{ChipManufactory\}/\$\{ChipSerial\}/([^\s)]+)', build_text): + selected.add(profile_name if variant == "${Profile}" else variant) + paths = set(chip_root.glob("*.cpp")) + for variant in selected: + paths.update((chip_root / variant).glob("*.cpp")) + for path in sorted(paths): + for number, line in enumerate(path.read_text(encoding="utf-8", errors="replace").splitlines(), 1): + for key in pattern.findall(line): + index.setdefault(key, []).append(f"{path.relative_to(source_root)}:{number}") + return index + + +def source_hits(source_root: Path | None, profile_name: str, key: str) -> list[str]: + return source_index(source_root, profile_name).get(key, [])[:12] if source_root is not None else [] + + +def profile_inventory(profile: Path, root_name: str, source_profile: Path | None, source_root: Path | None): + result = {} + drift = [] + for relative, path in config_files(profile, root_name).items(): + source_path = source_profile / relative if source_profile and relative != "AiPlugin.json" else None + if source_path is not None and (not source_path.is_file() or digest(path) != digest(source_path)): + drift.append(relative) + rows = [] + for field, default in leaves(load(path)): + key = re.split(r"\.|\[", field)[-1] + rows.append({ + "field": field, + "release_default": default, + "type": type(default).__name__, + "source_hits_hint_only": source_hits(source_root, profile.name, key), + }) + result[relative] = {"sha256": digest(path), "fields": rows} + return result, drift + + +def check_graph(media_system, media_files, ai_files): + errors = [] + if not isinstance(media_system, list) or not media_system: + return ["system.json 必须包含至少一个 System 通道"] + for system_index, system in enumerate(media_system): + groups = {g["vpssGrp"]: g for g in system.get("vpss", {}).get("group", [])} + pools = len(system.get("vb", [])) + for group_id, group in groups.items(): + for attach in group.get("attachPool", []): + if attach.get("chn", -1) not in range(len(group.get("chn", []))): + errors.append(f"System[{system_index}] VPSS {group_id} attachPool.chn 不存在") + if attach.get("pool", -1) not in range(pools): + errors.append(f"System[{system_index}] VPSS {group_id} attachPool.pool 不存在") + for bind in group.get("bind", []): + parent = groups.get(bind.get("group")) + if parent is None or bind.get("chn", -1) not in range(len(parent.get("chn", []))): + errors.append(f"System[{system_index}] VPSS {group_id} bind 来源不存在") + for relative in ("configs/venc.json", "configs/snap.json", "configs/osd.json", "configs/draw.json"): + for index, item in enumerate(media_files.get(relative, [])): + attr = item.get("attr", item) + group_id = attr.get("VpssGrp") + channel = attr.get("VpssChn") + if group_id is not None and (group_id not in groups or channel not in range(len(groups[group_id].get("chn", [])))): + errors.append(f"{relative}[{index}] VPSS {group_id}/{channel} 不存在") + enabled = ai_files.get("configs/config.json", {}) + for relative, items in ai_files.items(): + if relative == "configs/config.json" or not isinstance(items, list): + continue + module = Path(relative).stem + if enabled.get(module) is not True: + continue + for index, item in enumerate(items): + if not isinstance(item, dict) or "vpssGrp" not in item: + continue + group_id, channel = item["vpssGrp"], item.get("vpssChn") + if group_id not in groups or channel not in range(len(groups[group_id].get("chn", []))): + errors.append(f"{relative}[{index}] VPSS {group_id}/{channel} 不存在") + return errors + + +def model_references(ai_files, ai_meta, psp=None): + enabled = ai_files.get("configs/config.json", {}) + declared = set(ai_meta.get("runtime", {}).get("file", [])) + result = [] + for relative, items in ai_files.items(): + if not relative.startswith("configs/") or not isinstance(items, list): + continue + module = Path(relative).stem + if enabled.get(module) is not True: + continue + for index, item in enumerate(items): + if not isinstance(item, dict): + continue + for key, value in item.items(): + if not key.endswith("ModelPath") and key != "modelPath": + continue + if not isinstance(value, str) or not value: + continue + local = (psp / "AiPlugin" / value).is_file() if psp is not None else False + result.append({"module": module, "channel": index, "field": key, + "model": value, "release_runtime_declared": value in declared, + "psp_file_present": local, + "requires_source_or_packaging_evidence": value not in declared and not local}) + return result + + +def audit(media_profile: Path, ai_profile: Path, media_source: Path | None = None, + ai_source: Path | None = None, psp: Path | None = None): + media_profile, ai_profile = media_profile.resolve(), ai_profile.resolve() + if not media_profile.parent.name.startswith("EmxMedia-") or not ai_profile.parent.name.startswith("EmxAiPlugin-"): + raise ValueError("必须指定精确 Release 版本下的 profile,不能指定源码 profile 或 Debug") + media_root = media_profile.parent.name + ai_root = ai_profile.parent.name + media_meta = load(media_profile / "configs.json") + ai_meta = load(ai_profile / "profile.json") + ai_package = load(ai_profile / "AiPlugin.json") + if any(meta.get("ChipSerial", "").lower() != "cv181x" for meta in (media_meta, ai_meta)): + raise ValueError("当前只支持 CV181X Release") + media_source_profile = media_source / "profile" / media_profile.name if media_source else None + ai_source_profile = ai_source / "profile" / ai_profile.name if ai_source else None + media_catalog, media_drift = profile_inventory(media_profile, "configs.json", media_source_profile, media_source) + ai_catalog, ai_drift = profile_inventory(ai_profile, "profile.json", ai_source_profile, ai_source) + errors = [] + for key in ("EmxSdkVersion", "ChipManufactory", "ChipSerial", "ChipSdkVersion", "Cross"): + if media_meta.get(key) != ai_meta.get(key): + errors.append(f"Media/AI {key} 不一致") + if ai_package.get("profile") != ai_profile.name or ai_package.get("version") != ai_root.removeprefix("EmxAiPlugin-"): + errors.append("AiPlugin.json 与所选 Release/profile 不一致") + media_files = {rel: load(path) for rel, path in config_files(media_profile, "configs.json").items()} + ai_files = {rel: load(path) for rel, path in config_files(ai_profile, "profile.json").items()} + errors += check_graph(media_files["configs/system.json"], media_files, ai_files) + candidate = [] + model_refs = model_references(ai_files, ai_meta, psp) + if psp is not None: + psp = psp.resolve() + psp_json = load(psp / "psp.json") + for key, expected in (("EmxMediaVersion", media_root), ("EmxMediaProfile", media_profile.name), + ("EmxAiPluginVersion", ai_root), ("EmxAiPluginProfile", ai_profile.name)): + if psp_json.get(key) != expected: + errors.append(f"psp.json {key} 与所选 Release 不一致") + mapping = (("media", media_files, media_catalog, psp / "configs/media/scene/default"), + ("ai", ai_files, ai_catalog, psp / "AiPlugin/configs")) + effective = {"media": dict(media_files), "ai": dict(ai_files)} + for kind, defaults, catalog, target in mapping: + for relative, baseline in defaults.items(): + if not relative.startswith("configs/"): + continue + path = target / Path(relative).name + if not path.is_file(): + if kind == "ai": + path = psp / "AiPlugin/configs_ro" / Path(relative).name + if not path.is_file(): + candidate.append({"kind": kind, "file": relative, "state": "not_in_psp_check_effective_source"}) + continue + current = load(path) + effective[kind][relative] = current + baseline_fields, current_fields = dict(leaves(baseline)), dict(leaves(current)) + for field in sorted(baseline_fields.keys() | current_fields.keys()): + if field not in baseline_fields or field not in current_fields or baseline_fields[field] != current_fields[field]: + candidate.append({"kind": kind, "file": str(path), "field": field, + "release_default": baseline_fields.get(field), + "candidate": current_fields.get(field), + "state": "changed_or_missing_requires_evidence"}) + errors += check_graph(effective["media"]["configs/system.json"], effective["media"], effective["ai"]) + model_refs = model_references(effective["ai"], ai_meta, psp) + return { + "schema_version": 1, + "scope": "read_only_static_audit_not_hardware_or_runtime_validation", + "media_release": str(media_profile), "ai_release": str(ai_profile), + "media_sha256": digest(media_profile / "configs.json"), + "ai_sha256": digest(ai_profile / "profile.json"), + "source_release_drift": {"media": media_drift, "ai": ai_drift}, + "source_git": {"media": git_provenance(media_source), "ai": git_provenance(ai_source)}, + "field_counts": {"media": sum(len(x["fields"]) for x in media_catalog.values()), + "ai": sum(len(x["fields"]) for x in ai_catalog.values())}, + "catalog": {"media": media_catalog, "ai": ai_catalog}, + "candidate_differences": candidate, + "enabled_ai_model_references": model_refs, + "errors": errors, + "passed_static_cross_checks": not errors, + } + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--media-release-profile", type=Path, required=True) + parser.add_argument("--ai-release-profile", type=Path, required=True) + parser.add_argument("--media-source", type=Path) + parser.add_argument("--ai-source", type=Path) + parser.add_argument("--psp", type=Path) + args = parser.parse_args() + try: + result = audit(args.media_release_profile, args.ai_release_profile, + args.media_source, args.ai_source, args.psp) + except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError, + subprocess.CalledProcessError) as exc: + parser.exit(2, f"audit failed: {exc}\n") + print(json.dumps(result, ensure_ascii=False, indent=2)) + if result["errors"]: + parser.exit(1) + + +if __name__ == "__main__": + main() diff --git a/skills/firmware-lowlevel-psp-development/scripts/psp/psp_project_guard.py b/skills/firmware-lowlevel-psp-development/scripts/psp/psp_project_guard.py index 2d7b069..b667c3a 100755 --- a/skills/firmware-lowlevel-psp-development/scripts/psp/psp_project_guard.py +++ b/skills/firmware-lowlevel-psp-development/scripts/psp/psp_project_guard.py @@ -456,7 +456,7 @@ def inspect_project(target: Path, psp_root: Path = DEFAULT_PSP_ROOT) -> dict[str def prepare( - reference: Path, + reference: Path | None, target: Path, manifest_path: Path, old_tokens: list[str], @@ -464,9 +464,10 @@ def prepare( psp_root: Path = DEFAULT_PSP_ROOT, ) -> dict[str, object]: psp_root = psp_root_directory(psp_root) - reference = ordinary_directory(reference, "reference") + if reference is not None: + reference = ordinary_directory(reference, "reference") target = project_directory(target, psp_root, "target") - if reference == target or reference in target.parents or target in reference.parents: + if reference is not None and (reference == target or reference in target.parents or target in reference.parents): raise GuardError("reference、target 不能互相包含") target_git = git_state(target) target_repository = repository_identity(target, target_git) @@ -482,17 +483,22 @@ def prepare( if manifest_path.exists() or manifest_path.is_symlink(): raise GuardError(f"manifest 已存在,禁止覆盖: {manifest_path}") - links = scan_symlinks(reference) - if links: - raise GuardError("参考 PSP 包含软链接,未复制: " + ", ".join(links)) - credentials = scan_credentials(reference) - if credentials: - raise GuardError("参考 PSP 包含疑似凭据文件,未复制: " + ", ".join(credentials)) - source_git = git_state(reference) + if reference is not None: + links = scan_symlinks(reference) + if links: + raise GuardError("参考 PSP 包含软链接,未复制: " + ", ".join(links)) + credentials = scan_credentials(reference) + if credentials: + raise GuardError("参考 PSP 包含疑似凭据文件,未复制: " + ", ".join(credentials)) + source_git = git_state(reference) if reference is not None else None staging = target.parent / f".{target.name}.psp-staging-{uuid.uuid4().hex[:12]}" if staging.exists() or staging.is_symlink(): raise GuardError(f"随机 staging 已存在: {staging}") - if source_git.get("dirty") is True and not include_dirty: + if reference is None: + staging.mkdir() + baseline_mode = "empty" + warnings = ["无参考 PSP:必须按 BSP handoff、硬件资料、应用 schema 和精确 Release 建立每个配置;不可将 Release 当完整 PSP"] + elif source_git.get("dirty") is True and not include_dirty: copy_committed_head(reference, staging, source_git) baseline_mode = "committed-head" warnings = ["参考 PSP 工作区有未提交内容;staging 使用已提交 HEAD,不包含这些修改"] @@ -512,7 +518,7 @@ def prepare( "schema_version": 2, "created_at": now_utc(), "psp_root": str(psp_root), - "reference": str(reference), + "reference": str(reference) if reference is not None else None, "target": str(target), "staging": str(staging), "target_mode": "precreated-empty-repository", @@ -521,7 +527,7 @@ def prepare( "reference_baseline": baseline_mode, "include_dirty_reference": include_dirty, "old_tokens": sorted(set(token for token in old_tokens if token)), - "excluded_paths": excluded_paths(reference), + "excluded_paths": excluded_paths(reference) if reference is not None else [], "source_inventory": source_before, "staging_inventory_before_edits": staging_inventory, "warnings": warnings, @@ -529,7 +535,7 @@ def prepare( write_json_atomic(manifest_path, payload) return { "passed": True, - "reference": str(reference), + "reference": str(reference) if reference is not None else None, "target": str(target), "staging": str(staging), "psp_root": str(psp_root), @@ -550,12 +556,13 @@ def verify( ) -> dict[str, object]: manifest_path = manifest_path.expanduser().resolve(strict=True) manifest = load_json(manifest_path) - reference = ordinary_directory(Path(str(manifest.get("reference"))), "reference") + reference = (ordinary_directory(Path(str(manifest["reference"])), "reference") + if manifest.get("reference") is not None else None) staging = ordinary_directory(Path(str(manifest.get("staging"))), "staging") source = manifest.get("source_inventory") if not isinstance(source, dict): raise GuardError("manifest 缺少 source_inventory") - if not reference_matches_manifest(reference, manifest): + if reference is not None and not reference_matches_manifest(reference, manifest): raise GuardError("prepare 后参考 PSP 发生变化;必须重新 prepare") links = scan_symlinks(staging) credentials = scan_credentials(staging) @@ -586,7 +593,7 @@ def verify( "schema_version": 1, "checked_at": now_utc(), "manifest": str(manifest_path), - "reference": str(reference), + "reference": str(reference) if reference is not None else None, "staging": str(staging), "target": manifest.get("target"), "allowed_paths": sorted(allowed), @@ -693,7 +700,8 @@ def audit_final( raise GuardError("manifest 缺少 psp_root") psp_root = psp_root_directory(Path(root_value)) target = project_directory(Path(str(manifest.get("target"))), psp_root, "target") - reference = ordinary_directory(Path(str(manifest.get("reference"))), "reference") + reference = (ordinary_directory(Path(str(manifest["reference"])), "reference") + if manifest.get("reference") is not None else None) source = manifest.get("source_inventory") if not isinstance(source, dict): raise GuardError("manifest 缺少 source_inventory") @@ -732,8 +740,9 @@ def audit_final( "schema_version": 1, "checked_at": now_utc(), "manifest": str(manifest_path), - "reference": str(reference), - "reference_drifted_since_prepare": not reference_matches_manifest(reference, manifest), + "reference": str(reference) if reference is not None else None, + "reference_drifted_since_prepare": (not reference_matches_manifest(reference, manifest) + if reference is not None else False), "target": str(target), "allowed_paths": sorted(allowed), "allowed_residue_paths": sorted(residue_allow), @@ -782,6 +791,11 @@ def build_parser() -> argparse.ArgumentParser: prepare_parser.add_argument("--old-token", action="append", default=[]) prepare_parser.add_argument("--include-dirty", action="store_true") + empty_parser = subparsers.add_parser("prepare-empty", help="无参考 PSP:为预创建空仓库准备空 staging") + empty_parser.add_argument("--psp-root", type=Path, default=DEFAULT_PSP_ROOT) + empty_parser.add_argument("--target", type=Path, required=True) + empty_parser.add_argument("--manifest", type=Path, required=True) + verify_parser = subparsers.add_parser("verify", help="审计 staging 的精确差异与残留") verify_parser.add_argument("--manifest", type=Path, required=True) verify_parser.add_argument("--allow-path", action="append", default=[]) @@ -817,6 +831,8 @@ def main() -> int: args.include_dirty, args.psp_root, ) + elif args.command == "prepare-empty": + result = prepare(None, args.target, args.manifest, [], False, args.psp_root) elif args.command == "verify": result = verify(args.manifest, args.allow_path, args.allow_residue, args.report) elif args.command == "finalize": diff --git a/skills/firmware-lowlevel-psp-development/scripts/psp/record_packaging_result.py b/skills/firmware-lowlevel-psp-development/scripts/psp/record_packaging_result.py index 0d31b33..052e2f1 100755 --- a/skills/firmware-lowlevel-psp-development/scripts/psp/record_packaging_result.py +++ b/skills/firmware-lowlevel-psp-development/scripts/psp/record_packaging_result.py @@ -251,6 +251,8 @@ def create_contract(project_root: Path, chip: str, destination: Path) -> dict[st SIZE_RE = re.compile(r"^(\d+)([KMG])$", re.IGNORECASE) SIZE_MULTIPLIERS = {"K": 1024, "M": 1024 * 1024, "G": 1024 * 1024 * 1024} UBI_LEB_SIZE = 126976 +UBI_PEB_SIZE = 131072 +UBI_REQUIRED_RESERVE_PEBS = 8 def parse_size(value: object, field: str) -> int | None: @@ -415,7 +417,13 @@ def packaging_preflight( errors.append( f"DATA 组包资源没有明确来源映射: EmxResToProduct 缺少 {required_dst}" ) - counts = {int(value) for value in re.findall(r"mkAiData\.py\s+(\d+)\b", release_text)} + counts: set[int] = set() + for line in release_text.splitlines(): + if line.lstrip().startswith("#"): + continue + counts.update( + int(value) for value in re.findall(r"mkAiData\.py\s+(\d+)\b", line) + ) if len(counts) != 1: errors.append("无法从 release.py 唯一确定 mkAiData.py 的容量参数") elif isinstance(data_row.get("size_bytes"), int): @@ -423,9 +431,11 @@ def packaging_preflight( if count <= 0: errors.append("mkAiData.py 的容量参数必须大于 0") requested = count * UBI_LEB_SIZE - if requested > int(data_row["size_bytes"]): + reserved = UBI_REQUIRED_RESERVE_PEBS * UBI_PEB_SIZE + if requested + reserved > int(data_row["size_bytes"]): errors.append( - f"mkAiData.py 参数需要约 {requested} 字节,超过 data 分区 {data_row['size_bytes']} 字节" + f"mkAiData.py 参数需要 {requested} 字节并保留 {reserved} 字节," + f"超过 data 分区 {data_row['size_bytes']} 字节" ) bsp_lock_path = source / "metadata" / "bsp.lock.json" diff --git a/skills/firmware-lowlevel-psp-development/scripts/psp/render_psp_handoff.py b/skills/firmware-lowlevel-psp-development/scripts/psp/render_psp_handoff.py index 168e22e..8bf43c8 100755 --- a/skills/firmware-lowlevel-psp-development/scripts/psp/render_psp_handoff.py +++ b/skills/firmware-lowlevel-psp-development/scripts/psp/render_psp_handoff.py @@ -287,7 +287,7 @@ def render( status: str, psp_repo: Path, repository_url: str | None, - reference_psp: str, + reference_psp: str | None, reference_commit: str | None, static_path: Path, bsp_lock_path: Path, @@ -426,7 +426,9 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--status", required=True, choices=STATUSES) parser.add_argument("--psp-repo", type=Path, required=True) parser.add_argument("--repository-url") - parser.add_argument("--reference-psp", required=True) + reference_group = parser.add_mutually_exclusive_group(required=True) + reference_group.add_argument("--reference-psp") + reference_group.add_argument("--no-reference", action="store_true") parser.add_argument("--reference-commit") parser.add_argument("--static-validation", type=Path, required=True) parser.add_argument("--bsp-lock", type=Path, required=True) @@ -453,6 +455,8 @@ def build_parser() -> argparse.ArgumentParser: def main() -> int: args = build_parser().parse_args() try: + if args.no_reference and args.reference_commit: + raise HandoffError("无参考 PSP 时不能填写 --reference-commit") remote_verification = None if args.verify_remote: if not args.repository_url: diff --git a/skills/firmware-lowlevel-psp-development/scripts/psp/test_analyze_psp_delta.py b/skills/firmware-lowlevel-psp-development/scripts/psp/test_analyze_psp_delta.py new file mode 100755 index 0000000..9a7c30f --- /dev/null +++ b/skills/firmware-lowlevel-psp-development/scripts/psp/test_analyze_psp_delta.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 + +import subprocess +import tempfile +from pathlib import Path + +import analyze_psp_delta + + +def git(repo: Path, *args: str) -> str: + result = subprocess.run( + ["git", "-C", str(repo), *args], text=True, capture_output=True, check=True + ) + return result.stdout.strip() + + +def init(repo: Path, files: dict[str, str]) -> None: + repo.mkdir() + git(repo, "init", "-q") + for name, content in files.items(): + path = repo / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + git(repo, "add", ".") + git(repo, "-c", "user.name=PSP Test", "-c", "user.email=x@example.invalid", "commit", "-qm", "base") + + +def main() -> int: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + reference = root / "reference" + target = root / "target" + init(reference, {"stable.ini": "new\n", "dirty.ini": "head\n", "gone.ini": "old\n"}) + init(target, {"stable.ini": "old\n", "dirty.ini": "old\n", "gone.ini": "old\n", "local.ini": "base\n"}) + (reference / "dirty.ini").write_text("dirty-reference\n", encoding="utf-8") + (reference / "gone.ini").unlink() + (target / "stable.ini").write_text("new\n", encoding="utf-8") + (target / "dirty.ini").write_text("dirty-reference\n", encoding="utf-8") + (target / "gone.ini").unlink() + (target / "local.ini").write_text("target-only\n", encoding="utf-8") + + result = analyze_psp_delta.analyze(target, reference) + assert result["passed"] is True + assert result["reference"]["dirty"] is True + categories = result["categories"] + assert "stable.ini" in categories["matches_reference_head"] + assert "dirty.ini" in categories["matches_reference_dirty_worktree"] + assert "gone.ini" in categories["matches_reference_dirty_worktree"] + assert "local.ini" in categories["target_specific"] + + print("PSP three-way delta tests passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/firmware-lowlevel-psp-development/scripts/psp/test_audit_cv181x_media_ai.py b/skills/firmware-lowlevel-psp-development/scripts/psp/test_audit_cv181x_media_ai.py new file mode 100644 index 0000000..b36acb0 --- /dev/null +++ b/skills/firmware-lowlevel-psp-development/scripts/psp/test_audit_cv181x_media_ai.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Self-contained checks for Release selection and effective VPSS graph.""" + +import json +import tempfile +from pathlib import Path + +from audit_cv181x_media_ai import audit + + +def put(path: Path, value): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value), encoding="utf-8") + + +def main(): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + media = root / "EmxMedia-20260917-1" / "cv181xArmMuslDefault" + ai = root / "EmxAiPlugin-20260921-1" / "cv181xArmMusl" + psp = root / "PSP" + identity = {"ChipSerial": "cv181x", "ChipSdkVersion": "4.1.6_arm_musl", + "ChipManufactory": "cvitek", "Cross": "arm-none-linux-musleabihf", + "EmxSdkVersion": "EmxSdk-20260917-1"} + put(media / "configs.json", identity) + put(ai / "profile.json", identity) + put(ai / "AiPlugin.json", {"profile": ai.name, "version": "20260921-1"}) + system = [{"vb": [{"cnt": 2}], "vpss": {"group": [ + {"vpssGrp": 0, "chn": [{"u32Width": 640}], "attachPool": [{"chn": 0, "pool": 0}], "bind": []} + ]}}] + put(media / "configs/system.json", system) + put(media / "configs/venc.json", [{"attr": {"VpssGrp": 0, "VpssChn": 0}}]) + put(ai / "configs/config.json", {"PersonDetection": True}) + put(ai / "configs/PersonDetection.json", [{"vpssGrp": 0, "vpssChn": 0}]) + result = audit(media, ai) + assert result["passed_static_cross_checks"] is True, result["errors"] + assert result["field_counts"]["media"] > 0 + put(psp / "psp.json", {"EmxMediaVersion": media.parent.name, "EmxMediaProfile": media.name, + "EmxAiPluginVersion": ai.parent.name, "EmxAiPluginProfile": ai.name}) + put(psp / "configs/media/scene/default/system.json", system) + put(psp / "configs/media/scene/default/venc.json", [{"attr": {"VpssGrp": 4, "VpssChn": 0}}]) + changed = audit(media, ai, psp=psp) + assert changed["passed_static_cross_checks"] is False + assert any("VPSS 4/0" in error for error in changed["errors"]) + assert any(row.get("field") == "[0].attr.VpssGrp" for row in changed["candidate_differences"]) + put(ai / "profile.json", {**identity, "EmxSdkVersion": "EmxSdk-20260110-1"}) + mismatch = audit(media, ai) + assert any("EmxSdkVersion" in error for error in mismatch["errors"]) + + +if __name__ == "__main__": + main() diff --git a/skills/firmware-lowlevel-psp-development/scripts/psp/test_psp_project_guard.py b/skills/firmware-lowlevel-psp-development/scripts/psp/test_psp_project_guard.py index 89fe994..9621efb 100644 --- a/skills/firmware-lowlevel-psp-development/scripts/psp/test_psp_project_guard.py +++ b/skills/firmware-lowlevel-psp-development/scripts/psp/test_psp_project_guard.py @@ -111,6 +111,22 @@ def main() -> int: assert audited["passed"] is True, audited assert audited["repository_unchanged"] is True + no_reference = create_empty_project(psp_root, "NO-REFERENCE") + empty_manifest = root / "evidence" / "empty-manifest.json" + empty_prepared = psp_project_guard.prepare( + None, no_reference, empty_manifest, [], False, psp_root + ) + empty_staging = Path(empty_prepared["staging"]) + assert list(empty_staging.iterdir()) == [] + assert json.loads(empty_manifest.read_text(encoding="utf-8"))["reference"] is None + (empty_staging / "psp.json").write_text('{"project":"NO-REFERENCE"}', encoding="utf-8") + empty_report = root / "evidence" / "empty-verify.json" + assert psp_project_guard.verify(empty_manifest, ["psp.json"], [], empty_report)["passed"] + psp_project_guard.finalize(empty_manifest, empty_report) + assert psp_project_guard.audit_final(empty_manifest, ["psp.json"], [], None)["passed"] + assert (no_reference / ".git").is_dir() + assert (no_reference / "psp.json").is_file() + missing = psp_root / "MISSING" expect_guard_error( lambda: psp_project_guard.prepare( diff --git a/skills/firmware-lowlevel-psp-development/scripts/psp/test_render_psp_handoff.py b/skills/firmware-lowlevel-psp-development/scripts/psp/test_render_psp_handoff.py index 0b7d3d5..ab6fe74 100755 --- a/skills/firmware-lowlevel-psp-development/scripts/psp/test_render_psp_handoff.py +++ b/skills/firmware-lowlevel-psp-development/scripts/psp/test_render_psp_handoff.py @@ -155,6 +155,14 @@ def main() -> int: assert candidate["passed"] is True, candidate assert candidate["etunel"]["sent"] is False + without_reference = render_psp_handoff.render( + "NEW", "CV1841CP", "candidate", psp, None, None, None, + static, bsp, None, None, None, None, None, None, None, + [], [], [], None, False, + ) + assert without_reference["passed"] is True, without_reference + assert without_reference["reference_psp"] == {"source": None, "commit": None} + packaged = render_psp_handoff.render( "NEW", "CV1841CP", diff --git a/skills/firmware-lowlevel-psp-development/scripts/psp/test_validate_bsp_handoff.py b/skills/firmware-lowlevel-psp-development/scripts/psp/test_validate_bsp_handoff.py index a182d5d..38059cd 100755 --- a/skills/firmware-lowlevel-psp-development/scripts/psp/test_validate_bsp_handoff.py +++ b/skills/firmware-lowlevel-psp-development/scripts/psp/test_validate_bsp_handoff.py @@ -3,8 +3,10 @@ import hashlib import json +import os import tempfile from pathlib import Path +from unittest.mock import patch import validate_bsp_handoff @@ -12,7 +14,8 @@ import validate_bsp_handoff def main() -> int: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) - imgs = root / "imgs" / "20260902_1" + output_root = root / "outputs" + imgs = output_root / "CV1841CP-BOARD_dualos" / "imgs" / "20260902_1" imgs.mkdir(parents=True) artifact = imgs / "boot.bin" artifact.write_bytes(b"boot") @@ -66,6 +69,15 @@ def main() -> int: margin_path = project / "doc" / "bsp_adaptation" / "validation" / "image_partition_margin_20260902_1.json" margin_path.parent.mkdir(parents=True) margin_path.write_text(json.dumps(margin), encoding="utf-8") + permissions = { + "schema_version": 1, + "passed": True, + "policy": "a+rX", + "output_root": str(output_root), + "imgs_version_path": str(imgs), + } + permissions_path = margin_path.parent / "output_permissions_20260902_1.json" + permissions_path.write_text(json.dumps(permissions), encoding="utf-8") manifest = root / "psp_handoff.json" payload = { "schema_version": 1, @@ -86,6 +98,7 @@ def main() -> int: "completion_marker_seen": True, "imgs_version_path": str(imgs), "image_partition_margin_path": str(margin_path), + "output_permissions_path": str(permissions_path), }, "artifacts": [ { @@ -98,12 +111,17 @@ def main() -> int: ], "validation": { "image_partition_margin": margin, + "output_permissions": permissions, "hardware": "pending", "owner_acceptance": "pending", "release": "not-authorized", }, } manifest.write_text(json.dumps(payload), encoding="utf-8") + for directory in (output_root, output_root / "CV1841CP-BOARD_dualos", imgs.parent, imgs): + os.chmod(directory, 0o755) + for image in (artifact, kernel, rootfs): + os.chmod(image, 0o644) result = validate_bsp_handoff.validate_handoff( manifest, "CV1841CP-BOARD_dualos", @@ -116,9 +134,74 @@ def main() -> int: assert result["dependency"]["build_id"] == "20260902_1" assert result["dependency"]["chip"] == "CV1841CP" assert result["dependency"]["image_partition_margin"]["passed"] is True + assert result["dependency"]["output_permissions"]["current_check"]["passed"] is True assert result["dependency"]["artifacts"][0]["sha256"] assert result["warnings"] + os.chmod(output_root, 0o700) + os.chmod(imgs, 0o700) + os.chmod(kernel, 0o600) + other_build = root / "another-build" + other_build.mkdir(mode=0o700) + (imgs / "external-link").symlink_to(other_build, target_is_directory=True) + readonly = validate_bsp_handoff.validate_handoff(manifest) + assert readonly["passed"] is False + assert any("共享读取/进入权限" in error for error in readonly["errors"]) + assert output_root.stat().st_mode & 0o777 == 0o700 + repaired = validate_bsp_handoff.validate_handoff( + manifest, + expected_project="CV1841CP-BOARD_dualos", + repair_output_permissions=True, + ) + assert repaired["passed"] is True, repaired + assert repaired["dependency"]["output_permissions"]["current_check"]["repaired"] is True + assert repaired["dependency"]["output_permissions"]["current_check"]["symlinks_skipped"] == 1 + assert output_root.stat().st_mode & 0o555 == 0o555 + assert other_build.stat().st_mode & 0o777 == 0o700 + assert kernel.stat().st_mode & 0o444 == 0o444 + assert kernel.stat().st_mode & 0o111 == 0 + + no_evidence_payload = json.loads(json.dumps(payload)) + no_evidence_payload["validation"].pop("output_permissions") + no_evidence_manifest = root / "no_permissions_handoff.json" + no_evidence_manifest.write_text(json.dumps(no_evidence_payload), encoding="utf-8") + no_evidence = validate_bsp_handoff.validate_handoff( + no_evidence_manifest, + expected_project="CV1841CP-BOARD_dualos", + repair_output_permissions=True, + ) + assert no_evidence["passed"] is False + assert any("权限证据" in error for error in no_evidence["errors"]) + + os.chmod(kernel, 0o600) + bad_project = validate_bsp_handoff.validate_handoff( + manifest, + expected_project="NOT-THE-PROJECT", + repair_output_permissions=True, + ) + assert bad_project["passed"] is False + assert kernel.stat().st_mode & 0o444 != 0o444 + bad_evidence_payload = json.loads(json.dumps(payload)) + bad_evidence_payload["validation"]["output_permissions"]["output_root"] = str(root / "wrong-root") + bad_evidence_manifest = root / "bad_permissions_handoff.json" + bad_evidence_manifest.write_text(json.dumps(bad_evidence_payload), encoding="utf-8") + bad_evidence = validate_bsp_handoff.validate_handoff( + bad_evidence_manifest, + expected_project="CV1841CP-BOARD_dualos", + repair_output_permissions=True, + ) + assert bad_evidence["passed"] is False + assert kernel.stat().st_mode & 0o444 != 0o444 + with patch.object(validate_bsp_handoff.os, "chmod", side_effect=PermissionError("denied")): + unrepairable = validate_bsp_handoff.validate_handoff( + manifest, + expected_project="CV1841CP-BOARD_dualos", + repair_output_permissions=True, + ) + assert unrepairable["passed"] is False + assert any("无法检查或修复" in error for error in unrepairable["errors"]) + os.chmod(kernel, 0o644) + missing_margin_payload = json.loads(json.dumps(payload)) missing_margin_payload["validation"].pop("image_partition_margin") missing_margin_payload["build"].pop("image_partition_margin_path") diff --git a/skills/firmware-lowlevel-psp-development/scripts/psp/test_validate_hardware_topology.py b/skills/firmware-lowlevel-psp-development/scripts/psp/test_validate_hardware_topology.py new file mode 100755 index 0000000..7b9640e --- /dev/null +++ b/skills/firmware-lowlevel-psp-development/scripts/psp/test_validate_hardware_topology.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 + +import hashlib +import json +import tempfile +from pathlib import Path + +import validate_hardware_topology + + +def main() -> int: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + psp = root / "psp" + sensor_dir = psp / "configs" / "media" + sensor_dir.mkdir(parents=True) + (sensor_dir / "sensor_cfg.ini").write_text( + "[sensor]\nname=0X00003A00\nbus_id=2\ni2c_addr=0x30\ndevno=1\nlane_num=1\n" + "[sensor2]\nname=0X00003A00\nbus_id=2\ni2c_addr=0x31\ndevno=0\nlane_num=1\n", + encoding="utf-8", + ) + evidence = root / "spec.txt" + evidence.write_text("dual MIS40C1", encoding="utf-8") + digest = hashlib.sha256(evidence.read_bytes()).hexdigest() + topology = { + "schema_version": 1, + "project": "LY-SM-YT42-AI", + "chip": "CV1841CP", + "system_mode": "dualos", + "storage": {"type": "spinand", "capacity_bytes": 134217728}, + "sources": [{"kind": "spec", "path": str(evidence), "sha256": digest}], + "conflicts": [], + "sensors": [ + {"section": "sensor", "model": "MIS40C1", "enum_name": "MIS40C1_MIPI_4M_30FPS_12BIT", "i2c_bus": 2, "i2c_address": "0x30", "mipi_device": 1, "lane_count": 1, "evidence_sources": [0], "config_expectations": {"name": "0X00003A00", "bus_id": 2, "i2c_addr": "0x30", "devno": 1, "lane_num": 1}}, + {"section": "sensor2", "model": "MIS40C1", "enum_name": "MIS40C1_MIPI_4M_30FPS_12BIT", "i2c_bus": 2, "i2c_address": "0x31", "mipi_device": 0, "lane_count": 1, "evidence_sources": [0], "config_expectations": {"name": "0X00003A00", "bus_id": 2, "i2c_addr": "0x31", "devno": 0, "lane_num": 1}}, + ], + } + topology_path = root / "hardware-topology.json" + topology_path.write_text(json.dumps(topology), encoding="utf-8") + assert validate_hardware_topology.validate(psp, topology_path)["passed"] is True + + topology["sensors"] = topology["sensors"][:1] + topology_path.write_text(json.dumps(topology), encoding="utf-8") + failed = validate_hardware_topology.validate(psp, topology_path) + assert failed["passed"] is False + assert any("Sensor 数量不一致" in error for error in failed["errors"]) + + print("hardware topology tests passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/firmware-lowlevel-psp-development/scripts/psp/test_validate_partition_closure.py b/skills/firmware-lowlevel-psp-development/scripts/psp/test_validate_partition_closure.py new file mode 100755 index 0000000..67dc996 --- /dev/null +++ b/skills/firmware-lowlevel-psp-development/scripts/psp/test_validate_partition_closure.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 + +import json +import tempfile +from pathlib import Path + +import validate_partition_closure + + +def main() -> int: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + psp_json = root / "psp.json" + table = [ + ("boot", "2560K"), ("env", "512K"), ("yoc", "1024K"), + ("kernel.A", "3584K"), ("rootfs.A", "3072K"), ("firmware.A", "19456K"), + ("kernel.B", "3584K"), ("rootfs.B", "3072K"), ("firmware.B", "19456K"), + ("data", "14336K"), ("configs", "-"), + ] + psp_json.write_text(json.dumps({"partition": {"flashImgSize": "128M", "partitionTable": [{"name": name, "size": size} for name, size in table]}}), encoding="utf-8") + _flash, rows = validate_partition_closure.partition_rows(psp_json) + default = root / "Default" + assignments = "\n".join(f"{row['boot_name']}_PART_OFFSET={row['offset_bytes']:#x}\n{row['boot_name']}_PART_SIZE={row['size_bytes']:#x}" for row in rows) + mtdparts = ",".join(("-" if row["name"] == "configs" else hex(row["size_bytes"])) + f"({row['boot_name']})" for row in rows) + default.write_text(assignments + f"\nMTD=mtdparts=spi0.0:{mtdparts}\n", encoding="utf-8") + release = root / "release.py" + release.write_text('run("./mkAiData.py 104 none")\n', encoding="utf-8") + result = validate_partition_closure.validate(psp_json, default, release) + assert result["passed"] is True, result + + default.write_text(default.read_text(encoding="utf-8").replace("DATA_PART_SIZE=0xe00000", "DATA_PART_SIZE=0x100000"), encoding="utf-8") + stale = validate_partition_closure.validate(psp_json, default, release) + assert stale["passed"] is False + assert any("data_part_size" in error for error in stale["errors"]) + + default.write_text(assignments + f"\nMTD=mtdparts=spi0.0:{mtdparts}\n", encoding="utf-8") + release.write_text('run("./mkAiData.py 280 none")\n', encoding="utf-8") + too_large = validate_partition_closure.validate(psp_json, default, release) + assert too_large["passed"] is False + assert any("超过 DATA" in error for error in too_large["errors"]) + + print("partition closure tests passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/firmware-lowlevel-psp-development/scripts/psp/test_validate_psp.py b/skills/firmware-lowlevel-psp-development/scripts/psp/test_validate_psp.py index bec2022..6e64a68 100755 --- a/skills/firmware-lowlevel-psp-development/scripts/psp/test_validate_psp.py +++ b/skills/firmware-lowlevel-psp-development/scripts/psp/test_validate_psp.py @@ -5,6 +5,7 @@ import hashlib import json import tempfile from pathlib import Path +from unittest.mock import patch import validate_psp @@ -16,7 +17,8 @@ def main() -> int: (psp / "configs").mkdir(parents=True) (psp / "scripts").mkdir() (psp / "AiPlugin").mkdir() - artifact_root = root / "imgs" / "20260902_1" + output_root = root / "outputs" + artifact_root = output_root / "CV1841CP-NEW_dualos" / "imgs" / "20260902_1" artifact_root.mkdir(parents=True) artifact = artifact_root / "boot.bin" artifact.write_bytes(b"boot") @@ -38,6 +40,12 @@ def main() -> int: (psp / "AiPlugin" / "profile.json").write_text( json.dumps({"EmxSdkVersion": "EmxSdk-1"}), encoding="utf-8" ) + runtime_configs = root / "runtime-configs" + runtime_configs.mkdir() + (runtime_configs / "invalid.json").write_text("{", encoding="utf-8") + (runtime_configs / "invalid.ini").write_text("[", encoding="utf-8") + (runtime_configs / "invalid.sh").write_text("if\n", encoding="utf-8") + (psp / "AiPlugin" / "configs").symlink_to(runtime_configs, target_is_directory=True) (psp / "configs" / "same.json").write_text( json.dumps( { @@ -126,6 +134,15 @@ def main() -> int: margin_path = bsp_project / "doc" / "bsp_adaptation" / "validation" / "image_partition_margin_20260902_1.json" margin_path.parent.mkdir(parents=True) margin_path.write_text(json.dumps(margin), encoding="utf-8") + permissions = { + "schema_version": 1, + "passed": True, + "policy": "a+rX", + "output_root": str(output_root), + "imgs_version_path": str(artifact_root), + } + permissions_path = margin_path.parent / "output_permissions_20260902_1.json" + permissions_path.write_text(json.dumps(permissions), encoding="utf-8") handoff.write_text( json.dumps( { @@ -148,6 +165,7 @@ def main() -> int: "completion_marker_seen": True, "imgs_version_path": str(artifact_root), "image_partition_margin_path": str(margin_path), + "output_permissions_path": str(permissions_path), }, "artifacts": [ { @@ -160,6 +178,7 @@ def main() -> int: ], "validation": { "image_partition_margin": margin, + "output_permissions": permissions, "hardware": "pending", "owner_acceptance": "pending", "release": "not-authorized", @@ -188,9 +207,24 @@ def main() -> int: expected_sensors=expected_sensors, ) assert result["passed"] is True, result + scanned_files = { + path.relative_to(psp).as_posix() + for path in validate_psp.iter_regular_files(psp) + } + assert "AiPlugin/configs/invalid.json" not in scanned_files + assert "AiPlugin/configs/invalid.ini" not in scanned_files + assert "AiPlugin/configs/invalid.sh" not in scanned_files + with patch.object(Path, "exists", side_effect=PermissionError(13, "denied")): + exists, access_error = validate_psp.probe_path_exists(Path("/target/runtime")) + assert exists is False + assert access_error is not None and "denied" in access_error assert lock.is_file() lock_payload = json.loads(lock.read_text(encoding="utf-8")) assert lock_payload["image_partition_margin"]["required_margin_bytes"] == 512 * 1024 + assert lock_payload["output_permissions"]["passed"] is True + assert lock_payload["output_permissions"]["evidence_path"] == str(permissions_path) + assert output_root.stat().st_mode & 0o555 == 0o555 + assert artifact.stat().st_mode & 0o444 == 0o444 assert result["checks"]["configuration_overrides"][0]["allowed"] is True assert any("个人或临时目录" in warning for warning in result["warnings"]) assert any( diff --git a/skills/firmware-lowlevel-psp-development/scripts/psp/validate_bsp_handoff.py b/skills/firmware-lowlevel-psp-development/scripts/psp/validate_bsp_handoff.py index 0148a19..ec49c05 100755 --- a/skills/firmware-lowlevel-psp-development/scripts/psp/validate_bsp_handoff.py +++ b/skills/firmware-lowlevel-psp-development/scripts/psp/validate_bsp_handoff.py @@ -1,17 +1,22 @@ #!/usr/bin/env python3 -"""Validate a BSP-to-PSP handoff and its immutable artifact hashes.""" +"""Validate a BSP-to-PSP handoff, output permissions, and artifact hashes.""" from __future__ import annotations import argparse import hashlib import json +import os +import stat import xml.etree.ElementTree as ET from pathlib import Path REQUIRED_IMAGE_MARGIN_BYTES = 512 * 1024 REQUIRED_PARTITION_IMAGES = {"kernel.bin", "rootfs.bin"} +DIRECTORY_ACCESS_BITS = 0o555 +FILE_READ_BITS = 0o444 +FILE_EXECUTE_BITS = 0o111 def sha256_file(path: Path) -> str: @@ -30,6 +35,159 @@ def within(path: Path, root: Path) -> bool: return False +def validate_output_permissions( + payload: object, + build: dict[str, object], + producer: dict[str, object], + imgs_path: Path | None, + errors: list[str], + repair: bool, +) -> dict[str, object] | None: + """Check BSP evidence and live modes; optionally repair only its exact output.""" + if not isinstance(payload, dict) or payload.get("passed") is not True: + errors.append("BSP handoff 缺少通过的输出路径权限证据") + return None + if payload.get("schema_version") != 1 or payload.get("policy") != "a+rX": + errors.append("输出路径权限证据的 schema_version/policy 不匹配") + return None + + root_value = payload.get("output_root") + version_value = payload.get("imgs_version_path") + project_name = producer.get("project") + build_version = build.get("version") + if ( + not isinstance(root_value, str) + or not Path(root_value).is_absolute() + or not isinstance(version_value, str) + or not Path(version_value).is_absolute() + or not isinstance(project_name, str) + or project_name in ("", ".", "..") + or Path(project_name).name != project_name + or not isinstance(build_version, str) + or build_version in ("", ".", "..") + or Path(build_version).name != build_version + or imgs_path is None + ): + errors.append("输出路径权限证据缺少精确的绝对输出目录、项目或构建版本") + return None + output_root = Path(root_value) + if ( + len(output_root.parts) < 3 + or output_root == Path.home() + or output_root.is_symlink() + ): + errors.append("输出路径权限证据的根路径过宽或是软链接") + return None + expected_imgs_path = output_root / project_name / "imgs" / build_version + if Path(version_value) != expected_imgs_path or imgs_path != expected_imgs_path: + errors.append("输出路径权限证据与 BSP 项目、构建版本或 imgs_version_path 不一致") + return None + + evidence_value = build.get("output_permissions_path") + project_value = producer.get("project_path") + if ( + not isinstance(project_value, str) + or not Path(project_value).is_absolute() + or not isinstance(evidence_value, str) + or not Path(evidence_value).is_absolute() + ): + errors.append("build.output_permissions_path 必须是 BSP 项目内的绝对路径") + return None + evidence_path = Path(evidence_value) + expected_evidence = ( + Path(project_value) + / "doc" / "bsp_adaptation" / "validation" + / f"output_permissions_{build_version}.json" + ) + if evidence_path != expected_evidence or evidence_path.is_symlink(): + errors.append("输出路径权限证据文件路径不是预期 BSP 项目内的普通文件") + return None + try: + external_payload = json.loads(evidence_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + errors.append(f"输出路径权限证据文件不可读或解析失败: {exc}") + return None + if external_payload != payload: + errors.append("handoff 内嵌权限证据与外部证据文件不一致") + return None + + paths: list[tuple[Path, int, str]] = [] + for path in ( + output_root, + output_root / project_name, + output_root / project_name / "imgs", + expected_imgs_path, + ): + if path.is_symlink() or not path.is_dir(): + errors.append(f"构建输出路径节点不是普通目录: {path}") + return None + paths.append((path, DIRECTORY_ACCESS_BITS, "目录")) + + def walk_error(exc: OSError) -> None: + raise exc + + skipped_symlinks = 0 + try: + for parent, directory_names, file_names in os.walk( + expected_imgs_path, followlinks=False, onerror=walk_error + ): + parent_path = Path(parent) + for name in list(directory_names): + path = parent_path / name + if path.is_symlink(): + directory_names.remove(name) + skipped_symlinks += 1 + elif path.is_dir(): + paths.append((path, DIRECTORY_ACCESS_BITS, "目录")) + else: + errors.append(f"构建输出包含非普通目录: {path}") + return None + for name in file_names: + path = parent_path / name + if path.is_symlink(): + skipped_symlinks += 1 + elif path.is_file(): + bits = FILE_READ_BITS + if stat.S_IMODE(path.stat().st_mode) & FILE_EXECUTE_BITS: + bits |= FILE_EXECUTE_BITS + paths.append((path, bits, "文件")) + else: + errors.append(f"构建输出包含非普通文件: {path}") + return None + except OSError as exc: + errors.append(f"无法遍历构建输出目录: {exc}") + return None + + changed = 0 + for path, bits, kind in paths: + try: + if path.is_symlink(): + errors.append(f"权限处理拒绝软链接: {path}") + return None + before = stat.S_IMODE(path.stat().st_mode) + if before & bits != bits: + if not repair: + errors.append(f"构建输出{kind}缺少共享读取/进入权限: {path}") + continue + os.chmod(path, before | bits, follow_symlinks=False) + changed += 1 + after = stat.S_IMODE(path.stat().st_mode) + if after & bits != bits: + errors.append(f"构建输出{kind}权限修复后仍不满足要求: {path}") + except OSError as exc: + errors.append(f"无法检查或修复构建输出权限 {path}: {exc}") + return { + **payload, + "current_check": { + "passed": not errors, + "repaired": changed > 0, + "changed_count": changed, + "checked_paths": len(paths), + "symlinks_skipped": skipped_symlinks, + }, + } + + def validate_image_partition_margin( payload: object, build: dict[str, object], @@ -155,6 +313,7 @@ def validate_handoff( expected_sdk: str | None = None, expected_build_type: str | None = None, expected_system_form: str | None = None, + repair_output_permissions: bool = False, ) -> dict[str, object]: errors: list[str] = [] warnings: list[str] = [] @@ -196,6 +355,8 @@ def validate_handoff( for field, (actual, expected) in expected_values.items(): if expected is not None and actual != expected: errors.append(f"{field} 不匹配:期望 {expected!r},实际 {actual!r}") + if repair_output_permissions and expected_project is None: + errors.append("自动修复输出权限时必须提供预期的 BSP 项目名 --project") if build.get("status") != "build_passed" or build.get("exit_code") != 0: errors.append("BSP 构建状态不是 build_passed/exit_code=0") @@ -210,6 +371,16 @@ def validate_handoff( if not imgs_path.is_dir() or imgs_path.is_symlink(): errors.append(f"imgs_version_path 不存在或不是普通目录: {imgs_path}") + # Never modify BSP output if the handoff identity is already inconsistent. + output_permissions = validate_output_permissions( + validation.get("output_permissions"), + build, + producer, + imgs_path, + errors, + repair=repair_output_permissions and not errors and expected_project == producer.get("project"), + ) + verified = 0 verified_artifacts: list[dict[str, object]] = [] seen_relative_paths: set[str] = set() @@ -297,6 +468,8 @@ def validate_handoff( "verified_artifact_count": verified, "artifacts": verified_artifacts, "image_partition_margin": image_partition_margin, + "output_permissions_path": build.get("output_permissions_path"), + "output_permissions": output_permissions, "hardware_validation": validation.get("hardware"), "owner_acceptance": validation.get("owner_acceptance"), "release": validation.get("release"), @@ -312,6 +485,11 @@ def main() -> int: parser.add_argument("--sdk") parser.add_argument("--build-type") parser.add_argument("--system-form", choices=("single-os", "dual-os")) + parser.add_argument( + "--repair-output-permissions", + action="store_true", + help="仅对已核对项目身份及证据的本次 imgs 路径补齐 a+rX 权限", + ) args = parser.parse_args() try: result = validate_handoff( @@ -321,6 +499,7 @@ def main() -> int: args.sdk, args.build_type, args.system_form, + repair_output_permissions=args.repair_output_permissions, ) except (OSError, json.JSONDecodeError) as exc: result = {"passed": False, "errors": [str(exc)], "warnings": []} diff --git a/skills/firmware-lowlevel-psp-development/scripts/psp/validate_hardware_topology.py b/skills/firmware-lowlevel-psp-development/scripts/psp/validate_hardware_topology.py new file mode 100755 index 0000000..86a3b95 --- /dev/null +++ b/skills/firmware-lowlevel-psp-development/scripts/psp/validate_hardware_topology.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""Validate hardware evidence and its explicit sensor_cfg.ini expectations.""" + +from __future__ import annotations + +import argparse +import configparser +import hashlib +import json +import os +import sys +import tempfile +from pathlib import Path + + +def sha256_file(path: Path) -> str: + value = hashlib.sha256() + with path.open("rb") as handle: + for block in iter(lambda: handle.read(1024 * 1024), b""): + value.update(block) + return value.hexdigest() + + +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 normalized(value: object) -> str: + text = str(value).strip() + try: + return str(int(text, 0)) + except ValueError: + return text.casefold() + + +def validate(psp: Path, topology_path: Path) -> dict[str, object]: + errors: list[str] = [] + psp = psp.expanduser().resolve(strict=True) + topology_path = topology_path.expanduser().resolve(strict=True) + topology = json.loads(topology_path.read_text(encoding="utf-8")) + if not isinstance(topology, dict) or topology.get("schema_version") != 1: + raise ValueError("hardware-topology.json 必须是 schema_version=1 的对象") + + for field in ("project", "chip", "system_mode", "storage"): + if not topology.get(field): + errors.append(f"硬件拓扑缺少字段: {field}") + storage = topology.get("storage") + if not isinstance(storage, dict) or not storage.get("type") or not isinstance(storage.get("capacity_bytes"), int): + errors.append("storage 必须包含 type 和整数 capacity_bytes;型号未知时可省略 model") + + sources = topology.get("sources") + source_results: list[dict[str, object]] = [] + if not isinstance(sources, list) or not sources: + errors.append("硬件拓扑至少需要一个可校验证据源") + sources = [] + for index, item in enumerate(sources): + if not isinstance(item, dict): + errors.append(f"sources[{index}] 必须是对象") + continue + raw_path, expected = item.get("path"), item.get("sha256") + if not isinstance(raw_path, str) or not raw_path: + errors.append(f"sources[{index}].path 不能为空") + continue + source_path = Path(raw_path).expanduser() + if not source_path.is_absolute(): + source_path = (topology_path.parent / source_path).resolve(strict=False) + exists = source_path.is_file() and not source_path.is_symlink() + actual = sha256_file(source_path) if exists else None + if not exists: + errors.append(f"硬件证据源不存在或不是普通文件: {source_path}") + if not isinstance(expected, str) or len(expected) != 64 or expected != actual: + errors.append(f"硬件证据源 hash 不匹配: {source_path}") + source_results.append({"path": str(source_path), "expected_sha256": expected, "actual_sha256": actual}) + + conflicts = topology.get("conflicts") + if not isinstance(conflicts, list): + errors.append("conflicts 必须是数组") + elif conflicts: + errors.append("硬件资料仍有未解决冲突,禁止进入 PSP 适配") + + sensor_files = sorted( + path for path in psp.rglob("sensor_cfg.ini") + if path.is_file() and not path.is_symlink() and ".git" not in path.parts + ) + if len(sensor_files) != 1: + errors.append(f"必须唯一定位 sensor_cfg.ini,实际 {len(sensor_files)} 个") + parser = None + else: + parser = configparser.ConfigParser(interpolation=None, strict=False) + try: + parser.read(sensor_files[0], encoding="utf-8") + except configparser.Error as exc: + errors.append(f"sensor_cfg.ini 解析失败: {exc}") + parser = None + + sensors = topology.get("sensors") + if not isinstance(sensors, list) or not sensors: + errors.append("sensors 必须是非空数组") + sensors = [] + required_sensor_fields = ( + "section", "model", "enum_name", "i2c_bus", "i2c_address", + "mipi_device", "lane_count", "evidence_sources", "config_expectations", + ) + seen_sections: set[str] = set() + checks: list[dict[str, object]] = [] + for index, sensor in enumerate(sensors): + if not isinstance(sensor, dict): + errors.append(f"sensors[{index}] 必须是对象") + continue + missing = [field for field in required_sensor_fields if field not in sensor] + if missing: + errors.append(f"sensors[{index}] 缺少字段: {missing}") + continue + section = str(sensor["section"]) + if section.casefold() in seen_sections: + errors.append(f"Sensor section 重复: {section}") + seen_sections.add(section.casefold()) + evidence_indexes = sensor.get("evidence_sources") + if not isinstance(evidence_indexes, list) or not evidence_indexes or any( + not isinstance(value, int) or value < 0 or value >= len(sources) + for value in evidence_indexes + ): + errors.append(f"{section}.evidence_sources 必须引用有效 sources 索引") + expectations = sensor.get("config_expectations") + row_checks: list[dict[str, object]] = [] + if not isinstance(expectations, dict) or not expectations: + errors.append(f"{section}.config_expectations 必须明确列出配置字段") + elif parser is None or not parser.has_section(section): + errors.append(f"sensor_cfg.ini 缺少硬件拓扑声明的 section: {section}") + else: + for key, expected in sorted(expectations.items()): + actual = parser.get(section, key, fallback=None) + matched = actual is not None and normalized(actual) == normalized(expected) + row_checks.append({"key": key, "expected": expected, "actual": actual, "matched": matched}) + if not matched: + errors.append(f"{section}.{key} 与硬件拓扑不一致:期望 {expected!r},实际 {actual!r}") + checks.append({"section": section, "model": sensor.get("model"), "checks": row_checks}) + + configured_sections = [] if parser is None else [ + name for name in parser.sections() if name.casefold().startswith("sensor") + ] + if len(configured_sections) != len(sensors): + errors.append(f"Sensor 数量不一致:拓扑 {len(sensors)},配置 {len(configured_sections)}") + + return { + "schema_version": 1, + "passed": not errors, + "topology_path": str(topology_path), + "topology_sha256": sha256_file(topology_path), + "psp": str(psp), + "sensor_cfg": str(sensor_files[0]) if len(sensor_files) == 1 else None, + "sources": source_results, + "sensor_checks": checks, + "errors": errors, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--psp", type=Path, required=True) + parser.add_argument("--topology", type=Path, required=True) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + try: + result = validate(args.psp, args.topology) + 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()) diff --git a/skills/firmware-lowlevel-psp-development/scripts/psp/validate_partition_closure.py b/skills/firmware-lowlevel-psp-development/scripts/psp/validate_partition_closure.py new file mode 100755 index 0000000..3ed06d2 --- /dev/null +++ b/skills/firmware-lowlevel-psp-development/scripts/psp/validate_partition_closure.py @@ -0,0 +1,276 @@ +#!/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()) diff --git a/skills/firmware-lowlevel-psp-development/scripts/psp/validate_psp.py b/skills/firmware-lowlevel-psp-development/scripts/psp/validate_psp.py index 00f61b8..eff8e8e 100755 --- a/skills/firmware-lowlevel-psp-development/scripts/psp/validate_psp.py +++ b/skills/firmware-lowlevel-psp-development/scripts/psp/validate_psp.py @@ -87,10 +87,33 @@ def under(path: Path, root: Path) -> bool: return False +def iter_regular_files(root: Path) -> Iterator[Path]: + """Yield repository files without following runtime or Git directory links.""" + for current, dirnames, filenames in os.walk(root, followlinks=False): + current_path = Path(current) + dirnames[:] = sorted( + name + for name in dirnames + if name != ".git" and not (current_path / name).is_symlink() + ) + for name in sorted(filenames): + path = current_path / name + if path.is_symlink() or not path.is_file(): + continue + yield path + + +def probe_path_exists(path: Path) -> tuple[bool, str | None]: + try: + return path.exists(), None + except OSError as exc: + return False, str(exc) + + def collect_json(root: Path, errors: list[str]) -> dict[str, Any]: payloads: dict[str, Any] = {} - for path in sorted(root.rglob("*.json")): - if ".git" in path.parts: + for path in iter_regular_files(root): + if path.suffix.lower() != ".json": continue relative = path.relative_to(root).as_posix() try: @@ -102,8 +125,8 @@ def collect_json(root: Path, errors: list[str]) -> dict[str, Any]: def validate_ini(root: Path, errors: list[str]) -> int: count = 0 - for path in sorted(root.rglob("*.ini")): - if ".git" in path.parts: + for path in iter_regular_files(root): + if path.suffix.lower() != ".ini": continue parser = configparser.ConfigParser(interpolation=None, strict=False) try: @@ -200,11 +223,9 @@ def validate_sensor_baseline( errors.append("非 CV181X/CV184X 项目不应提供 CV18 Sensor 枚举校验参数") return result - candidates = sorted( - path - for path in psp_root.rglob("sensor_cfg.ini") - if ".git" not in path.parts and path.is_file() and not path.is_symlink() - ) + candidates = [ + path for path in iter_regular_files(psp_root) if path.name == "sensor_cfg.ini" + ] if len(candidates) != 1: errors.append( f"CV18 PSP 必须唯一定位一个 sensor_cfg.ini,实际 {len(candidates)} 个" @@ -320,8 +341,8 @@ def validate_sensor_baseline( def validate_shell(root: Path, errors: list[str]) -> int: count = 0 - for path in sorted(root.rglob("*.sh")): - if ".git" in path.parts: + for path in iter_regular_files(root): + if path.suffix.lower() != ".sh": continue result = subprocess.run( ["bash", "-n", str(path)], @@ -346,12 +367,9 @@ def path_risk(path: Path) -> str | None: def text_absolute_paths(root: Path) -> Iterator[tuple[str, str, str]]: - for path in sorted(root.rglob("*")): + for path in iter_regular_files(root): if ( - not path.is_file() - or path.is_symlink() - or ".git" in path.parts - or path.suffix.lower() not in TEXT_PATH_SUFFIXES + path.suffix.lower() not in TEXT_PATH_SUFFIXES or path.stat().st_size > 4 * 1024 * 1024 ): continue @@ -409,7 +427,7 @@ def validate_absolute_paths( continue seen.add(key) path = Path(value).resolve(strict=False) - exists = path.exists() + exists, access_error = probe_path_exists(path) allowed_by = next((item for item in normalized_allowed if under(path, item)), None) risk_root = None if allowed_by is not None else path_risk(path) build_input = source in BUILD_INPUT_JSONS @@ -419,12 +437,19 @@ def validate_absolute_paths( "path": value, "resolved_path": str(path), "exists": exists, + "access_error": access_error, "build_input": build_input, "allowed_by": str(allowed_by) if allowed_by else None, "personal_or_temporary_root": risk_root, } entries.append(entry) - if not exists: + if access_error: + message = ( + f"外部绝对路径无法在当前主机检查 {source}:{location}: " + f"{value}: {access_error}" + ) + (errors if build_input else warnings).append(message) + elif not exists: message = f"外部绝对路径不存在 {source}:{location}: {value}" if build_input: errors.append(f"外部绝对路径不存在 {source}:{location}: {value}") @@ -503,7 +528,7 @@ def scan_overrides( if not psp_dir.is_dir(): errors.append(f"PSP 配置目录不存在: {psp_dir}") continue - for app_path in sorted(path for path in app_dir.rglob("*") if path.is_file()): + for app_path in iter_regular_files(app_dir): relative = app_path.relative_to(app_dir) psp_path = psp_dir / relative if not psp_path.is_file(): @@ -533,6 +558,10 @@ def scan_overrides( def build_bsp_lock(dependency: dict[str, object], warnings: list[str]) -> dict[str, object]: + permissions = dependency.get("output_permissions") + permissions = permissions if isinstance(permissions, dict) else {} + live_check = permissions.get("current_check") + live_check = live_check if isinstance(live_check, dict) else {} return { "schema_version": 1, "handoff_path": dependency.get("manifest_path"), @@ -548,6 +577,13 @@ def build_bsp_lock(dependency: dict[str, object], warnings: list[str]) -> dict[s "imgs_version_path": dependency.get("imgs_version_path"), "artifacts": dependency.get("artifacts", []), "image_partition_margin": dependency.get("image_partition_margin"), + "output_permissions": { + "policy": permissions.get("policy"), + "evidence_path": dependency.get("output_permissions_path"), + "output_root": permissions.get("output_root"), + "imgs_version_path": permissions.get("imgs_version_path"), + "passed": live_check.get("passed") is True, + }, "mixed_build": None, "warnings": warnings, } @@ -736,6 +772,7 @@ def validate_psp( expected_sdk=expected_bsp_sdk, expected_build_type=expected_build_type, expected_system_form=expected_system_form, + repair_output_permissions=True, ) if bsp_result.get("passed") is not True: errors.extend(f"BSP handoff: {item}" for item in bsp_result.get("errors", []))