Compare commits

..
2 Commits
Author SHA1 Message Date
tangfahong ea4ff92835 chore(release): 1.0.1
明确 BSP 的 RES 开发范围和 SDK 构建写入边界;调整 CV184X 强制补丁预检并补齐构建交接测试。
2026-10-09 18:05:07 +08:00
tangfahong 1641fac631 feat: strengthen BSP and PSP validation and handoff 2026-09-24 11:52:22 +08:00
40 changed files with 2771 additions and 93 deletions

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "firmware-lowlevel-development", "name": "firmware-lowlevel-development",
"version": "1.0.0", "version": "1.0.1",
"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", "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": { "author": {
"name": "EAPIL" "name": "EAPIL"
@@ -16,6 +16,14 @@ description: "基于 EmxRes 参考项目创建和适配板级 BSP,处理 CV18
5. 涉及媒体内存或分区时,必须读取 [内存与分区规则](references/memory-and-partition.md)。 5. 涉及媒体内存或分区时,必须读取 [内存与分区规则](references/memory-and-partition.md)。
6. 涉及项目证据、SDK 同步、统一编译、imgs 路径或 PSP 交接时,必须读取 [构建、证据与交接规则](references/build-and-evidence.md)。 6. 涉及项目证据、SDK 同步、统一编译、imgs 路径或 PSP 交接时,必须读取 [构建、证据与交接规则](references/build-and-evidence.md)。
7. 只有从原理图提取或重新映射 Pinmux、需要审核表时,才读取 [Pinmux 证据工作流](references/pinmux.md);CSV 不是修改 BSP 的必需输入。 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 根目录;默认不访问。
## 开发范围与准确表述
BSP 开发改动只写入当前 `EmxRes/<项目名>` 及其 staging 项目;SDK 在调查阶段只读。已确认的原生全量编译会将 RES 配置同步到 SDK,并写入生成文件,这属于构建实际写入,不能承诺 SDK 目录完全不变。不得把 SDK 同步目标列为人工开发修改清单。
形成方案、说明 SDK 差异或报告构建结果前,读取并遵守 [RES、SDK 与表述边界](references/res-sdk-boundary.md)。SDK 编辑、软链接改造、已有改动恢复及共享构建脚本修改均不属于默认 BSP 工作范围。
## 默认工作模式 ## 默认工作模式
@@ -23,13 +31,14 @@ description: "基于 EmxRes 参考项目创建和适配板级 BSP,处理 CV18
- 默认调查范围只包含用户明确给出的附件、当前 Skill 及内置资料、参考项目、目标/staging 项目和 `EmxRes/tools` 中的精确构建入口;禁止扫描整个用户主目录、整个 `EapilBsp` 或整个 SDK。 - 默认调查范围只包含用户明确给出的附件、当前 Skill 及内置资料、参考项目、目标/staging 项目和 `EmxRes/tools` 中的精确构建入口;禁止扫描整个用户主目录、整个 `EapilBsp` 或整个 SDK。
- 未指定的 GPIO、内存、分区、板卡版本和其他配置继承参考项目;禁止以“清理”“顺手升级”扩大差异。 - 未指定的 GPIO、内存、分区、板卡版本和其他配置继承参考项目;禁止以“清理”“顺手升级”扩大差异。
- 默认要求参考与目标的精确芯片、SDK、Flash 和单/双系统形态兼容。目录名只能提供线索,必须读取实际配置。 - 默认要求参考与目标的精确芯片、SDK、Flash 和单/双系统形态兼容。目录名只能提供线索,必须读取实际配置。
- 按 `buildEnvConfig.sh` 的 `EP_CHIP` 识别配置布局:CV184X 沿用 `configs/<board_build_type>/` 与 `configs/EPSYNCSDKConfig.sh`;CV181X 分别核对 `configs/kernel/`、`configs/uboot/` 与两侧 defconfig。不得把两种布局合并或凭项目名猜测。
- 新项目以 `<芯片型号>-<项目型号>` 命名;双系统追加 `_dualos`。额外版本、日期、存储或优化后缀只在用户明确要求时使用。 - 新项目以 `<芯片型号>-<项目型号>` 命名;双系统追加 `_dualos`。额外版本、日期、存储或优化后缀只在用户明确要求时使用。
- 开始持久化复制前,展示一次预检摘要、交付级别和完整 GPIO 实施计划并等待确认。确认覆盖本地复制、已列明差量、项目内文档、所选静态验证模式,以及任务需要构建产物时的标准全量编译;不覆盖设备、上传或发布。 - 开始持久化复制前,展示一次预检摘要、交付级别和完整 GPIO 实施计划并等待确认。确认覆盖本地复制、已列明差量、项目内文档、所选静态验证模式,以及任务需要构建产物时的标准全量编译;不覆盖设备、上传或发布。
- 使用 `scripts/bsp/project_guard.py` 执行确定性的复制、清单、差异白名单和最终原子写入;项目特定配置由 Agent 根据真实结构修改,不使用万能文本替换。 - 使用 `scripts/bsp/project_guard.py` 执行确定性的复制、清单、差异白名单和最终原子写入;项目特定配置由 Agent 根据真实结构修改,不使用万能文本替换。
## 允许的 BSP 差量 ## 允许的 BSP 差量
正常范围包括项目命名及必要引用、CV18 `cvi_board_init.c` 与有效 DTS/DTSI 的 GPIO 释放或重分配、项目级媒体 ION 内存、现有分区源定义、`imgs` 软链接,以及 `doc/bsp_adaptation/` 证据。 正常范围限于当前 RES 项目中的项目命名及必要引用、CV18 `cvi_board_init.c` 与有效 DTS/DTSI 的 GPIO 释放或重分配、项目级媒体 ION 内存、现有分区源定义、`imgs` 软链接,以及 `doc/bsp_adaptation/` 证据。SDK 中的对应路径只作为调查映射和原生构建同步目标。
项目级媒体保留内存和已有分区配置属于本 Skill;DDR 初始化、MMU、通用分配器、存储驱动、Boot 逻辑、跨芯片适配和公共平台能力属于 PSP 或新的已确认工作包。不得修改应用业务。 项目级媒体保留内存和已有分区配置属于本 Skill;DDR 初始化、MMU、通用分配器、存储驱动、Boot 逻辑、跨芯片适配和公共平台能力属于 PSP 或新的已确认工作包。不得修改应用业务。
@@ -42,6 +51,7 @@ description: "基于 EmxRes 参考项目创建和适配板级 BSP,处理 CV18
- 原理图派生 GPIO 必须先写入并通过 `scripts/bsp/validate_gpio_plan.py`,再由用户确认整个实施表;`provisional` 或 `blocked` 行不能进入代码。 - 原理图派生 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`,改用组件构建、覆盖已有输出或修改统一构建脚本不属于自动范围,出现这些需求时停止并请用户确认。 - 任务需要构建产物时,`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 的补丁检查由项目统一编译脚本负责;Skill 不额外运行 `--check-env`、检查补丁完整性或访问 SDK 子仓库。不得主动或通过统一脚本自动执行 `--applypatch --force`。构建预检只读取精确的项目构建入口,发现该脚本可能调用强制打补丁时停止,不运行编译;先由构建脚本所有者改成失败即返回错误,不代改共享脚本、不通过检查通过与否绕开风险。安全入口就绪后运行原生全量编译,记录真实退出码、失败原因和产物结果。
- 编译成功后先对本次精确 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`;未执行同步、编译和板上验证时必须如实标注。 - 本地配置检查通过只能声明项目已创建或 `local validation passed`;未执行同步、编译和板上验证时必须如实标注。
- 完成时按 [记录规范](../firmware-lowlevel-development/references/records.md) 和 [交付契约](../firmware-lowlevel-development/references/deliverable-contract.md) 报告;不执行 `git add`、提交或远端推送。 - 完成时按 [记录规范](../firmware-lowlevel-development/references/records.md) 和 [交付契约](../firmware-lowlevel-development/references/deliverable-contract.md) 报告;不执行 `git add`、提交或远端推送。
@@ -20,6 +20,10 @@ doc/bsp_adaptation/
CSV 只有从原理图提取或重新映射 Pinmux 时才作为派生审核证据写入 `inputs/`;它不是唯一事实源。 CSV 只有从原理图提取或重新映射 Pinmux 时才作为派生审核证据写入 `inputs/`;它不是唯一事实源。
## 开发改动与构建写入
按 [RES、SDK 与表述边界](res-sdk-boundary.md) 将当前 RES 项目的开发改动、SDK 只读核对映射和原生构建实际写入分开记录。构建授权覆盖标准工具已说明的配置同步及生成物写入,不授权额外编辑 SDK、替换 SDK 软链接或备份后恢复 SDK 配置。SDK 中出现差异时,先确定是否影响本次同步;不要把调查结果直接变成 SDK 修复方案。
## 本地 Git ## 本地 Git
`EmxRes` 已有 Git worktree 只用于只读状态、参考项目脏文件发现和差异证据。不得执行 `git init`、`add`、`commit`、`stash`、清理、分支切换、历史改写或 `push`。 `EmxRes` 已有 Git worktree 只用于只读状态、参考项目脏文件发现和差异证据。不得执行 `git init`、`add`、`commit`、`stash`、清理、分支切换、历史改写或 `push`。
@@ -57,7 +61,9 @@ python3 <BSP Skill>/scripts/bsp/bsp_preflight.py build \
--version <YYYYMMDD_N> --version <YYYYMMDD_N>
``` ```
只有 `passed=true` 才能自动执行。执行前把工具返回的精确工作目录、完整命令、项目与 SDK 路径、构建范围、最终 imgs 版本路径、工作区状态、配置同步副作用,以及统一脚本中的远端 `git fetch origin` 写入记录,不再请求第二次确认。不得从 `EmxRes` 的父目录启动,也不得依赖登录 Shell 的偶然当前目录。 `passed=true` 是构建的必要条件。执行前把工具返回的精确工作目录、完整命令、项目与 SDK 路径、构建范围、最终 imgs 版本路径、工作区状态、配置同步副作用,以及统一脚本中的远端 `git fetch origin` 写入记录。不得从 `EmxRes` 的父目录启动,也不得依赖登录 Shell 的偶然当前目录。
CV184X 的补丁检查和失败诊断交给原生构建脚本;Skill 不单独检查补丁完整性,也不进入 SDK 仓库。预检仅查看本轮精确构建脚本是否仍可能自动调用 `--applypatch --force`;若有则停止,交构建脚本所有者改为失败即退出,禁止用静默替换脚本、跳过补丁检查或注入拦截命令来继续。安全脚本就绪后直接执行统一编译,按实际退出码和日志报告结果;CV181X 不套用 CV184X 的补丁逻辑。
下列情况不自动处理:预检失败、统一脚本或路径与固定规则不符、输出版本已存在、参考或目标出现新的重叠改动、需要直接运行同步脚本、需要组件构建、需要改变构建参数、需要修改统一脚本,或者构建失败后需要清理和重试。此时停止并向用户说明异常、影响和可选处理方式。 下列情况不自动处理:预检失败、统一脚本或路径与固定规则不符、输出版本已存在、参考或目标出现新的重叠改动、需要直接运行同步脚本、需要组件构建、需要改变构建参数、需要修改统一脚本,或者构建失败后需要清理和重试。此时停止并向用户说明异常、影响和可选处理方式。
@@ -75,6 +81,14 @@ python3 <BSP Skill>/scripts/bsp/bsp_preflight.py build \
构建前记录新项目内同步脚本权限;统一脚本临时 `chmod 777` 后,构建结束恢复已记录的原权限并写入报告。只恢复新项目内的已知文件,不改 SDK 或其他项目权限。 构建前记录新项目内同步脚本权限;统一脚本临时 `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 +96,7 @@ python3 <BSP Skill>/scripts/bsp/bsp_preflight.py build \
- 命令退出码为零; - 命令退出码为零;
- 日志到达正常完成阶段; - 日志到达正常完成阶段;
- 输出目录存在且含非空固件文件; - 输出目录存在且含非空固件文件;
- 本次精确 imgs 版本路径已经通过 `a+rX` 权限处理与复核;
- 当前项目实际生效的分区表可唯一确定,或已显式指定 `configs/` 内精确文件; - 当前项目实际生效的分区表可唯一确定,或已显式指定 `configs/` 内精确文件;
- 最终 imgs 中唯一 `kernel.bin` 和 `rootfs.bin` 相对各自最小对应分区均保留至少 512 KiB(524288 字节); - 最终 imgs 中唯一 `kernel.bin` 和 `rootfs.bin` 相对各自最小对应分区均保留至少 512 KiB(524288 字节);
- 文件名、大小和 SHA-256 清单写入 `validation/`; - 文件名、大小和 SHA-256 清单写入 `validation/`;
@@ -113,12 +128,13 @@ python3 <BSP Skill>/scripts/bsp/project_guard.py audit-final \
- `validation/artifact_manifest_<构建版本>.sha256`; - `validation/artifact_manifest_<构建版本>.sha256`;
- `validation/image_partition_margin_<构建版本>.json`; - `validation/image_partition_margin_<构建版本>.json`;
- `validation/output_permissions_<构建版本>.json`;
- `handoff/psp_handoff.json`; - `handoff/psp_handoff.json`;
- `status.json`。 - `status.json`。
`psp_handoff.json` 遵循统一入口的 [BSP→PSP 交接契约](../../firmware-lowlevel-development/references/bsp-psp-handoff.md)。`audit-final` 必须在这些文档写入后执行;它使用 prepare 时冻结的参考清单,而不是假定参考项目从未变化。 `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 +146,4 @@ python3 <BSP Skill>/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 交接。
@@ -0,0 +1,32 @@
# CV181X 分目录与 SDK 只读核对
这份说明只适用于项目 `buildEnvConfig.sh` 指向 Cvitek CV181X、SPI NAND,并且板级配置分在 `configs/kernel/`、`configs/uboot/` 的情况。CV184X 仍按原来的单板卡目录和 `EPSYNCSDKConfig.sh` 工作,不能套用本页的路径。
## 先确定改哪里
- `configs/uboot/<board_build_type>_defconfig` 确定板型;同目录的 `cvitek_<board_build_type>_defconfig` 和 `configs/kernel/` 中的对应 defconfig 必须成组存在。
- U-Boot 板级初始化、分区表在当前 RES 项目的 `configs/uboot/`;Kernel defconfig、DTS/DTSI、`memmap.py` 在 `configs/kernel/`。开发只修改这些 RES 源文件;SDK 对应文件用于只读核对和原生构建同步,不直接编辑。范围说明按 [RES、SDK 与表述边界](res-sdk-boundary.md) 执行。
- `CONFIG_ENABLE_FREERTOS=y` 只说明启用了 FreeRTOS 相关配置,不能单凭这一项认定交付形态是双系统。
- 参考项目 `CV1815JA_F36` 只能作为已知实现的例子,不代表所有 CV181X 产品的分区、内存、GPIO 或传感器配置。
## 需要看 SDK 时
用户必须先给出精确 SDK 根目录。执行只读检查:
```bash
python3 <BSP Skill>/scripts/bsp/inspect_cv181x_sdk.py \
--project <EmxRes 中的项目绝对路径> \
--sdk-root <用户明确指定的 SDK 根目录绝对路径>
```
脚本先核对 `EP_CHIP`、SDK 版本、分目录布局和已知同步脚本,再比较项目文件与 SDK 对应文件。报告中的 `sdk_copy_differs` 表示当前内容不一致,不说明哪一份正确;`shared_sdk_target` 表示 SDK 板型文件实际是指向公共默认文件的软链接,后续复制可能影响别的板型;`generated_config_differs` 表示 SDK `build/.config` 与项目板级配置不一致,也可能只是 SDK 上次用于别的项目。对这些提示先核对使用的是哪个 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 同步、构建、设备操作和发布仍遵守原有审批与证据规则。
@@ -0,0 +1,31 @@
# CV184X 板级配置与构建链
适用于 `EP_CHIP=CV184*` 且项目采用 `configs/<board_build_type>/` 的 EmxRes 项目。CV181X 的 `configs/kernel/`、`configs/uboot/` 是另一条流程,不能混用。先看项目 `buildEnvConfig.sh`、板级目录和 `configs/EPSYNCSDKConfig.sh`,再从 `EmxRes/tools/EMX2_BuildSDKTool` 确认实际走的是 `BuildCvitek184XSDKTool.sh`;不要凭项目名猜板型。
## 配置如何生效
开发源文件与配置只修改当前 RES 项目。SDK 只读调查和原生构建同步按 [RES、SDK 与表述边界](res-sdk-boundary.md) 分开说明;本节中的 SDK 路径均为同步或生成目标,不授予人工编辑权限。
专用工具按 `EP_CHIP` 和 `EP_FLASH_TYPE` 选定 `CV_BUILD_TYPE`。`EPSYNCSDKConfig.sh` 将项目单板卡目录用 `rsync -av` 写到 SDK `build/boards/cv184x/<CV_BUILD_TYPE>/`,然后工具执行 SDK `defconfig`、`clean_all` 和分项构建。先核对工具选出的 build type 与项目目录一致;若不一致,停止,不让同步脚本猜目标。同步脚本末尾固定 `exit 0`,单看脚本退出码不够,需比较每个映射文件及实际构建日志。
项目板卡目录至少应结合真实需求检查顶层 `<board>_defconfig`、Kernel/U-Boot defconfig、`dts_arm/<board>.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/<Flash>/` 打包;排查镜像内容时需要把这条独立输入链单独核对。
## 用户给出 SDK 路径后的只读核对
```bash
python3 <BSP Skill>/scripts/bsp/inspect_cv184x_sdk.py \
--project <EmxRes 项目绝对路径> \
--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` 应自行处理补丁检查,失败时返回错误。Skill 不额外运行 `--check-env`、扫描 SDK 子仓库或判断补丁完整性,只记录原生脚本的退出码和必要错误日志。编译前仅核对精确的项目构建入口不会在检查失败时自动执行 `--applypatch --force`;这不是补丁完整性检查。旧脚本仍包含自动强制打补丁时,不能以“失败后报告”代替禁止执行:先停止,让构建脚本所有者将失败分支改为返回非零;Skill 不自行改共享构建脚本或覆盖其行为。
`EmxRes/tools/BuildGitCheck.sh` 会执行远端 `git fetch origin`,但对非 Git 顶层目录的错误不一定可靠传播,因此它显示“检查通过”不能单独证明所有 SDK 子仓库干净或版本一致。构建成功之后仍要核对实际生效分区表、`kernel.bin`/`rootfs.bin` 余量,以及 `fip.bin`、`boot.bin`、`yoc.bin`、驱动和 rootfs 是否按需求出现。单凭配置同步、命令零退出码或某个文件存在,不能声称 BSP 已通过板上验证。
@@ -0,0 +1,33 @@
# RES、SDK 与表述边界
## 开发源文件
BSP 工作包的人工开发改动只写入当前 `EmxRes/<项目名>` 及其 staging 项目。Pinmux、GPIO、DTS/DTSI、defconfig、媒体内存、分区和项目同步脚本均在 RES 中维护;参考项目只读。SDK 文件、SDK 软链接和 `EmxRes/tools` 共享构建脚本不属于该修改范围。
确需修改 SDK 驱动、公共配置或共享构建工具时,先说明已确认的原因、所有权、精确文件和验证方式,作为独立工作包处理。读取 SDK 的授权和当前 BSP 工作包的批准均不自动授予这些编辑权限。
## SDK 只读调查
用户明确给出 SDK 根目录后,只读取与当前板型和问题相关的映射文件、必要 include 链及实现。调查所得的 SDK 路径用于证明映射或解释行为,不放入人工开发修改清单。
项目与 SDK 内容不同可能来自上次构建、已有工作或基线差异。先说明证据及对当前任务的影响,不能仅凭内容不同认定 SDK 需要修复。发现软链接指向公共文件时,报告原生同步会写到哪里;不默认提出替换 SDK 软链接、重建目录、清理、恢复或备份后覆盖的方案。若未确认的已有改动将被本次同步覆盖,停止该构建并请用户决定,继续不依赖该问题的 RES 工作。
## 原生编译的实际写入
已确认工作包中的标准全量编译会执行项目原生配置同步,并写入 SDK 板级目标、生成配置、头文件、DTB、缓存和编译产物。配置同步可能穿过软链接写入公共目标;这些也是 SDK 的真实文件写入,必须按已核对的构建链如实说明。源文件的维护位置仍为 RES。
这项构建授权只覆盖原生工具已说明的动作。不得为了编译顺利或让 SDK 看起来没有变化,额外编辑 SDK、改造软链接、注入同步逻辑、做完后恢复 SDK 配置,或修改共享构建脚本。项目同步脚本的修改也须列入 RES 实施方案,并说明其 SDK 写入效果;不能利用 RES 脚本扩大 SDK 修改范围。
按用户已确认的语义处理“只改 RES”:若指开发改动只在 RES,保留已授权的原生编译,不为标准同步重复请求确认;若用户明确要求 SDK 在整个过程严格只读,而现有原生工具必须写入 SDK,则停止该构建,说明具体冲突并让用户选择继续 RES 静态工作或另行处理构建环境。不能自行改造 SDK 来消除冲突。
## 方案与结果的说法
方案和报告分别列出三项:当前 RES 项目的开发改动、SDK 的只读核对映射、原生构建将执行或已经执行的文件写入。SDK 存在多份同名文件时,使用精确路径,并标明软链接及其真实目标。
- 开发阶段可以说“开发改动只写入 RES;SDK 仅用于只读核对”。
- 构建阶段应说“原生编译会将 RES 配置同步到 SDK,并生成编译文件”。不能笼统承诺“SDK 不会发生任何修改”。
- 不能将“构建同步到 SDK 的六个目标文件”说成“需要人工修改 SDK 的六个文件”。
- 区分计划、已经执行和检查通过;只有读取证据时不能说已经同步,命令或复制成功不能代替生效配置、镜像和设备验证。
- 用户纠正范围时,立即更新受影响的方案及记录;已知的路径、决定和授权继续使用。不要把一次范围澄清变成重复索要整套信息或重新审批已授权的标准构建。
报告原生构建写入时,说明实际命令、配置来源、同步目标和生成物;没有执行的同步、编译及设备验证明确标为未执行。只有确认整个过程没有 SDK 写入时,才可以声称 SDK 目录未变化。
@@ -1,5 +1,7 @@
# BSP 调查范围与验证模式 # BSP 调查范围与验证模式
调查、开发修改与原生构建写入按 [RES、SDK 与表述边界](res-sdk-boundary.md) 分开说明。下列读取范围不授予 SDK 编辑权限。
## 默认范围 ## 默认范围
默认只读取: 默认只读取:
@@ -24,6 +26,12 @@
该模式不会因为禁止人工读取 SDK 而阻塞项目创建,但会降低 DTS 结论的验证等级。 该模式不会因为禁止人工读取 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 审核模式 ## 完整 DTS 审核模式
仅在用户授权读取精确 SDK 基线路径时使用: 仅在用户授权读取精确 SDK 基线路径时使用:
@@ -31,7 +39,7 @@
1. 从实际板级 DTS 开始递归解析有效 DTS/DTSI include 链和条件编译上下文。 1. 从实际板级 DTS 开始递归解析有效 DTS/DTSI include 链和条件编译上下文。
2. 读取范围限于为该 build type 解析出的精确文件和必要的 Pinmux 定义;仍禁止扫描整个 SDK。 2. 读取范围限于为该 build type 解析出的精确文件和必要的 Pinmux 定义;仍禁止扫描整个 SDK。
3. include 无法解析或上下文不唯一时,目标 GPIO 占用检查不得判为通过。 3. include 无法解析或上下文不唯一时,目标 GPIO 占用检查不得判为通过。
4. 本模式只授予只读分析;只有任务明确进入构建产物交付级别时,才按 BSP 构建规则自动执行标准全量编译。其他 SDK 写入仍需用户确认。 4. 本模式只授予只读分析;只有任务明确进入构建产物交付级别时,才按 BSP 构建规则自动执行标准全量编译。原生构建以外的 SDK 编辑不属于本工作包;确有需要时,先明确所有权及精确范围,另行确认。
## 模式记录 ## 模式记录
@@ -28,8 +28,11 @@
1. 将 `~` 解析为执行任务的当前用户主目录,确认参考项目是 `EmxRes` 的直接子目录,目标也是新的直接子目录。 1. 将 `~` 解析为执行任务的当前用户主目录,确认参考项目是 `EmxRes` 的直接子目录,目标也是新的直接子目录。
2. 检查目标正式目录和同名残留目录;正式目录已存在时停止,不覆盖。 2. 检查目标正式目录和同名残留目录;正式目录已存在时停止,不覆盖。
3. 先运行 `scripts/bsp/bsp_preflight.py project`,再只读取报告定位的 `buildEnvConfig.sh`、配置同步脚本和实际配置目录,发现精确芯片、SDK、Flash、架构、单/双系统形态和板卡 build type。 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` 冲突时停止。 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 链。 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 和系统形态。不同芯片或布局转换不是少量适配,要求更接近的参考项目或新的工作包。 6. 默认要求参考与目标使用相同精确芯片、SDK、Flash 和系统形态。不同芯片或布局转换不是少量适配,要求更接近的参考项目或新的工作包。
7. 只读检查 `EmxRes` Git 状态。参考项目自身包含已修改或未跟踪文件时,列出并要求确认复制当前磁盘状态;其他项目的改动只记录、不阻塞。 7. 只读检查 `EmxRes` Git 状态。参考项目自身包含已修改或未跟踪文件时,列出并要求确认复制当前磁盘状态;其他项目的改动只记录、不阻塞。
8. 扫描软链接。允许保留项目内相对链接;约定的 `imgs` 链接按第 6 节处理;其他指向 `EmxRes` 外部的链接要求确认。 8. 扫描软链接。允许保留项目内相对链接;约定的 `imgs` 链接按第 6 节处理;其他指向 `EmxRes` 外部的链接要求确认。
@@ -56,7 +59,7 @@
- 芯片、SDK、Flash、系统形态、布局和板卡版本; - 芯片、SDK、Flash、系统形态、布局和板卡版本;
- 最终项目名; - 最终项目名;
- GPIO、内存、分区差量和继承项; - GPIO、内存、分区差量和继承项;
- 候选修改文件; - 当前 RES/staging 项目内的候选修改文件;SDK 同步映射和构建写入另列,按 [RES、SDK 与表述边界](res-sdk-boundary.md) 说明;
- `imgs` 链接变化; - `imgs` 链接变化;
- 输入证据和 `doc/` 计划; - 输入证据和 `doc/` 计划;
- 交付级别、调查范围、验证模式及结论上限; - 交付级别、调查范围、验证模式及结论上限;
@@ -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` 软链接或“最新目录”,都不能作为下游基线。
@@ -49,6 +49,64 @@ def detects_dual_os(config_dir: Path) -> bool:
return False 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]: def project_preflight(reference_name: str, target_name: str, root: Path) -> dict[str, object]:
errors: list[str] = [] errors: list[str] = []
warnings: 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}") errors.append(f"缺少 buildEnvConfig.sh: {build_env}")
else: else:
assignments = parse_assignments(build_env) assignments = parse_assignments(build_env)
config_dirs = config_directories(reference) layout = resolve_board_layout(reference, assignments.get("EP_CHIP"))
if len(config_dirs) != 1: if layout["error"]:
errors.append(f"参考项目必须唯一定位一个配置目录,实际为 {len(config_dirs)} 个") errors.append(str(layout["error"]))
config_dir = config_dirs[0] if len(config_dirs) == 1 else None if layout["layout"] == "split-kernel-uboot":
sync_script = reference / "configs" / "EPSYNCSDKConfig.sh" sync_scripts = [reference / "configs" / part / script for part, script in (
if not sync_script.is_file(): ("kernel", "ep_kernel.sh"), ("uboot", "ep_uboot.sh"))]
warnings.append(f"未找到项目同步脚本: {sync_script}") 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 = reference / "imgs"
imgs_target = os.readlink(imgs) if imgs.is_symlink() else None 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"), "architecture": assignments.get("EP_ARCH"),
"flash": assignments.get("EP_FLASH_TYPE"), "flash": assignments.get("EP_FLASH_TYPE"),
"nand_flash_id": assignments.get("NAND_FLASH_ID"), "nand_flash_id": assignments.get("NAND_FLASH_ID"),
"board_build_type": config_dir.name if config_dir else None, "board_build_type": layout["board_build_type"],
"system_form": "dual-os" if config_dir and detects_dual_os(config_dir) else "single-os", "system_form": layout["system_form"],
"config_layout": layout["layout"],
}, },
"bounded_paths": { "bounded_paths": {
"build_env": str(build_env), "build_env": str(build_env),
"config_directory": str(config_dir) if config_dir else None, "config_directory": layout["config_directory"],
"sync_script": str(sync_script), "sync_script": str(sync_script) if sync_script else None,
"sync_scripts": [str(path) for path in sync_scripts],
"reference_imgs_target": imgs_target, "reference_imgs_target": imgs_target,
"target_imgs_target": project_guard.expected_imgs_target(target_name), "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): if not VERSION_RE.fullmatch(version):
errors.append("构建版本必须为 YYYYMMDD_N,且 N 为正整数") 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" completion_script = root / "tools" / "script_completion.sh"
if not completion_script.is_file(): if not completion_script.is_file():
errors.append(f"统一构建入口不存在: {completion_script}") errors.append(f"统一构建入口不存在: {completion_script}")
@@ -137,6 +211,22 @@ def build_preflight(project_name: str, version: str, root: Path, output_root: Pa
f"EMX2_BuildSDKTool {project_name} {version} all" f"EMX2_BuildSDKTool {project_name} {version} all"
) )
warnings.append("统一构建可能同步配置到项目外 SDK,并按工具实现执行 git fetch origin") warnings.append("统一构建可能同步配置到项目外 SDK,并按工具实现执行 git fetch origin")
is_cv184 = assignments.get("EP_CHIP", "").upper().startswith("CV184")
if is_cv184:
build_tool = root / "tools" / "BuildCvitek184XSDKTool.sh"
if not build_tool.is_file() or build_tool.is_symlink():
errors.append(f"CV184X 统一构建脚本缺失或不是普通文件: {build_tool}")
else:
script_lines = (
line.split("#", 1)[0]
for line in build_tool.read_text(encoding="utf-8").splitlines()
)
if any("--applypatch" in line and "--force" in line for line in script_lines):
errors.append(
f"统一构建脚本可能自动执行 --applypatch --force,禁止启动本次构建: {build_tool};"
"请由脚本所有者将该分支改为失败即退出"
)
warnings.append("CV184X 补丁完整性由统一构建脚本处理;Skill 不单独检查补丁环境")
return { return {
"schema_version": 1, "schema_version": 1,
"kind": "build-preflight", "kind": "build-preflight",
@@ -145,6 +235,10 @@ def build_preflight(project_name: str, version: str, root: Path, output_root: Pa
"warnings": warnings, "warnings": warnings,
"project": project_name, "project": project_name,
"project_path": str(project), "project_path": str(project),
"config_layout": layout["layout"],
"board_build_type": layout["board_build_type"],
"requires_patch_precheck": False,
"patch_check_owner": "native-build-script" if is_cv184 else None,
"build_version": version, "build_version": version,
"working_directory": str(root), "working_directory": str(root),
"completion_script": str(completion_script), "completion_script": str(completion_script),
@@ -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())
@@ -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())
@@ -8,6 +8,7 @@ import hashlib
import json import json
import os import os
import shlex import shlex
import stat
import subprocess import subprocess
import tempfile import tempfile
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
@@ -20,6 +21,9 @@ import project_guard
REQUIRED_IMAGE_MARGIN_BYTES = 512 * 1024 REQUIRED_IMAGE_MARGIN_BYTES = 512 * 1024
REQUIRED_PARTITION_IMAGES = ("kernel.bin", "rootfs.bin") 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: def now_utc() -> str:
@@ -113,6 +117,119 @@ def artifact_inventory(output_directory: Path) -> tuple[list[dict[str, object]],
return artifacts, warnings 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: def resolve_partition_table(project: Path, requested: Path | None) -> Path:
configs = project / "configs" configs = project / "configs"
if not configs.is_dir() or configs.is_symlink(): if not configs.is_dir() or configs.is_symlink():
@@ -258,10 +375,14 @@ def record_handoff(
root = project_guard.normalize_root(root) root = project_guard.normalize_root(root)
project = project_guard.direct_child(root, project_name, "project", True) 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 output_directory = output_root / project_name / "imgs" / build_version
if not output_directory.is_dir() or output_directory.is_symlink(): if not output_directory.is_dir() or output_directory.is_symlink():
raise project_guard.GuardError(f"最终 imgs 版本目录不存在或不是普通目录: {output_directory}") raise project_guard.GuardError(f"最终 imgs 版本目录不存在或不是普通目录: {output_directory}")
output_permission_check = ensure_output_permissions(output_root, output_directory)
artifacts, warnings = artifact_inventory(output_directory) artifacts, warnings = artifact_inventory(output_directory)
if not artifacts: if not artifacts:
raise project_guard.GuardError("最终 imgs 版本目录没有非空普通文件") raise project_guard.GuardError("最终 imgs 版本目录没有非空普通文件")
@@ -272,13 +393,10 @@ def record_handoff(
build_env = project / "buildEnvConfig.sh" build_env = project / "buildEnvConfig.sh"
assignments = bsp_preflight.parse_assignments(build_env) if build_env.is_file() else {} assignments = bsp_preflight.parse_assignments(build_env) if build_env.is_file() else {}
config_dirs = bsp_preflight.config_directories(project) layout = bsp_preflight.resolve_board_layout(project, assignments.get("EP_CHIP"))
config_dir = config_dirs[0] if len(config_dirs) == 1 else None if layout["error"]:
if len(config_dirs) != 1: raise project_guard.GuardError(str(layout["error"]))
warnings.append(f"无法唯一确定 board build type:找到 {len(config_dirs)} 个配置目录") system_form = layout["system_form"]
system_form = (
"dual-os" if config_dir is not None and bsp_preflight.detects_dual_os(config_dir) else "single-os"
)
command = build_command or ( command = build_command or (
f"cd {shlex.quote(str(root))} && source tools/script_completion.sh && " f"cd {shlex.quote(str(root))} && source tools/script_completion.sh && "
f"EMX2_BuildSDKTool {project_name} {build_version} all" f"EMX2_BuildSDKTool {project_name} {build_version} all"
@@ -288,6 +406,7 @@ def record_handoff(
handoff_dir = project / "doc" / "bsp_adaptation" / "handoff" handoff_dir = project / "doc" / "bsp_adaptation" / "handoff"
hash_path = validation_dir / f"artifact_manifest_{build_version}.sha256" hash_path = validation_dir / f"artifact_manifest_{build_version}.sha256"
margin_path = validation_dir / f"image_partition_margin_{build_version}.json" margin_path = validation_dir / f"image_partition_margin_{build_version}.json"
permission_path = validation_dir / f"output_permissions_{build_version}.json"
hash_lines = "".join( hash_lines = "".join(
f"{item['sha256']} {item['relative_path']}\n" for item in artifacts f"{item['sha256']} {item['relative_path']}\n" for item in artifacts
) )
@@ -295,6 +414,9 @@ def record_handoff(
write_text_atomic( write_text_atomic(
margin_path, json.dumps(image_partition_check, ensure_ascii=False, indent=2) + "\n" 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] = { manifest: dict[str, object] = {
"schema_version": 1, "schema_version": 1,
@@ -312,7 +434,7 @@ def record_handoff(
"sdk": assignments.get("EP_SDK_VER"), "sdk": assignments.get("EP_SDK_VER"),
"architecture": assignments.get("EP_ARCH"), "architecture": assignments.get("EP_ARCH"),
"flash": assignments.get("EP_FLASH_TYPE"), "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, "system_form": system_form,
}, },
"build": { "build": {
@@ -326,11 +448,13 @@ def record_handoff(
"imgs_version_path": str(output_directory), "imgs_version_path": str(output_directory),
"artifact_manifest_path": str(hash_path), "artifact_manifest_path": str(hash_path),
"image_partition_margin_path": str(margin_path), "image_partition_margin_path": str(margin_path),
"output_permissions_path": str(permission_path),
}, },
"artifacts": artifacts, "artifacts": artifacts,
"validation": { "validation": {
"local_build": "passed", "local_build": "passed",
"image_partition_margin": image_partition_check, "image_partition_margin": image_partition_check,
"output_permissions": output_permission_check,
"hardware": "pending", "hardware": "pending",
"owner_acceptance": "pending", "owner_acceptance": "pending",
"release": "not-authorized", "release": "not-authorized",
@@ -342,7 +466,7 @@ def record_handoff(
"immutable_artifacts": True, "immutable_artifacts": True,
"verify_sha256_before_use": True, "verify_sha256_before_use": True,
"select_latest_directory": False, "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" handoff_path = handoff_dir / "psp_handoff.json"
@@ -358,6 +482,8 @@ def record_handoff(
"psp_handoff_path": str(handoff_path), "psp_handoff_path": str(handoff_path),
"image_partition_margin_path": str(margin_path), "image_partition_margin_path": str(margin_path),
"image_partition_margin": image_partition_check, "image_partition_margin": image_partition_check,
"output_permissions_path": str(permission_path),
"output_permissions": output_permission_check,
"hardware_validation": "pending", "hardware_validation": "pending",
"owner_acceptance": "pending", "owner_acceptance": "pending",
"release": "not-authorized", "release": "not-authorized",
@@ -371,6 +497,8 @@ def record_handoff(
"artifact_manifest_path": str(hash_path), "artifact_manifest_path": str(hash_path),
"image_partition_margin_path": str(margin_path), "image_partition_margin_path": str(margin_path),
"image_partition_margin": image_partition_check, "image_partition_margin": image_partition_check,
"output_permissions_path": str(permission_path),
"output_permissions": output_permission_check,
"psp_handoff_path": str(handoff_path), "psp_handoff_path": str(handoff_path),
"status_path": str(status_path), "status_path": str(status_path),
"warnings": warnings, "warnings": warnings,
@@ -3,8 +3,11 @@
import hashlib import hashlib
import json import json
import os
import stat
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from unittest.mock import patch
import bsp_preflight import bsp_preflight
import project_guard import project_guard
@@ -47,6 +50,10 @@ def main() -> int:
tools = root / "tools" tools = root / "tools"
tools.mkdir() tools.mkdir()
(tools / "script_completion.sh").write_text("# test\n", encoding="utf-8") (tools / "script_completion.sh").write_text("# test\n", encoding="utf-8")
(tools / "BuildCvitek184XSDKTool.sh").write_text(
"#!/bin/sh\n./cvitek_cv184x_patch/scripts/repos --check-env || exit 1\n",
encoding="utf-8",
)
reference = make_project(root, "CV1841CP-REF_dualos") reference = make_project(root, "CV1841CP-REF_dualos")
project_report = bsp_preflight.project_preflight( project_report = bsp_preflight.project_preflight(
@@ -72,6 +79,19 @@ def main() -> int:
kernel.write_bytes(b"k" * (512 * 1024)) kernel.write_bytes(b"k" * (512 * 1024))
rootfs = output / "rootfs.bin" rootfs = output / "rootfs.bin"
rootfs.write_bytes(b"r" * (256 * 1024)) 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( conflict = bsp_preflight.build_preflight(
reference.name, "20260902_1", root, output_root reference.name, "20260902_1", root, output_root
) )
@@ -95,6 +115,21 @@ def main() -> int:
assert recorded["passed"] is True, recorded assert recorded["passed"] is True, recorded
assert recorded["imgs_version_path"] == str(output.resolve()) assert recorded["imgs_version_path"] == str(output.resolve())
assert recorded["image_partition_margin"]["passed"] is True 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_path = Path(recorded["psp_handoff_path"])
handoff = json.loads(handoff_path.read_text(encoding="utf-8")) handoff = json.loads(handoff_path.read_text(encoding="utf-8"))
assert handoff["handoff_type"] == "bsp-to-psp" assert handoff["handoff_type"] == "bsp-to-psp"
@@ -109,10 +144,24 @@ def main() -> int:
assert kernel_check["actual_margin_bytes"] == 512 * 1024 assert kernel_check["actual_margin_bytes"] == 512 * 1024
assert kernel_check["effective_partition_size_bytes"] == 1024 * 1024 assert kernel_check["effective_partition_size_bytes"] == 1024 * 1024
assert Path(recorded["image_partition_margin_path"]).is_file() 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["consumer_contract"]["select_latest_directory"] is False
assert handoff["artifacts"][0]["sha256"] == hashlib.sha256(boot.read_bytes()).hexdigest() assert handoff["artifacts"][0]["sha256"] == hashlib.sha256(boot.read_bytes()).hexdigest()
assert handoff["validation"]["hardware"] == "pending" 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 = output_root / reference.name / "imgs" / "20260902_2"
overflow_output.mkdir(parents=True) overflow_output.mkdir(parents=True)
(overflow_output / "boot.bin").write_bytes(b"boot") (overflow_output / "boot.bin").write_bytes(b"boot")
@@ -141,6 +190,41 @@ def main() -> int:
/ "image_partition_margin_20260902_2.json" / "image_partition_margin_20260902_2.json"
).exists() ).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 = ( original_partition = (
reference reference
/ "configs" / "configs"
@@ -0,0 +1,113 @@
#!/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")
build_tool = root / "tools/BuildCvitek184XSDKTool.sh"
write(build_tool, "./cvitek_cv184x_patch/scripts/repos --check-env\n"
"./cvitek_cv184x_patch/scripts/repos --applypatch --force\n")
cv184_build = bsp_preflight.build_preflight(cv184.name, "20260923_1", root, Path(temporary) / "output")
assert cv184_build["passed"] is False, cv184_build
assert any("--applypatch --force" in item for item in cv184_build["errors"]), cv184_build
write(build_tool, "./cvitek_cv184x_patch/scripts/repos --check-env || exit 1\n")
cv184_build = bsp_preflight.build_preflight(cv184.name, "20260923_1", root, Path(temporary) / "output")
assert cv184_build["passed"] is True, cv184_build
assert cv184_build["requires_patch_precheck"] is False, cv184_build
assert cv184_build["patch_check_owner"] == "native-build-script", cv184_build
print("CV181X layout and SDK read-only inspection tests passed")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -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", "<physical_partition/>\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", "<physical_partition/>\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())
@@ -14,13 +14,14 @@ manifest 的 `schema_version` 为 `1`、`handoff_type` 为 `bsp-to-psp`,并至
- 芯片、SDK、架构、Flash、board build type 和单/双系统形态; - 芯片、SDK、架构、Flash、board build type 和单/双系统形态;
- 构建版本、状态、退出码、完成标记、工作目录和完整命令; - 构建版本、状态、退出码、完成标记、工作目录和完整命令;
- `imgs_root` 和精确到构建版本的 `imgs_version_path`; - `imgs_root` 和精确到构建版本的 `imgs_version_path`;
- 本次 imgs 版本路径的 `a+rX` 处理结果、复核摘要和权限证据路径;
- 每个非空产物的相对路径、绝对路径、大小和 SHA-256; - 每个非空产物的相对路径、绝对路径、大小和 SHA-256;
- artifact hash 清单路径; - artifact hash 清单路径;
- 实际分区表路径/hash,以及 `kernel.bin`、`rootfs.bin` 的实际大小、全部对应分区、最小容量、至少 512 KiB 的实际余量和检查证据路径; - 实际分区表路径/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 消费者 ## PSP 消费者
@@ -28,7 +29,7 @@ PSP 接收 BSP 基线时:
1. 读取明确提供的 `psp_handoff.json`,不得扫描 imgs 根目录并自动选择最新版本。 1. 读取明确提供的 `psp_handoff.json`,不得扫描 imgs 根目录并自动选择最新版本。
2. 校验 schema、handoff type、项目/芯片/SDK/build type 与 PSP 目标一致。 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。 4. 对实际使用的每个 BSP 产物重新检查大小和 SHA-256;重新核对分区表 hash、外部余量证据,以及 kernel/rootfs 相对最小对应分区的 512 KiB 门禁;在 PSP 依赖闭包 manifest 中引用 BSP 交接文件自身的路径/hash。
5. 原样保留 BSP 的硬件、负责人和发布状态;PSP 组包不能替 BSP 升级这些状态。 5. 原样保留 BSP 的硬件、负责人和发布状态;PSP 组包不能替 BSP 升级这些状态。
6. 项目、构建版本、路径、大小或任一 hash 变化都会使相关 PSP 组包和验证失效,必须重新锁定基线。 6. 项目、构建版本、路径、大小或任一 hash 变化都会使相关 PSP 组包和验证失效,必须重新锁定基线。
@@ -40,9 +41,11 @@ PSP 使用以下只读校验器生成依赖证据:
```bash ```bash
python3 <PSP Skill>/scripts/psp/validate_bsp_handoff.py \ python3 <PSP Skill>/scripts/psp/validate_bsp_handoff.py \
<BSP项目>/doc/bsp_adaptation/handoff/psp_handoff.json \ <BSP项目>/doc/bsp_adaptation/handoff/psp_handoff.json \
--repair-output-permissions \
--chip <目标芯片> \ --chip <目标芯片> \
--project <目标BSP项目> \
--sdk <目标SDK> \ --sdk <目标SDK> \
--build-type <目标build type> --build-type <目标build type>
``` ```
交接 manifest 缺失或校验失败时,PSP 可以保留静态调查结果,但不能自行猜测 BSP 版本、形成可交付 candidate 或声明 packaged。 不带 `--repair-output-permissions` 时命令只读检查;PSP 已确认的适配流程启用受控权限修复。交接 manifest 缺失或校验失败时,PSP 可以保留静态调查结果,但不能自行猜测 BSP 版本、形成可交付 candidate 或声明 packaged。
@@ -1,19 +1,19 @@
--- ---
name: firmware-lowlevel-psp-development 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 作为独立、轻量的配置仓库维护。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),所有面向人的流程文档使用简体中文和直白表达。 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)。 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`、分支和远端;不再为导入和提交重复询问。已有业务内容的项目转入现有项目更新流程,不得按参考复制覆盖。 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`;禁止扫描目录选择最新构建。 6. 使用 BSP 产物时,读取统一的 [BSP→PSP 交接契约](../firmware-lowlevel-development/references/bsp-psp-handoff.md),校验用户明确提供的 `psp_handoff.json`;禁止扫描目录选择最新构建。
7. 需要组包或设备验证时,读取[组包与设备验证](references/packaging-and-device-validation.md)。EMX2.1 的目录和命令按内置固定规则生成本地契约,再做打包准备检查;不用让用户重复提供已经固定的信息。 7. 需要组包或设备验证时,读取[组包与设备验证](references/packaging-and-device-validation.md)。EMX2.1 的目录和命令按内置固定规则生成本地契约,再做打包准备检查;不用让用户重复提供已经固定的信息。
8. 需要跨角色交付或返工时,读取 [Etunel 交接](references/etunel-handoff.md);机器可读文件按[数据契约](references/schemas.md)生成。 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 必须位于当前用户的 `~/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 枚举不同才使用显式头文件覆盖。 - CV181X 的 `sensor_cfg.ini` 中 `name` 必须填写内置 `sensor_list.h` 的 `_SAMPLE_SNS_TYPE_E` 枚举名称;CV184X 必须填写内置 `sensor_cfg.h` 的 `_SNS_TYPE_E` 对应枚举值。规格选型不明确或内置表中无匹配项时停止,不得猜测;只有已证明 SDK 枚举不同才使用显式头文件覆盖。
- 保留团队统一环境中的绝对路径;检查全部 JSON 和配置/脚本文本。允许公共构建根路径;个人目录和临时目录必须警告,正式交付仅在用户显式批准精确根路径后放行。 - 保留团队统一环境中的绝对路径;检查全部 JSON 和配置/脚本文本。允许公共构建根路径;个人目录和临时目录必须警告,正式交付仅在用户显式批准精确根路径后放行。
- `psp.json`、BSP handoff、配置接口或依赖基线不匹配时,不通过寻找近似文件、选择最新目录、静默覆盖或降低检查来继续。 - `psp.json`、BSP handoff、配置接口或依赖基线不匹配时,不通过寻找近似文件、选择最新目录、静默覆盖或降低检查来继续。
@@ -42,7 +43,11 @@ PSP 仓库不保存固定打包框架、BSP 源码或 IMG、大型模型、公
## 确定性辅助工具 ## 确定性辅助工具
- `scripts/psp/psp_project_guard.py`:用 `inspect` 只读校验固定 PSP 根目录、项目路径和独立仓库并区分空仓库/现有项目;首次填充时受控复制到 staging,检查差异白名单与旧项目残留,验证通过后自动导入内容、复核最终目录且证明 Git 分支/远端未改变;不创建目录、不初始化仓库。 - `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_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/record_packaging_result.py`:用 `contract` 根据 EMX2.1 固定目录和命令生成本地契约;用 `preflight` 在候选提交前和正式组包前检查资源来源、DATA 工具、分区字段、BSP 分区余量与应用脚本;用 `snapshot/record` 证明本轮唯一新增的 `output/<版本>/` 及指定烧录包并记录路径、大小和 hash。工具不执行打包命令。
- `scripts/psp/render_psp_handoff.py`:把同一 commit 的验证证据汇总为供用户确认或 Etunel Hub 使用的交接文件,并可只读核对远端 commit;不发送消息。 - `scripts/psp/render_psp_handoff.py`:把同一 commit 的验证证据汇总为供用户确认或 Etunel Hub 使用的交接文件,并可只读核对远端 commit;不发送消息。
@@ -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 都要分别给出规格书选定的枚举名称和依据。 先从原理图或 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 目录或附件目录中搜索“最新”或相近文件: Skill 已内置本流程确认的两份枚举基线,运行时不得再到 SDK 目录或附件目录中搜索“最新”或相近文件:
- [CV181X `sensor_list.h`](sensor-enums/cv181x/sensor_list.h),用于 `_SAMPLE_SNS_TYPE_E`; - [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 文件由用户更新,Skill 不生成、不选择、不修改,也不把参考值自动声明为适配完成。若参考 PSP 已包含这些文件,保留其目录和内容,把状态记录为 `USER_OWNED/PENDING`;用户提供更新后记录精确文件/hash 和 `USER_REPORTED` 确认。ISP/PQ 未更新不阻止完成 PSP 的其他静态检查,但相关设备检查保持未执行或风险项,不能据此声称图像效果已经通过。
ISP/PQ 清单必须同时证明配置中的实际引用、文件存在性和 SHA-256。缺文件、引用旧文件、目录里有文件但没有任何生效引用,均不能标为已更新;Sensor 型号、数量、lane 或 ISP profile 变化后,旧确认自动失效。
## Media 与 AIPlugin ## 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 版本、Sensor 型号/数量/总线、用户负责的 ISP/PQ 状态、AIPlugin 版本、AIPlugin profile 的 SDK、模型平台和运行库目录。版本冲突默认阻塞;只有有实际负责人确认的兼容例外才能继续,并写入最终结果。
未声明的参考配置可以继承,但必须把关键继承项列给用户确认。Media 或 AIPlugin 未提供差异不等于自动禁用,也不等于其真实兼容。 未声明的参考配置可以继承,但必须把关键继承项列给用户确认。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` 和绝对路径
应用打包代码已依赖参考 `psp.json`,因此默认保持文件名、层级和字段。新增 BSP lock、允许覆盖和治理信息使用 `metadata/` sidecar;变更 `psp.json` schema、文件名、目录、启动调用或应用可见 GPIO 语义时,先通过 Etunel 取得技术负责人和应用层确认。 应用打包代码已依赖参考 `psp.json`,因此默认保持文件名、层级和字段。新增 BSP lock、允许覆盖和治理信息使用 `metadata/` sidecar;变更 `psp.json` schema、文件名、目录、启动调用或应用可见 GPIO 语义时,先通过 Etunel 取得技术负责人和应用层确认。
@@ -0,0 +1,36 @@
# CV181X Media / AIPlugin 配置取值指南
这份指南用于**没有相近板卡 PSP** 的新项目,也用于审计已有 PSP。它只提供代码和 Release 能证明的软件约束;Sensor 电气连接、镜头方向、屏幕、音频器件、内存余量、模型效果和 ISP/PQ 仍须由各自证据确认。
## 先找真正生效的文件
1. 从项目需求、BSP handoff 和应用工程锁定芯片、架构、libc、系统形态、SDK 版本、屏幕/音频/AI 能力,再指定**精确**的 `EmxMedia-<版本>/<profile>` 与 `EmxAiPlugin-<版本>/<profile>`。不得按日期挑最新,也不得用源码 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 <Media源码根> --ai-source <AI源码根> [--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 组合和证据继续。以上只是展示审计方法,不是新项目推荐版本。
@@ -40,12 +40,14 @@ PSP 的日常开发仓库位于 `~/work/project/EMX2.1/AppTeam/Psp/<项目名>/`
- `scripts/product.py`、`scripts/release.py` 和 `psp.json` 是普通文件; - `scripts/product.py`、`scripts/release.py` 和 `psp.json` 是普通文件;
- `EmxResToProduct` 每个 `src` 都有明确且存在的来源,`dst` 是安全的工程内相对路径且不重复; - `EmxResToProduct` 每个 `src` 都有明确且存在的来源,`dst` 是安全的工程内相对路径且不重复;
- `data` 分区有 `file`,`release.py` 使用 DATA 组包时,`mkAiData.py` 和 `data_ubinize.cfg` 都由 PSP 显式映射,不能依赖应用工作区里的旧文件; - `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 的 kernel/rootfs 分区名称、文件和容量与 `metadata/bsp.lock.json` 中已经通过的 BSP 分区证据一致,余量仍不少于 512 KiB;
- PSP 是预期 commit 且工作区干净。 - PSP 是预期 commit 且工作区干净。
工具能证明的是静态关系。还要只读检查 `product.py` 使用的 Media、AIPlugin、运行库和驱动清单;空目录、空列表以及“脚本打印错误但退出码仍为 0”的路径都要在执行前说明。该问题属于应用打包框架时,未经明确授权不得修改应用脚本。 工具能证明的是静态关系。还要只读检查 `product.py` 使用的 Media、AIPlugin、运行库和驱动清单;空目录、空列表以及“脚本打印错误但退出码仍为 0”的路径都要在执行前说明。该问题属于应用打包框架时,未经明确授权不得修改应用脚本。
分区发生变化时,先运行 `validate_partition_closure.py --psp-json <PSP/psp.json> --boot-default <PSP内实际生效Default> --release-py <应用/scripts/release.py>`。报告必须证明每个分区的推导 offset/size 与 Boot/Default 一致,以及 DATA 请求空间加保留块后仍可容纳。`release.py` 属于应用工程;工具只读校验,修改仍需要应用侧授权,不能因为 PSP 分区变化就越界改脚本。
完成上述检查后自动继续: 完成上述检查后自动继续:
1. 从项目已有规则读取“哪个文件是实际烧录包”的精确相对路径。无法唯一发现时视为异常,先请用户指定;`output/<版本>/` 可能包含多个 `.bin`,不能按扩展名或修改时间自行挑选。 1. 从项目已有规则读取“哪个文件是实际烧录包”的精确相对路径。无法唯一发现时视为异常,先请用户指定;`output/<版本>/` 可能包含多个 `.bin`,不能按扩展名或修改时间自行挑选。
@@ -7,11 +7,15 @@
- 多个应用只有在硬件、GPIO、内存/分区、BSP 固件组合、Sensor/Media/ISP/PQ 和 AIPlugin 平台配置完全一致时才共用同一 PSP。 - 多个应用只有在硬件、GPIO、内存/分区、BSP 固件组合、Sensor/Media/ISP/PQ 和 AIPlugin 平台配置完全一致时才共用同一 PSP。
- 保留预创建仓库的默认分支。经用户确认需要任务分支时,Etunel 任务建议使用 `candidate/<SUBTASK_ID>`,普通任务使用 `candidate/<简短任务名>`;分支名不是版本。 - 保留预创建仓库的默认分支。经用户确认需要任务分支时,Etunel 任务建议使用 `candidate/<SUBTASK_ID>`,普通任务使用 `candidate/<简短任务名>`;分支名不是版本。
- 应用仓库通过 Git submodule 把 PSP 固定在 `psp/`,由 submodule gitlink 锁定精确 commit;应用侧 `psp.lock.json` 只提供可读交接和一致性检查。 - 应用仓库通过 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` 时停止本节,转入“现有项目更新”。 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 表示。 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,拒绝软链接、疑似凭据、缺失目标、目标根目录逃逸及非空目标。 3. 使用 `psp_project_guard.py prepare --target ~/work/project/EMX2.1/AppTeam/Psp/<项目名>` 复制到同一根目录下的唯一 staging,拒绝软链接、疑似凭据、缺失目标、目标根目录逃逸及非空目标。
4. 在 staging 中修改;不接触参考 PSP 和目标 PSP。 4. 在 staging 中修改;不接触参考 PSP 和目标 PSP。
5. 使用 `verify` 按精确路径白名单检查差异和旧标识残留。 5. 使用 `verify` 按精确路径白名单检查差异和旧标识残留。
@@ -33,6 +37,7 @@
- 用户提供共享仓库地址或明确授权仓库创建流程。PSP skill 不自行选择 Git 服务、组织或远端。 - 用户提供共享仓库地址或明确授权仓库创建流程。PSP skill 不自行选择 Git 服务、组织或远端。
- 本地路径不能作为正式 Etunel 应用交付地址。commit 可从团队地址获取并验证后,才能声称应用可消费。 - 本地路径不能作为正式 Etunel 应用交付地址。commit 可从团队地址获取并验证后,才能声称应用可消费。
- Git push、Etunel candidate 交接、accepted/revoked tag 创建与 push、设备烧录和产品发布分别确认;一次确认不自动覆盖后续动作。 - Git push、Etunel candidate 交接、accepted/revoked tag 创建与 push、设备烧录和产品发布分别确认;一次确认不自动覆盖后续动作。
- push 或返回仓库链接前重新执行 `psp_project_guard.py inspect`,确认当前目录仍是固定 PSP 根目录的直接子仓库、HEAD 干净且远端属于该独立 PSP。当前仓库根是应用工程或 `PspProductionBase` 时停止,切换到独立 PSP 生产仓库后再继续。
- 不改写已经提供给打包、设备或应用验证的 commit。修正产生新 commit,并记录其替代的旧 candidate。 - 不改写已经提供给打包、设备或应用验证的 commit。修正产生新 commit,并记录其替代的旧 candidate。
## 状态和 tag ## 状态和 tag
@@ -2,6 +2,41 @@
所有 SHA 使用 64 位小写 SHA-256;Git commit 使用仓库返回的完整 SHA。状态必须引用实际证据,不允许人工填写 `passed` 掩盖未执行步骤。 所有 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": "<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` ## `metadata/bsp.lock.json`
PSP 仓库内保存 BSP handoff 的稳定摘要,不保存自身 commit: PSP 仓库内保存 BSP handoff 的稳定摘要,不保存自身 commit:
@@ -17,6 +52,13 @@ PSP 仓库内保存 BSP handoff 的稳定摘要,不保存自身 commit:
"chip": "CV1841CP", "chip": "CV1841CP",
"build_id": "20260901_1", "build_id": "20260901_1",
"imgs_version_path": "/epbuild/EMX2.0/EmxRes/.../imgs/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": { "image_partition_margin": {
"partition_table": "/absolute/BSP/configs/.../partition_spinand.xml", "partition_table": "/absolute/BSP/configs/.../partition_spinand.xml",
"required_margin_bytes": 524288, "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": "<ISO-8601>"
}
```
正式 preflight 必须在当前应用工程中重新验证每个 source/hash 和目标映射。文件缺失、hash 漂移、Media 版本变化或 destination 未被实际打包时例外失效。
## 应用侧 `psp.lock.json` ## 应用侧 `psp.lock.json`
由应用层创建,不放进 PSP 仓库: 由应用层创建,不放进 PSP 仓库:
@@ -122,6 +188,8 @@ submodule gitlink 是实际锁定依据;lock 用于人和工具核对。两者
`record_packaging_result.py preflight` 生成不可覆盖的 `schema_version: 1` 检查结果。候选提交前可以用 `--psp-source <staging>` 检查待导入内容;正式组包前必须使用应用工程内 `psp`、完整 commit 和 `--formal`。结果至少记录 PSP/脚本路径、资源来源映射、分区、BSP 分区比对、错误和警告。 `record_packaging_result.py preflight` 生成不可覆盖的 `schema_version: 1` 检查结果。候选提交前可以用 `--psp-source <staging>` 检查待导入内容;正式组包前必须使用应用工程内 `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 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,不复制日志正文。 `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,不复制日志正文。
@@ -5,7 +5,7 @@
开始前从项目和安全只读检查中确认: 开始前从项目和安全只读检查中确认:
- 目标应用项目路径、新项目名称,以及固定根目录 `~/work/project/EMX2.1/AppTeam/Psp/` 下由用户或团队预创建的同名 PSP 项目目录; - 目标应用项目路径、新项目名称,以及固定根目录 `~/work/project/EMX2.1/AppTeam/Psp/` 下由用户或团队预创建的同名 PSP 项目目录;
- 用户指定的相近板卡 PSP 路径或精确 commit; - 用户指定的相近板卡 PSP 路径或精确 commit;没有时记录“无参考”,不索取不存在的项目;
- 芯片、硬件 revision、单/双系统、存储介质和目标 BSP build type; - 芯片、硬件 revision、单/双系统、存储介质和目标 BSP build type;
- BSP `doc/bsp_adaptation/handoff/psp_handoff.json`; - BSP `doc/bsp_adaptation/handoff/psp_handoff.json`;
- 相对参考 PSP 的已知 GPIO、内存/分区、Media、Sensor 和 AIPlugin 差异,以及由用户更新的 ISP/PQ 状态; - 相对参考 PSP 的已知 GPIO、内存/分区、Media、Sensor 和 AIPlugin 差异,以及由用户更新的 ISP/PQ 状态;
@@ -14,12 +14,16 @@
- 禁止修改的目录及可用的 Git/Etunel 上下文。 - 禁止修改的目录及可用的 Git/Etunel 上下文。
- 用户指定的 EMX2.1 应用工程绝对路径;工程已有固定 `scripts/product.py`、`scripts/release.py` 和 `output/` 约定时,Skill 自行读取,不再向用户追问命令。 - 用户指定的 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 整体导入。 把 `~` 展开为运行 skill 的当前用户目录,并记录规范化后的绝对 PSP 根路径。目标项目必须是该根目录的普通直接子目录和独立 Git 仓库;保留已有分支、远端和仓库配置。目标目录不存在时停止并请用户或团队创建,禁止由 skill 建目录、运行 `git init` 或替用户配置远端。预创建目录已有业务内容时视为现有 PSP,只走基于 Git 差异的更新流程,禁止再次从参考 PSP 整体导入。
## 2. 参考兼容性 ## 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 能力。列出: 比较参考和目标的芯片系列、板卡形态、单/双系统、存储介质、Sensor 数量/型号、GPIO/Pinmux 结构、媒体能力和 AIPlugin 能力。列出:
- 可直接继承; - 可直接继承;
@@ -29,13 +33,15 @@
明显不兼容时默认停止。用户明确接受跨平台参考风险后仍须把每个未验证项留在结果中。 明显不兼容时默认停止。用户明确接受跨平台参考风险后仍须把每个未验证项留在结果中。
参考或目标是 Git 仓库时运行 `analyze_psp_delta.py`,固定四个视图:目标 HEAD、目标 worktree、参考 HEAD、参考 worktree。后续差异必须归入“匹配参考稳定 HEAD”“匹配参考未提交 worktree”“目标特有”“未分类冲突”;不能把参考仓库的脏文件冒充其 commit 内容。未分类项必须逐项解决,工具报告和两个完整 commit 一并进入外部证据。
## 3. 受控适配 ## 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 为差异基线。不得为了套用参考项目而清空、覆盖或重新初始化目标。 对于已有业务内容的目标项目,先证明它是固定根目录下的预期独立仓库,记录 HEAD、分支、远端和工作区状态;只修改任务明确授权的文件,并以修改前 commit/worktree 为差异基线。不得为了套用参考项目而清空、覆盖或重新初始化目标。
只修改确认的目标差异。适配后生成两张清单: 只修改确认的目标差异。无参考时“修改”指相对选定 Release 默认/应用接口新建或改变的字段,不是相对另一个板卡。适配后生成两张清单:
1. 修改项:文件、关键字段、旧值、新值、依据和影响; 1. 修改项:文件、关键字段、旧值、新值、依据和影响;
2. 继承项:GPIO、Sensor、BSP、Media、AIPlugin 等关键配置及确认状态。 2. 继承项:GPIO、Sensor、BSP、Media、AIPlugin 等关键配置及确认状态。
@@ -44,21 +50,25 @@
## 4. 先处理 BSP 阻塞 ## 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 操作确认。 如果 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,避免未经协调改变应用打包接口。 按[配置边界与检查](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. 静态验证和打包准备检查 ## 5. 静态验证和打包准备检查
最低静态检查包括: 最低静态检查包括:
- 受控复制和差异白名单; - 受控复制和差异白名单;
- PSP 目录、JSON/INI 和 shell 语法; - PSP 目录、JSON/INI 和 shell 语法;
- 文本编码、软链接和不可访问路径;运行时目录软链接不跟随扫描,但必须由资源映射和打包检查单独证明;
- 旧项目残留; - 旧项目残留;
- BSP handoff、路径、大小和 hash; - BSP handoff、路径、大小和 hash;
- GPIO、Media、Sensor 枚举映射、AIPlugin、SDK 和模型引用的一致性,以及 ISP/PQ 的用户负责状态; - GPIO、Media、Sensor 枚举映射、AIPlugin、SDK 和模型引用的一致性,以及 ISP/PQ 的用户负责状态;
- 硬件拓扑证据、四视图差异分类(无参考时为 N/A 并附 Release→生效值目录)、配置依赖闭包和分区闭包;
- 应用与 PSP 的同名配置覆盖清单; - 应用与 PSP 的同名配置覆盖清单;
- 未决、警告和批准例外。 - 未决、警告和批准例外。
@@ -98,6 +108,8 @@ candidate -> packaged -> device_verified
按 [Etunel 交接](etunel-handoff.md)把 packaged candidate 提供给 Hub。应用问题必须携带 PSP commit、应用 commit、BSP build、复现步骤、期望/实际、日志和阻塞影响,经 Hub 创建新的底层任务。 按 [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。 职责内问题形成新 candidate commit;跨 BSP、应用、硬件或公共组件的问题只提交证据和归属建议,由 Hub 路由。不得 force-push 已供打包、设备或应用验证的 commit。
## 9. 完成状态 ## 9. 完成状态
@@ -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())
@@ -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()
@@ -456,7 +456,7 @@ def inspect_project(target: Path, psp_root: Path = DEFAULT_PSP_ROOT) -> dict[str
def prepare( def prepare(
reference: Path, reference: Path | None,
target: Path, target: Path,
manifest_path: Path, manifest_path: Path,
old_tokens: list[str], old_tokens: list[str],
@@ -464,9 +464,10 @@ def prepare(
psp_root: Path = DEFAULT_PSP_ROOT, psp_root: Path = DEFAULT_PSP_ROOT,
) -> dict[str, object]: ) -> dict[str, object]:
psp_root = psp_root_directory(psp_root) 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") 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 不能互相包含") raise GuardError("reference、target 不能互相包含")
target_git = git_state(target) target_git = git_state(target)
target_repository = repository_identity(target, target_git) target_repository = repository_identity(target, target_git)
@@ -482,17 +483,22 @@ def prepare(
if manifest_path.exists() or manifest_path.is_symlink(): if manifest_path.exists() or manifest_path.is_symlink():
raise GuardError(f"manifest 已存在,禁止覆盖: {manifest_path}") raise GuardError(f"manifest 已存在,禁止覆盖: {manifest_path}")
links = scan_symlinks(reference) if reference is not None:
if links: links = scan_symlinks(reference)
raise GuardError("参考 PSP 包含软链接,未复制: " + ", ".join(links)) if links:
credentials = scan_credentials(reference) raise GuardError("参考 PSP 包含软链接,未复制: " + ", ".join(links))
if credentials: credentials = scan_credentials(reference)
raise GuardError("参考 PSP 包含疑似凭据文件,未复制: " + ", ".join(credentials)) if credentials:
source_git = git_state(reference) 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]}" staging = target.parent / f".{target.name}.psp-staging-{uuid.uuid4().hex[:12]}"
if staging.exists() or staging.is_symlink(): if staging.exists() or staging.is_symlink():
raise GuardError(f"随机 staging 已存在: {staging}") 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) copy_committed_head(reference, staging, source_git)
baseline_mode = "committed-head" baseline_mode = "committed-head"
warnings = ["参考 PSP 工作区有未提交内容;staging 使用已提交 HEAD,不包含这些修改"] warnings = ["参考 PSP 工作区有未提交内容;staging 使用已提交 HEAD,不包含这些修改"]
@@ -512,7 +518,7 @@ def prepare(
"schema_version": 2, "schema_version": 2,
"created_at": now_utc(), "created_at": now_utc(),
"psp_root": str(psp_root), "psp_root": str(psp_root),
"reference": str(reference), "reference": str(reference) if reference is not None else None,
"target": str(target), "target": str(target),
"staging": str(staging), "staging": str(staging),
"target_mode": "precreated-empty-repository", "target_mode": "precreated-empty-repository",
@@ -521,7 +527,7 @@ def prepare(
"reference_baseline": baseline_mode, "reference_baseline": baseline_mode,
"include_dirty_reference": include_dirty, "include_dirty_reference": include_dirty,
"old_tokens": sorted(set(token for token in old_tokens if token)), "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, "source_inventory": source_before,
"staging_inventory_before_edits": staging_inventory, "staging_inventory_before_edits": staging_inventory,
"warnings": warnings, "warnings": warnings,
@@ -529,7 +535,7 @@ def prepare(
write_json_atomic(manifest_path, payload) write_json_atomic(manifest_path, payload)
return { return {
"passed": True, "passed": True,
"reference": str(reference), "reference": str(reference) if reference is not None else None,
"target": str(target), "target": str(target),
"staging": str(staging), "staging": str(staging),
"psp_root": str(psp_root), "psp_root": str(psp_root),
@@ -550,12 +556,13 @@ def verify(
) -> dict[str, object]: ) -> dict[str, object]:
manifest_path = manifest_path.expanduser().resolve(strict=True) manifest_path = manifest_path.expanduser().resolve(strict=True)
manifest = load_json(manifest_path) 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") staging = ordinary_directory(Path(str(manifest.get("staging"))), "staging")
source = manifest.get("source_inventory") source = manifest.get("source_inventory")
if not isinstance(source, dict): if not isinstance(source, dict):
raise GuardError("manifest 缺少 source_inventory") 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") raise GuardError("prepare 后参考 PSP 发生变化;必须重新 prepare")
links = scan_symlinks(staging) links = scan_symlinks(staging)
credentials = scan_credentials(staging) credentials = scan_credentials(staging)
@@ -586,7 +593,7 @@ def verify(
"schema_version": 1, "schema_version": 1,
"checked_at": now_utc(), "checked_at": now_utc(),
"manifest": str(manifest_path), "manifest": str(manifest_path),
"reference": str(reference), "reference": str(reference) if reference is not None else None,
"staging": str(staging), "staging": str(staging),
"target": manifest.get("target"), "target": manifest.get("target"),
"allowed_paths": sorted(allowed), "allowed_paths": sorted(allowed),
@@ -693,7 +700,8 @@ def audit_final(
raise GuardError("manifest 缺少 psp_root") raise GuardError("manifest 缺少 psp_root")
psp_root = psp_root_directory(Path(root_value)) psp_root = psp_root_directory(Path(root_value))
target = project_directory(Path(str(manifest.get("target"))), psp_root, "target") 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") source = manifest.get("source_inventory")
if not isinstance(source, dict): if not isinstance(source, dict):
raise GuardError("manifest 缺少 source_inventory") raise GuardError("manifest 缺少 source_inventory")
@@ -732,8 +740,9 @@ def audit_final(
"schema_version": 1, "schema_version": 1,
"checked_at": now_utc(), "checked_at": now_utc(),
"manifest": str(manifest_path), "manifest": str(manifest_path),
"reference": str(reference), "reference": str(reference) if reference is not None else None,
"reference_drifted_since_prepare": not reference_matches_manifest(reference, manifest), "reference_drifted_since_prepare": (not reference_matches_manifest(reference, manifest)
if reference is not None else False),
"target": str(target), "target": str(target),
"allowed_paths": sorted(allowed), "allowed_paths": sorted(allowed),
"allowed_residue_paths": sorted(residue_allow), "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("--old-token", action="append", default=[])
prepare_parser.add_argument("--include-dirty", action="store_true") 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 = subparsers.add_parser("verify", help="审计 staging 的精确差异与残留")
verify_parser.add_argument("--manifest", type=Path, required=True) verify_parser.add_argument("--manifest", type=Path, required=True)
verify_parser.add_argument("--allow-path", action="append", default=[]) verify_parser.add_argument("--allow-path", action="append", default=[])
@@ -817,6 +831,8 @@ def main() -> int:
args.include_dirty, args.include_dirty,
args.psp_root, args.psp_root,
) )
elif args.command == "prepare-empty":
result = prepare(None, args.target, args.manifest, [], False, args.psp_root)
elif args.command == "verify": elif args.command == "verify":
result = verify(args.manifest, args.allow_path, args.allow_residue, args.report) result = verify(args.manifest, args.allow_path, args.allow_residue, args.report)
elif args.command == "finalize": elif args.command == "finalize":
@@ -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_RE = re.compile(r"^(\d+)([KMG])$", re.IGNORECASE)
SIZE_MULTIPLIERS = {"K": 1024, "M": 1024 * 1024, "G": 1024 * 1024 * 1024} SIZE_MULTIPLIERS = {"K": 1024, "M": 1024 * 1024, "G": 1024 * 1024 * 1024}
UBI_LEB_SIZE = 126976 UBI_LEB_SIZE = 126976
UBI_PEB_SIZE = 131072
UBI_REQUIRED_RESERVE_PEBS = 8
def parse_size(value: object, field: str) -> int | None: def parse_size(value: object, field: str) -> int | None:
@@ -415,7 +417,13 @@ def packaging_preflight(
errors.append( errors.append(
f"DATA 组包资源没有明确来源映射: EmxResToProduct 缺少 {required_dst}" 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: if len(counts) != 1:
errors.append("无法从 release.py 唯一确定 mkAiData.py 的容量参数") errors.append("无法从 release.py 唯一确定 mkAiData.py 的容量参数")
elif isinstance(data_row.get("size_bytes"), int): elif isinstance(data_row.get("size_bytes"), int):
@@ -423,9 +431,11 @@ def packaging_preflight(
if count <= 0: if count <= 0:
errors.append("mkAiData.py 的容量参数必须大于 0") errors.append("mkAiData.py 的容量参数必须大于 0")
requested = count * UBI_LEB_SIZE 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( 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" bsp_lock_path = source / "metadata" / "bsp.lock.json"
@@ -287,7 +287,7 @@ def render(
status: str, status: str,
psp_repo: Path, psp_repo: Path,
repository_url: str | None, repository_url: str | None,
reference_psp: str, reference_psp: str | None,
reference_commit: str | None, reference_commit: str | None,
static_path: Path, static_path: Path,
bsp_lock_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("--status", required=True, choices=STATUSES)
parser.add_argument("--psp-repo", type=Path, required=True) parser.add_argument("--psp-repo", type=Path, required=True)
parser.add_argument("--repository-url") 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("--reference-commit")
parser.add_argument("--static-validation", type=Path, required=True) parser.add_argument("--static-validation", type=Path, required=True)
parser.add_argument("--bsp-lock", 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: def main() -> int:
args = build_parser().parse_args() args = build_parser().parse_args()
try: try:
if args.no_reference and args.reference_commit:
raise HandoffError("无参考 PSP 时不能填写 --reference-commit")
remote_verification = None remote_verification = None
if args.verify_remote: if args.verify_remote:
if not args.repository_url: if not args.repository_url:
@@ -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())
@@ -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()
@@ -111,6 +111,22 @@ def main() -> int:
assert audited["passed"] is True, audited assert audited["passed"] is True, audited
assert audited["repository_unchanged"] is True 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" missing = psp_root / "MISSING"
expect_guard_error( expect_guard_error(
lambda: psp_project_guard.prepare( lambda: psp_project_guard.prepare(
@@ -155,6 +155,14 @@ def main() -> int:
assert candidate["passed"] is True, candidate assert candidate["passed"] is True, candidate
assert candidate["etunel"]["sent"] is False 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( packaged = render_psp_handoff.render(
"NEW", "NEW",
"CV1841CP", "CV1841CP",
@@ -3,8 +3,10 @@
import hashlib import hashlib
import json import json
import os
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from unittest.mock import patch
import validate_bsp_handoff import validate_bsp_handoff
@@ -12,7 +14,8 @@ import validate_bsp_handoff
def main() -> int: def main() -> int:
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
root = Path(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) imgs.mkdir(parents=True)
artifact = imgs / "boot.bin" artifact = imgs / "boot.bin"
artifact.write_bytes(b"boot") 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 = project / "doc" / "bsp_adaptation" / "validation" / "image_partition_margin_20260902_1.json"
margin_path.parent.mkdir(parents=True) margin_path.parent.mkdir(parents=True)
margin_path.write_text(json.dumps(margin), encoding="utf-8") 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" manifest = root / "psp_handoff.json"
payload = { payload = {
"schema_version": 1, "schema_version": 1,
@@ -86,6 +98,7 @@ def main() -> int:
"completion_marker_seen": True, "completion_marker_seen": True,
"imgs_version_path": str(imgs), "imgs_version_path": str(imgs),
"image_partition_margin_path": str(margin_path), "image_partition_margin_path": str(margin_path),
"output_permissions_path": str(permissions_path),
}, },
"artifacts": [ "artifacts": [
{ {
@@ -98,12 +111,17 @@ def main() -> int:
], ],
"validation": { "validation": {
"image_partition_margin": margin, "image_partition_margin": margin,
"output_permissions": permissions,
"hardware": "pending", "hardware": "pending",
"owner_acceptance": "pending", "owner_acceptance": "pending",
"release": "not-authorized", "release": "not-authorized",
}, },
} }
manifest.write_text(json.dumps(payload), encoding="utf-8") 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( result = validate_bsp_handoff.validate_handoff(
manifest, manifest,
"CV1841CP-BOARD_dualos", "CV1841CP-BOARD_dualos",
@@ -116,9 +134,74 @@ def main() -> int:
assert result["dependency"]["build_id"] == "20260902_1" assert result["dependency"]["build_id"] == "20260902_1"
assert result["dependency"]["chip"] == "CV1841CP" assert result["dependency"]["chip"] == "CV1841CP"
assert result["dependency"]["image_partition_margin"]["passed"] is True 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["dependency"]["artifacts"][0]["sha256"]
assert result["warnings"] 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 = json.loads(json.dumps(payload))
missing_margin_payload["validation"].pop("image_partition_margin") missing_margin_payload["validation"].pop("image_partition_margin")
missing_margin_payload["build"].pop("image_partition_margin_path") missing_margin_payload["build"].pop("image_partition_margin_path")
@@ -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())
@@ -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())
@@ -5,6 +5,7 @@ import hashlib
import json import json
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from unittest.mock import patch
import validate_psp import validate_psp
@@ -16,7 +17,8 @@ def main() -> int:
(psp / "configs").mkdir(parents=True) (psp / "configs").mkdir(parents=True)
(psp / "scripts").mkdir() (psp / "scripts").mkdir()
(psp / "AiPlugin").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_root.mkdir(parents=True)
artifact = artifact_root / "boot.bin" artifact = artifact_root / "boot.bin"
artifact.write_bytes(b"boot") artifact.write_bytes(b"boot")
@@ -38,6 +40,12 @@ def main() -> int:
(psp / "AiPlugin" / "profile.json").write_text( (psp / "AiPlugin" / "profile.json").write_text(
json.dumps({"EmxSdkVersion": "EmxSdk-1"}), encoding="utf-8" 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( (psp / "configs" / "same.json").write_text(
json.dumps( 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 = bsp_project / "doc" / "bsp_adaptation" / "validation" / "image_partition_margin_20260902_1.json"
margin_path.parent.mkdir(parents=True) margin_path.parent.mkdir(parents=True)
margin_path.write_text(json.dumps(margin), encoding="utf-8") 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( handoff.write_text(
json.dumps( json.dumps(
{ {
@@ -148,6 +165,7 @@ def main() -> int:
"completion_marker_seen": True, "completion_marker_seen": True,
"imgs_version_path": str(artifact_root), "imgs_version_path": str(artifact_root),
"image_partition_margin_path": str(margin_path), "image_partition_margin_path": str(margin_path),
"output_permissions_path": str(permissions_path),
}, },
"artifacts": [ "artifacts": [
{ {
@@ -160,6 +178,7 @@ def main() -> int:
], ],
"validation": { "validation": {
"image_partition_margin": margin, "image_partition_margin": margin,
"output_permissions": permissions,
"hardware": "pending", "hardware": "pending",
"owner_acceptance": "pending", "owner_acceptance": "pending",
"release": "not-authorized", "release": "not-authorized",
@@ -188,9 +207,24 @@ def main() -> int:
expected_sensors=expected_sensors, expected_sensors=expected_sensors,
) )
assert result["passed"] is True, result 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() assert lock.is_file()
lock_payload = json.loads(lock.read_text(encoding="utf-8")) lock_payload = json.loads(lock.read_text(encoding="utf-8"))
assert lock_payload["image_partition_margin"]["required_margin_bytes"] == 512 * 1024 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 result["checks"]["configuration_overrides"][0]["allowed"] is True
assert any("个人或临时目录" in warning for warning in result["warnings"]) assert any("个人或临时目录" in warning for warning in result["warnings"])
assert any( assert any(
@@ -1,17 +1,22 @@
#!/usr/bin/env python3 #!/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 from __future__ import annotations
import argparse import argparse
import hashlib import hashlib
import json import json
import os
import stat
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from pathlib import Path from pathlib import Path
REQUIRED_IMAGE_MARGIN_BYTES = 512 * 1024 REQUIRED_IMAGE_MARGIN_BYTES = 512 * 1024
REQUIRED_PARTITION_IMAGES = {"kernel.bin", "rootfs.bin"} 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: def sha256_file(path: Path) -> str:
@@ -30,6 +35,159 @@ def within(path: Path, root: Path) -> bool:
return False 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( def validate_image_partition_margin(
payload: object, payload: object,
build: dict[str, object], build: dict[str, object],
@@ -155,6 +313,7 @@ def validate_handoff(
expected_sdk: str | None = None, expected_sdk: str | None = None,
expected_build_type: str | None = None, expected_build_type: str | None = None,
expected_system_form: str | None = None, expected_system_form: str | None = None,
repair_output_permissions: bool = False,
) -> dict[str, object]: ) -> dict[str, object]:
errors: list[str] = [] errors: list[str] = []
warnings: list[str] = [] warnings: list[str] = []
@@ -196,6 +355,8 @@ def validate_handoff(
for field, (actual, expected) in expected_values.items(): for field, (actual, expected) in expected_values.items():
if expected is not None and actual != expected: if expected is not None and actual != expected:
errors.append(f"{field} 不匹配:期望 {expected!r},实际 {actual!r}") 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: if build.get("status") != "build_passed" or build.get("exit_code") != 0:
errors.append("BSP 构建状态不是 build_passed/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(): if not imgs_path.is_dir() or imgs_path.is_symlink():
errors.append(f"imgs_version_path 不存在或不是普通目录: {imgs_path}") 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 = 0
verified_artifacts: list[dict[str, object]] = [] verified_artifacts: list[dict[str, object]] = []
seen_relative_paths: set[str] = set() seen_relative_paths: set[str] = set()
@@ -297,6 +468,8 @@ def validate_handoff(
"verified_artifact_count": verified, "verified_artifact_count": verified,
"artifacts": verified_artifacts, "artifacts": verified_artifacts,
"image_partition_margin": image_partition_margin, "image_partition_margin": image_partition_margin,
"output_permissions_path": build.get("output_permissions_path"),
"output_permissions": output_permissions,
"hardware_validation": validation.get("hardware"), "hardware_validation": validation.get("hardware"),
"owner_acceptance": validation.get("owner_acceptance"), "owner_acceptance": validation.get("owner_acceptance"),
"release": validation.get("release"), "release": validation.get("release"),
@@ -312,6 +485,11 @@ def main() -> int:
parser.add_argument("--sdk") parser.add_argument("--sdk")
parser.add_argument("--build-type") parser.add_argument("--build-type")
parser.add_argument("--system-form", choices=("single-os", "dual-os")) 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() args = parser.parse_args()
try: try:
result = validate_handoff( result = validate_handoff(
@@ -321,6 +499,7 @@ def main() -> int:
args.sdk, args.sdk,
args.build_type, args.build_type,
args.system_form, args.system_form,
repair_output_permissions=args.repair_output_permissions,
) )
except (OSError, json.JSONDecodeError) as exc: except (OSError, json.JSONDecodeError) as exc:
result = {"passed": False, "errors": [str(exc)], "warnings": []} result = {"passed": False, "errors": [str(exc)], "warnings": []}
@@ -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())
@@ -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())
@@ -87,10 +87,33 @@ def under(path: Path, root: Path) -> bool:
return False 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]: def collect_json(root: Path, errors: list[str]) -> dict[str, Any]:
payloads: dict[str, Any] = {} payloads: dict[str, Any] = {}
for path in sorted(root.rglob("*.json")): for path in iter_regular_files(root):
if ".git" in path.parts: if path.suffix.lower() != ".json":
continue continue
relative = path.relative_to(root).as_posix() relative = path.relative_to(root).as_posix()
try: 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: def validate_ini(root: Path, errors: list[str]) -> int:
count = 0 count = 0
for path in sorted(root.rglob("*.ini")): for path in iter_regular_files(root):
if ".git" in path.parts: if path.suffix.lower() != ".ini":
continue continue
parser = configparser.ConfigParser(interpolation=None, strict=False) parser = configparser.ConfigParser(interpolation=None, strict=False)
try: try:
@@ -200,11 +223,9 @@ def validate_sensor_baseline(
errors.append("非 CV181X/CV184X 项目不应提供 CV18 Sensor 枚举校验参数") errors.append("非 CV181X/CV184X 项目不应提供 CV18 Sensor 枚举校验参数")
return result return result
candidates = sorted( candidates = [
path path for path in iter_regular_files(psp_root) if path.name == "sensor_cfg.ini"
for path in psp_root.rglob("sensor_cfg.ini") ]
if ".git" not in path.parts and path.is_file() and not path.is_symlink()
)
if len(candidates) != 1: if len(candidates) != 1:
errors.append( errors.append(
f"CV18 PSP 必须唯一定位一个 sensor_cfg.ini,实际 {len(candidates)} 个" 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: def validate_shell(root: Path, errors: list[str]) -> int:
count = 0 count = 0
for path in sorted(root.rglob("*.sh")): for path in iter_regular_files(root):
if ".git" in path.parts: if path.suffix.lower() != ".sh":
continue continue
result = subprocess.run( result = subprocess.run(
["bash", "-n", str(path)], ["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]]: 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 ( if (
not path.is_file() path.suffix.lower() not in TEXT_PATH_SUFFIXES
or path.is_symlink()
or ".git" in path.parts
or path.suffix.lower() not in TEXT_PATH_SUFFIXES
or path.stat().st_size > 4 * 1024 * 1024 or path.stat().st_size > 4 * 1024 * 1024
): ):
continue continue
@@ -409,7 +427,7 @@ def validate_absolute_paths(
continue continue
seen.add(key) seen.add(key)
path = Path(value).resolve(strict=False) 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) 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) risk_root = None if allowed_by is not None else path_risk(path)
build_input = source in BUILD_INPUT_JSONS build_input = source in BUILD_INPUT_JSONS
@@ -419,12 +437,19 @@ def validate_absolute_paths(
"path": value, "path": value,
"resolved_path": str(path), "resolved_path": str(path),
"exists": exists, "exists": exists,
"access_error": access_error,
"build_input": build_input, "build_input": build_input,
"allowed_by": str(allowed_by) if allowed_by else None, "allowed_by": str(allowed_by) if allowed_by else None,
"personal_or_temporary_root": risk_root, "personal_or_temporary_root": risk_root,
} }
entries.append(entry) 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}" message = f"外部绝对路径不存在 {source}:{location}: {value}"
if build_input: if build_input:
errors.append(f"外部绝对路径不存在 {source}:{location}: {value}") errors.append(f"外部绝对路径不存在 {source}:{location}: {value}")
@@ -503,7 +528,7 @@ def scan_overrides(
if not psp_dir.is_dir(): if not psp_dir.is_dir():
errors.append(f"PSP 配置目录不存在: {psp_dir}") errors.append(f"PSP 配置目录不存在: {psp_dir}")
continue 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) relative = app_path.relative_to(app_dir)
psp_path = psp_dir / relative psp_path = psp_dir / relative
if not psp_path.is_file(): 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]: 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 { return {
"schema_version": 1, "schema_version": 1,
"handoff_path": dependency.get("manifest_path"), "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"), "imgs_version_path": dependency.get("imgs_version_path"),
"artifacts": dependency.get("artifacts", []), "artifacts": dependency.get("artifacts", []),
"image_partition_margin": dependency.get("image_partition_margin"), "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, "mixed_build": None,
"warnings": warnings, "warnings": warnings,
} }
@@ -736,6 +772,7 @@ def validate_psp(
expected_sdk=expected_bsp_sdk, expected_sdk=expected_bsp_sdk,
expected_build_type=expected_build_type, expected_build_type=expected_build_type,
expected_system_form=expected_system_form, expected_system_form=expected_system_form,
repair_output_permissions=True,
) )
if bsp_result.get("passed") is not True: if bsp_result.get("passed") is not True:
errors.extend(f"BSP handoff: {item}" for item in bsp_result.get("errors", [])) errors.extend(f"BSP handoff: {item}" for item in bsp_result.get("errors", []))