feat: strengthen BSP and PSP validation and handoff

This commit is contained in:
tangfahong committed 2026-09-24 11:52:22 +08:00
1 parent bcea35a91e
commit 1641fac631
39 files changed
+2695 -90

No files matched your search

@@ -1,19 +1,19 @@
---
name: firmware-lowlevel-psp-development
description: "基于相近板卡 PSP 创建、修改和验证项目级平台配置,锁定 BSP 固件、Media 与 AIPlugin 依赖,并通过固定打包环境形成可交给应用层的精确 Git 基线。用于 PSP 配置适配、组包证据、人工设备验证和 Etunel 交接;不用于 BSP/DTS/驱动实现、应用业务、公共组件开发或未经确认的烧录、上传与发布。"
description: "创建、修改和验证项目级 PSP 平台配置;有相近板卡 PSP 时受控继承,无参考项目时依据硬件/BSP 事实、应用接口与精确 Media/AIPlugin Release 默认配置逐字段建立。用于 CV181X Media/AI 通道和参数审计、PSP 适配、组包证据及交接;不用于 BSP/驱动、应用业务、公共组件或未经确认的设备/发布操作。"
---
# 固件底层开发_PSP开发
把每个硬件项目的 PSP 作为独立、轻量的配置仓库维护。PSP 固定工作区是当前用户的 `~/work/project/EMX2.1/AppTeam/Psp/`;项目目录由用户或团队预先在该根目录下创建。以用户指定的相近板卡 PSP 为参考,只做目标项目所需的最小差异;PSP 的唯一身份是完整 Git commit SHA。
把每个硬件项目的 PSP 作为独立、轻量的配置仓库维护。PSP 固定工作区是当前用户的 `~/work/project/EMX2.1/AppTeam/Psp/`;项目目录由用户或团队预先在该根目录下创建。有参考 PSP 时只做最小差异;没有参考 PSP 时从已确认的硬件/BSP、应用接口和精确 Release 基线建立,不把任一 Release 当成完整 PSP。PSP 的唯一身份是完整 Git commit SHA。
## 进入流程
1. 先读取并遵守统一的[流程文档写法](../firmware-lowlevel-development/references/chinese-process-docs.md),所有面向人的流程文档使用简体中文和直白表达。
2. 读取统一入口的[项目所有权契约](../firmware-lowlevel-development/references/ownership-contract.md)、[治理工作流](../firmware-lowlevel-development/references/governance.md)和[风险等级](../firmware-lowlevel-development/references/risk-levels.md)。
3. 读取 [PSP 主工作流](references/workflow.md),确认目标项目目录是固定工作区的直接子目录,并确认参考 PSP、芯片/板卡/系统形态、BSP handoff、差异清单和负责人。目标目录不存在时停止,请用户或团队先创建;不得代为创建目录或初始化仓库。
3. 读取 [PSP 主工作流](references/workflow.md),先形成带来源/hash 的硬件拓扑事实,再确认目标项目目录是固定工作区的直接子目录,并确认是否有参考 PSP、芯片/板卡/系统形态、BSP handoff、差异清单和负责人。目标目录不存在时停止,请用户或团队先创建;不得代为创建目录或初始化仓库。
4. 首次填充、迁移、提交或更新 PSP 仓库时,读取[项目与 Git 规则](references/project-and-git.md)。首次填充只对预创建的空独立 Git 仓库执行:先在 staging 中适配并审计,验证通过后自动导入并创建本地候选提交,同时保留 `.git`、分支和远端;不再为导入和提交重复询问。已有业务内容的项目转入现有项目更新流程,不得按参考复制覆盖。
5. 修改 GPIO、BSP 引用、内存/分区结果、Sensor、Media 或 AIPlugin 配置时,读取[配置边界与检查](references/configuration-boundaries.md)。CV18 Sensor 默认按芯片系列使用 skill 内置枚举表,把规格书选型自动映射为配置名称或数值;无需临时查找 SDK 头文件。ISP/PQ 由用户更新。PSP 只消费 BSP 和公共组件成果,不越界修改其源码。
5. 修改 GPIO、BSP 引用、内存/分区结果、Sensor、Media 或 AIPlugin 配置时,读取[配置边界与检查](references/configuration-boundaries.md)。CV181X Media/AI 配置另读[源码与 Release 取值指南](references/cv181x-media-ai-configuration.md),按每个字段的生效路径、代码行为和实际硬件需求取值。CV18 Sensor 默认按芯片系列使用 skill 内置枚举表;ISP/PQ 由用户更新。PSP 只消费 BSP 和公共组件成果,不越界修改其源码。
6. 使用 BSP 产物时,读取统一的 [BSP→PSP 交接契约](../firmware-lowlevel-development/references/bsp-psp-handoff.md),校验用户明确提供的 `psp_handoff.json`;禁止扫描目录选择最新构建。
7. 需要组包或设备验证时,读取[组包与设备验证](references/packaging-and-device-validation.md)。EMX2.1 的目录和命令按内置固定规则生成本地契约,再做打包准备检查;不用让用户重复提供已经固定的信息。
8. 需要跨角色交付或返工时,读取 [Etunel 交接](references/etunel-handoff.md);机器可读文件按[数据契约](references/schemas.md)生成。
@@ -26,9 +26,10 @@ PSP 仓库不保存固定打包框架、BSP 源码或 IMG、大型模型、公
## 不可绕过的门禁
- 新 PSP 必须有用户明确指定的参考 PSP;先核对芯片系列、系统形态、存储介质、Sensor、GPIO、媒体和 AIPlugin 能力。明显不兼容时停止,除非用户知情确认跨平台参考。
- 新 PSP 优先使用用户明确指定的兼容参考 PSP;没有参考时允许走 `prepare-empty`,但必须独立证明硬件/BSP、应用 `psp.json` schema、Media/AI 精确 Release、运行配置、资源和分区闭包。仅凭 Release 默认值不能形成 candidate;跨平台参考明显不兼容时停止,除非用户知情确认风险。
- 原理图、规格书、GPIO 表和 BSP handoff 必须先收敛成 `hardware-topology.json`。目标旧配置和参考 PSP(若有)只证明软件现状,不能覆盖硬件资料;资料冲突未解决时停止。
- 目标 PSP 必须位于当前用户的 `~/work/project/EMX2.1/AppTeam/Psp/<项目名>/`,是该固定根目录的普通直接子目录和独立 Git 仓库。目录缺失、软链接、嵌套仓库或根目录外路径均停止;不运行 `mkdir` 或 `git init` 补齐。
- 默认继承参考 PSP,但必须分别展示修改项和关键继承项。能由参考兼容性、BSP handoff 和仓库证据证明的继承值不要求用户重复确认;GPIO、Sensor、BSP 固件、Media 或 AIPlugin 仍有冲突、缺项或多种合理解释时才阻止形成 `candidate`。
- 有参考时分别展示修改项和关键继承项;无参考时展示 Release 默认→最终生效值的逐字段差异及每项来源。能由代码、Release、BSP handoff 和硬件资料证明的值不要求用户重复确认;GPIO、Sensor、BSP 固件、Media 或 AIPlugin 有冲突、缺项或多种合理解释时阻止形成 `candidate`。
- CV181X 的 `sensor_cfg.ini` 中 `name` 必须填写内置 `sensor_list.h` 的 `_SAMPLE_SNS_TYPE_E` 枚举名称;CV184X 必须填写内置 `sensor_cfg.h` 的 `_SNS_TYPE_E` 对应枚举值。规格选型不明确或内置表中无匹配项时停止,不得猜测;只有已证明 SDK 枚举不同才使用显式头文件覆盖。
- 保留团队统一环境中的绝对路径;检查全部 JSON 和配置/脚本文本。允许公共构建根路径;个人目录和临时目录必须警告,正式交付仅在用户显式批准精确根路径后放行。
- `psp.json`、BSP handoff、配置接口或依赖基线不匹配时,不通过寻找近似文件、选择最新目录、静默覆盖或降低检查来继续。
@@ -42,7 +43,11 @@ PSP 仓库不保存固定打包框架、BSP 源码或 IMG、大型模型、公
## 确定性辅助工具
- `scripts/psp/psp_project_guard.py`:用 `inspect` 只读校验固定 PSP 根目录、项目路径和独立仓库并区分空仓库/现有项目;首次填充时受控复制到 staging,检查差异白名单与旧项目残留,验证通过后自动导入内容、复核最终目录且证明 Git 分支/远端未改变;不创建目录、不初始化仓库。
- `scripts/psp/validate_bsp_handoff.py`:校验 BSP handoff、精确 imgs 路径和产物 hash。
- `scripts/psp/validate_hardware_topology.py`:校验证据文件/hash、存储和每路 Sensor 物理事实,并用显式 `config_expectations` 对照唯一 `sensor_cfg.ini`。
- `scripts/psp/analyze_psp_delta.py`:同时比较目标 HEAD、目标 worktree、参考 HEAD 和参考 worktree,区分稳定参考、参考未提交改动、目标特有改动和未分类冲突。
- `scripts/psp/audit_cv181x_media_ai.py`:只读枚举 CV181X 精确 Media/AI Release 的全部 JSON 叶子字段、默认值/hash、源码读取位置线索,核对 SDK/ABI 标识、VPSS 图和 PSP 候选差异;不把代码命中或静态通过当成硬件正确性证明。
- `scripts/psp/validate_bsp_handoff.py`:校验 BSP handoff、权限证据、精确 imgs 路径和产物 hash。直接运行默认只读;PSP 已确认的适配流程自动在证据和项目身份一致时修复本次输出的 `a+rX` 权限并复核,失败则阻止 candidate。
- `scripts/psp/validate_psp.py`:校验 PSP 结构、配置语法、路径、脚本、CV18 Sensor 枚举映射和应用/PSP 覆盖关系;根据 `--expected-chip` 自动选择内置 CV181X/CV184X 头文件,通常无需 `--sensor-enum-header`。
- `scripts/psp/validate_partition_closure.py`:从 `psp.json` 推导完整分区 offset/size,逐项核对 Boot/Default 镜像值,并按 UBI LEB/PEB 与保留块校验 `mkAiData.py` 参数。
- `scripts/psp/record_packaging_result.py`:用 `contract` 根据 EMX2.1 固定目录和命令生成本地契约;用 `preflight` 在候选提交前和正式组包前检查资源来源、DATA 工具、分区字段、BSP 分区余量与应用脚本;用 `snapshot/record` 证明本轮唯一新增的 `output/<版本>/` 及指定烧录包并记录路径、大小和 hash。工具不执行打包命令。
- `scripts/psp/render_psp_handoff.py`:把同一 commit 的验证证据汇总为供用户确认或 Etunel Hub 使用的交接文件,并可只读核对远端 commit;不发送消息。
@@ -37,6 +37,8 @@ CV18 系列以 PSP 内唯一的 `sensor_cfg.ini` 为主要 Sensor 配置。文
先从原理图或 GPIO list 确认 Sensor 型号、数量、I2C bus/address、MIPI device/lane、P/N swap、MCLK、reset/power GPIO 等实际字段,再用规格书中的 Sensor 类型锁定枚举标识符。不得只凭相近命名、参考板旧值或枚举数字猜选型。每个 `[sensor]`、`[sensor2]` 等实际启用 section 都要分别给出规格书选定的枚举名称和依据。
Sensor 拓扑变更的依赖闭包至少包含:唯一 `sensor_cfg.ini`、Media profile/config/param、ISP/PQ 文件引用和 hash、AIPlugin 通道/输入配置、pinmux/reset/power/MCLK/PWM 脚本,以及与通道数有关的资源拷贝映射。只改 `sensor_cfg.ini` 不算闭环;不适用项也要写明原因。
Skill 已内置本流程确认的两份枚举基线,运行时不得再到 SDK 目录或附件目录中搜索“最新”或相近文件:
- [CV181X `sensor_list.h`](sensor-enums/cv181x/sensor_list.h),用于 `_SAMPLE_SNS_TYPE_E`;
@@ -55,12 +57,20 @@ Skill 已内置本流程确认的两份枚举基线,运行时不得再到 SDK
ISP 和 PQ 文件由用户更新,Skill 不生成、不选择、不修改,也不把参考值自动声明为适配完成。若参考 PSP 已包含这些文件,保留其目录和内容,把状态记录为 `USER_OWNED/PENDING`;用户提供更新后记录精确文件/hash 和 `USER_REPORTED` 确认。ISP/PQ 未更新不阻止完成 PSP 的其他静态检查,但相关设备检查保持未执行或风险项,不能据此声称图像效果已经通过。
ISP/PQ 清单必须同时证明配置中的实际引用、文件存在性和 SHA-256。缺文件、引用旧文件、目录里有文件但没有任何生效引用,均不能标为已更新;Sensor 型号、数量、lane 或 ISP profile 变化后,旧确认自动失效。
## Media 与 AIPlugin
CV181X 的新项目或 Media/AI 变更先读[源码与 Release 取值指南](cv181x-media-ai-configuration.md),并用 `audit_cv181x_media_ai.py` 按精确 Release/profile 枚举全部字段、默认值、代码读取线索、最终 PSP 差异和 VPSS 通道图。`source_hits_hint_only` 不是语义证明;每个拟修改字段仍要从代码、当前 SDK 枚举/范围、产品需求或硬件事实确认。没有参考 PSP 时不得把 Release 配置直接当成完整 PSP,也不能因为字段目录存在就认为系统可运行。
至少核对:Media 版本、Sensor 型号/数量/总线、用户负责的 ISP/PQ 状态、AIPlugin 版本、AIPlugin profile 的 SDK、模型平台和运行库目录。版本冲突默认阻塞;只有有实际负责人确认的兼容例外才能继续,并写入最终结果。
未声明的参考配置可以继承,但必须把关键继承项列给用户确认。Media 或 AIPlugin 未提供差异不等于自动禁用,也不等于其真实兼容。
Media 的运行库或驱动列表为空时默认阻塞。只有存在 `metadata/media-runtime-exception.json`,明确替代来源、目标路径、文件/hash、适用 Media 版本、原因、负责人和确认时间,并在当前应用工程验证替代来源真实存在,才能按例外继续。空列表不能只靠口头说明或“系统里已有”放行。
BSP build 变化的依赖闭包至少包含:`psp.json` 全部 BSP/IMG 资源路径、`metadata/bsp.lock.json`、驱动/模块资源映射、Boot/Default、旧 build ID/旧路径残留。分区变化的依赖闭包至少包含:`psp.json` 分区顺序/大小/file、Boot/Default 的 offset/size/mtdparts/内嵌 partition JSON、所有 MTD 索引、DATA 工具映射、`release.py` 的 `mkAiData.py` 参数和最终分区 CSV/摘要。
## `psp.json` 和绝对路径
应用打包代码已依赖参考 `psp.json`,因此默认保持文件名、层级和字段。新增 BSP lock、允许覆盖和治理信息使用 `metadata/` sidecar;变更 `psp.json` schema、文件名、目录、启动调用或应用可见 GPIO 语义时,先通过 Etunel 取得技术负责人和应用层确认。
@@ -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` 是普通文件;
- `EmxResToProduct` 每个 `src` 都有明确且存在的来源,`dst` 是安全的工程内相对路径且不重复;
- `data` 分区有 `file`,`release.py` 使用 DATA 组包时,`mkAiData.py` 和 `data_ubinize.cfg` 都由 PSP 显式映射,不能依赖应用工作区里的旧文件;
- `mkAiData.py` 的容量参数不超过 `data` 分区;
- `mkAiData.py` 的容量参数按 `参数 × UBI LEB` 换算实际请求空间,并加上明确的 PEB 保留量后不超过 `data` 分区;默认基线是 LEB=126976、PEB=131072、至少保留 8 PEB,项目工具参数不允许沿用无来源的固定常数;
- PSP 的 kernel/rootfs 分区名称、文件和容量与 `metadata/bsp.lock.json` 中已经通过的 BSP 分区证据一致,余量仍不少于 512 KiB;
- PSP 是预期 commit 且工作区干净。
工具能证明的是静态关系。还要只读检查 `product.py` 使用的 Media、AIPlugin、运行库和驱动清单;空目录、空列表以及“脚本打印错误但退出码仍为 0”的路径都要在执行前说明。该问题属于应用打包框架时,未经明确授权不得修改应用脚本。
分区发生变化时,先运行 `validate_partition_closure.py --psp-json <PSP/psp.json> --boot-default <PSP内实际生效Default> --release-py <应用/scripts/release.py>`。报告必须证明每个分区的推导 offset/size 与 Boot/Default 一致,以及 DATA 请求空间加保留块后仍可容纳。`release.py` 属于应用工程;工具只读校验,修改仍需要应用侧授权,不能因为 PSP 分区变化就越界改脚本。
完成上述检查后自动继续:
1. 从项目已有规则读取“哪个文件是实际烧录包”的精确相对路径。无法唯一发现时视为异常,先请用户指定;`output/<版本>/` 可能包含多个 `.bin`,不能按扩展名或修改时间自行挑选。
@@ -7,11 +7,15 @@
- 多个应用只有在硬件、GPIO、内存/分区、BSP 固件组合、Sensor/Media/ISP/PQ 和 AIPlugin 平台配置完全一致时才共用同一 PSP。
- 保留预创建仓库的默认分支。经用户确认需要任务分支时,Etunel 任务建议使用 `candidate/<SUBTASK_ID>`,普通任务使用 `candidate/<简短任务名>`;分支名不是版本。
- 应用仓库通过 Git submodule 把 PSP 固定在 `psp/`,由 submodule gitlink 锁定精确 commit;应用侧 `psp.lock.json` 只提供可读交接和一致性检查。
- 固定工作区中的独立 PSP 仓库是唯一生产者和发布来源;应用工程中的 `psp/` 只是消费者。仓库链接、push 结果和交付 commit 只能从独立 PSP 仓库的远端与 HEAD 取得,禁止从应用父仓库或 `PspProductionBase` 根仓库生成“PSP 仓库链接”。
## 受控复制
没有参考 PSP 的首次填充,不执行本节的参考复制步骤 2–5;先用 `psp_project_guard.py inspect` 确认预创建空独立仓库,再运行 `prepare-empty --target <固定根下项目> --manifest <仓库外新路径>` 建立空 staging。按应用 `product.py`/`release.py` 接口、BSP handoff、硬件事实、精确 Media/AI Release 和配置取值指南建立内容。对 staging 的每个新路径使用 `verify --allow-path <精确相对路径>`,通过全部原有静态/组包准备门禁后才 `finalize` 和 `audit-final`;`source_inventory` 为空,不存在参考提交或参考残留。`prepare-empty` 不会替用户写缺乏依据的 `psp.json`、Sensor、分区、GPIO、ISP/PQ;材料不足就停止,不以空 staging 的审计通过代替 PSP 完整性。
1. 展开当前用户的 `~`,先运行 `psp_project_guard.py inspect --target ~/work/project/EMX2.1/AppTeam/Psp/<项目名>`,确认目标 PSP 是固定根目录的普通直接子目录、其 `.git` 是普通目录,并记录分支、HEAD、远端指纹和配置指纹。首次填充要求结果是 `precreated-empty-repository`;结果是 `existing-project` 时停止本节,转入“现有项目更新”。
2. 检查参考 PSP 是普通目录、Git worktree 还是 submodule,记录精确来源。参考 worktree 脏时默认使用可追踪 HEAD;复制未提交内容必须让用户确认并标记来源不能由单一 commit 表示。
无论是否复制脏内容,都先用 `analyze_psp_delta.py` 分离参考 HEAD 与 worktree;差异报告不能只做一次普通目录对比。
3. 使用 `psp_project_guard.py prepare --target ~/work/project/EMX2.1/AppTeam/Psp/<项目名>` 复制到同一根目录下的唯一 staging,拒绝软链接、疑似凭据、缺失目标、目标根目录逃逸及非空目标。
4. 在 staging 中修改;不接触参考 PSP 和目标 PSP。
5. 使用 `verify` 按精确路径白名单检查差异和旧标识残留。
@@ -33,6 +37,7 @@
- 用户提供共享仓库地址或明确授权仓库创建流程。PSP skill 不自行选择 Git 服务、组织或远端。
- 本地路径不能作为正式 Etunel 应用交付地址。commit 可从团队地址获取并验证后,才能声称应用可消费。
- Git push、Etunel candidate 交接、accepted/revoked tag 创建与 push、设备烧录和产品发布分别确认;一次确认不自动覆盖后续动作。
- push 或返回仓库链接前重新执行 `psp_project_guard.py inspect`,确认当前目录仍是固定 PSP 根目录的直接子仓库、HEAD 干净且远端属于该独立 PSP。当前仓库根是应用工程或 `PspProductionBase` 时停止,切换到独立 PSP 生产仓库后再继续。
- 不改写已经提供给打包、设备或应用验证的 commit。修正产生新 commit,并记录其替代的旧 candidate。
## 状态和 tag
@@ -2,6 +2,41 @@
所有 SHA 使用 64 位小写 SHA-256;Git commit 使用仓库返回的完整 SHA。状态必须引用实际证据,不允许人工填写 `passed` 掩盖未执行步骤。
## 外部 `hardware-topology.json`
硬件事实文件保存在 PSP 仓库外,避免把附件绝对路径和调查状态写入正式配置。最小结构如下:
```json
{
"schema_version": 1,
"project": "LY-SM-YT42-AI",
"chip": "CV1841CP",
"system_mode": "dualos",
"storage": {"type": "spinand", "capacity_bytes": 134217728},
"sources": [{"kind": "schematic", "path": "/absolute/file.pdf", "sha256": "<sha256>"}],
"conflicts": [],
"sensors": [{
"section": "sensor",
"model": "MIS40C1",
"enum_name": "<规格对应枚举名>",
"i2c_bus": 2,
"i2c_address": "0x30",
"mipi_device": 1,
"lane_count": 1,
"evidence_sources": [0],
"config_expectations": {"bus_id": 2, "sns_i2c_addr": "0x30", "devno": 1}
}]
}
```
`config_expectations` 使用目标 `sensor_cfg.ini` 的真实字段名,不由工具猜测别名。证据冲突未解决时 `conflicts` 保留明细且验证失败。Flash 型号未提供时可以省略 `storage.model`,但容量和介质类型不能省略。
## 外部三方差异报告
`analyze_psp_delta.py` 输出目标 HEAD/worktree 与参考 HEAD/worktree 四视图,记录两个完整 commit、双方 dirty 状态、参考 dirty paths,以及 `matches_reference_head`、`matches_reference_dirty_worktree`、`target_unchanged`、`target_specific`、`unclassified_conflict` 分类。报告保存在仓库外;引用参考脏内容时必须明确它不能由参考 commit 单独复现。
无参考 PSP 时四视图报告为 `N/A`,不伪造参考。`psp_project_guard.py prepare-empty` 的 manifest 使用 `reference: null`、`reference_baseline: "empty"`、空 `source_inventory`;外部 CV181X Media/AI 审计报告保存精确 Release 路径、`configs.json` / `profile.json` hash、全部字段默认值及实际差异。`render_psp_handoff.py` 传 `--no-reference`,交接中 `reference_psp.source` 和 `commit` 均为 `null`;不得用 `"none"` 之类假路径。
## `metadata/bsp.lock.json`
PSP 仓库内保存 BSP handoff 的稳定摘要,不保存自身 commit:
@@ -17,6 +52,13 @@ PSP 仓库内保存 BSP handoff 的稳定摘要,不保存自身 commit:
"chip": "CV1841CP",
"build_id": "20260901_1",
"imgs_version_path": "/epbuild/EMX2.0/EmxRes/.../imgs/20260901_1",
"output_permissions": {
"policy": "a+rX",
"evidence_path": "/absolute/BSP/doc/bsp_adaptation/validation/output_permissions_20260901_1.json",
"output_root": "/epbuild/EMX2.0/EmxRes",
"imgs_version_path": "/epbuild/EMX2.0/EmxRes/.../imgs/20260901_1",
"passed": true
},
"image_partition_margin": {
"partition_table": "/absolute/BSP/configs/.../partition_spinand.xml",
"required_margin_bytes": 524288,
@@ -74,6 +116,30 @@ PSP 仓库内保存 BSP handoff 的稳定摘要,不保存自身 commit:
路径必须是仓库内精确相对路径,不允许绝对路径、`..` 或通配符。
## `metadata/media-runtime-exception.json`
仅在 Media profile 的运行库或驱动列表为空、但应用工程有已验证替代来源时使用:
```json
{
"schema_version": 1,
"media_version": "<精确版本>",
"reason": "<为何 PSP 列表为空>",
"alternatives": [
{
"kind": "userspace_or_driver",
"source": "Public/EmxRes/<精确相对路径>",
"destination": "<组包目标相对路径>",
"sha256": "<实际文件hash>"
}
],
"confirmed_by": "<负责人>",
"confirmed_at": "<ISO-8601>"
}
```
正式 preflight 必须在当前应用工程中重新验证每个 source/hash 和目标映射。文件缺失、hash 漂移、Media 版本变化或 destination 未被实际打包时例外失效。
## 应用侧 `psp.lock.json`
由应用层创建,不放进 PSP 仓库:
@@ -122,6 +188,8 @@ submodule gitlink 是实际锁定依据;lock 用于人和工具核对。两者
`record_packaging_result.py preflight` 生成不可覆盖的 `schema_version: 1` 检查结果。候选提交前可以用 `--psp-source <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 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 项目目录;
- 用户指定的相近板卡 PSP 路径或精确 commit;
- 用户指定的相近板卡 PSP 路径或精确 commit;没有时记录“无参考”,不索取不存在的项目;
- 芯片、硬件 revision、单/双系统、存储介质和目标 BSP build type;
- BSP `doc/bsp_adaptation/handoff/psp_handoff.json`;
- 相对参考 PSP 的已知 GPIO、内存/分区、Media、Sensor 和 AIPlugin 差异,以及由用户更新的 ISP/PQ 状态;
@@ -14,12 +14,16 @@
- 禁止修改的目录及可用的 Git/Etunel 上下文。
- 用户指定的 EMX2.1 应用工程绝对路径;工程已有固定 `scripts/product.py`、`scripts/release.py` 和 `output/` 约定时,Skill 自行读取,不再向用户追问命令。
缺失可从仓库发现的事实时先调查,不要求用户重复。参考 PSP、目标差异、负责人决定或跨平台风险属于用户决定,不能推断。同一缺项连续出现时只更新缺项列表,不重复完整问卷,也不在没有新输入时反复生成同一结论。
先把原理图、规格书、GPIO 表和已经确认的 BSP 事实收敛为仓库外 `hardware-topology.json`,运行 `validate_hardware_topology.py`。它至少记录芯片、系统形态、存储类型/容量、每路 Sensor 型号、I2C bus/address、MIPI device、lane 数、枚举名、证据源路径/hash,以及对 `sensor_cfg.ini` 的显式字段期望。器件型号未给出时只能按用户允许的范围记录容量,不猜型号。目标旧配置和参考 PSP 属于待核对的软件现状,不能作为覆盖硬件资料的证据;任何未解决冲突都阻止适配。
缺失可从仓库发现的事实时先调查,不要求用户重复。是否有参考 PSP、负责人决定或跨平台风险属于用户决定,不能推断;用户明确没有参考时启用下述“无参考”分支。同一缺项连续出现时只更新缺项列表,不重复完整问卷,也不在没有新输入时反复生成同一结论。
把 `~` 展开为运行 skill 的当前用户目录,并记录规范化后的绝对 PSP 根路径。目标项目必须是该根目录的普通直接子目录和独立 Git 仓库;保留已有分支、远端和仓库配置。目标目录不存在时停止并请用户或团队创建,禁止由 skill 建目录、运行 `git init` 或替用户配置远端。预创建目录已有业务内容时视为现有 PSP,只走基于 Git 差异的更新流程,禁止再次从参考 PSP 整体导入。
## 2. 参考兼容性
无参考 PSP 时本节兼容性和四视图报告标记为 `N/A(无参考)`,不得伪造一个参考。先锁定精确 Media/AI Release 版本/profile、硬件事实、BSP handoff 和应用打包接口,依[CV181X Media/AI 取值指南](cv181x-media-ai-configuration.md)生成全字段默认目录和生效配置差异。即使 Release 与源码完全相同,Media/AI 的 SDK、ABI、通道、模型、驱动与最终 PSP 值仍须单独验证。
比较参考和目标的芯片系列、板卡形态、单/双系统、存储介质、Sensor 数量/型号、GPIO/Pinmux 结构、媒体能力和 AIPlugin 能力。列出:
- 可直接继承;
@@ -29,13 +33,15 @@
明显不兼容时默认停止。用户明确接受跨平台参考风险后仍须把每个未验证项留在结果中。
参考或目标是 Git 仓库时运行 `analyze_psp_delta.py`,固定四个视图:目标 HEAD、目标 worktree、参考 HEAD、参考 worktree。后续差异必须归入“匹配参考稳定 HEAD”“匹配参考未提交 worktree”“目标特有”“未分类冲突”;不能把参考仓库的脏文件冒充其 commit 内容。未分类项必须逐项解决,工具报告和两个完整 commit 一并进入外部证据。
## 3. 受控适配
对于尚无业务内容的预创建项目仓库,按[项目与 Git 规则](project-and-git.md)在同一固定根目录的唯一 staging 中复制参考 PSP,排除参考仓库身份、构建输出、缓存、日志、临时文件、本机生成物和旧的应用侧 `psp.lock.json`。保持参考 PSP 的有效目录、配置、脚本、小型资源和精简文档。最终仅把已验证业务内容导入目标,目标自身 `.git` 不进入 staging、也不被替换。
对于尚无业务内容的预创建项目仓库,有参考时按[项目与 Git 规则](project-and-git.md)在同一固定根目录的唯一 staging 中复制参考 PSP,排除参考仓库身份、构建输出、缓存、日志、临时文件、本机生成物和旧的应用侧 `psp.lock.json`。无参考时使用 `psp_project_guard.py prepare-empty` 建立空 staging,依据应用接口、BSP handoff、硬件拓扑和精确 Release 默认值逐项建立业务文件;工具不会生成 `psp.json`、分区、Sensor、GPIO 或 PQ,缺少这些依据时只能保持 partial/blocked。最终仅把已验证业务内容导入目标,目标自身 `.git` 不进入 staging、也不被替换。
对于已有业务内容的目标项目,先证明它是固定根目录下的预期独立仓库,记录 HEAD、分支、远端和工作区状态;只修改任务明确授权的文件,并以修改前 commit/worktree 为差异基线。不得为了套用参考项目而清空、覆盖或重新初始化目标。
只修改确认的目标差异。适配后生成两张清单:
只修改确认的目标差异。无参考时“修改”指相对选定 Release 默认/应用接口新建或改变的字段,不是相对另一个板卡。适配后生成两张清单:
1. 修改项:文件、关键字段、旧值、新值、依据和影响;
2. 继承项:GPIO、Sensor、BSP、Media、AIPlugin 等关键配置及确认状态。
@@ -44,21 +50,25 @@
## 4. 先处理 BSP 阻塞
使用 `validate_bsp_handoff.py` 校验精确 BSP 输入,并把稳定摘要写入 `metadata/bsp.lock.json`;不要修改 BSP handoff。调用 `validate_psp.py` 时必须显式提供目标 PSP 项目名,以及期望的 BSP 项目、芯片、SDK、build type 和单/双系统形态,任一身份缺失或不一致都阻止 candidate。关键 IMG 缺失或 hash 不一致时同样阻止 candidate。
使用 `validate_bsp_handoff.py` 校验精确 BSP 输入、生产者的权限证据和当前路径权限,并把稳定摘要写入 `metadata/bsp.lock.json`;不要修改 BSP handoff。调用 `validate_psp.py` 时必须显式提供目标 PSP 项目名,以及期望的 BSP 项目、芯片、SDK、build type 和单/双系统形态;项目身份和权限证据一致但当前输出权限不足时,自动只补齐本次 imgs 路径的 `a+rX` 并复核。任一身份缺失或不一致、权限证据缺失或无权修复都阻止 candidate。关键 IMG 缺失或 hash 不一致时同样阻止 candidate。
如果 BSP handoff 缺失、镜像 hash 不符或 kernel/rootfs 的 512 KiB 余量检查失败,只报告这一组 BSP 问题和精确失败值,先交 BSP 处理。不要同时要求用户补 Sensor、ISP/PQ 或应用打包信息。能从现有 BSP 产物重新生成的证据应先做只读检查,再只申请一次明确的 BSP 操作确认。
按[配置边界与检查](configuration-boundaries.md)核对 GPIO、内存/分区、Sensor、Media、AIPlugin 及应用覆盖关系。CV18 根据规格书确定枚举名称后调用 `validate_psp.py --expected-sensor sensor=ENUM_NAME`,工具根据芯片自动使用内置枚举表:CV181X 验证配置中的枚举名称,CV184X 验证该名称对应的枚举数值。无需搜索或传入 SDK 头文件;只有已确认的枚举版本差异才显式覆盖。ISP/PQ 仅记录用户负责的状态,不由 Skill 修改。`psp.json` 保持参考格式;新增治理数据写入 sidecar metadata,避免未经协调改变应用打包接口。
任何分区变化必须运行 `validate_partition_closure.py`,从 `psp.json` 推导几何并核对 PSP 内实际生效的 Boot/Default 和应用 `release.py`。任何 Sensor、BSP build、Media/AIPlugin 变化都按配置依赖闭包逐项检查,不能只改最显眼的一个文件后进入 candidate。
## 5. 静态验证和打包准备检查
最低静态检查包括:
- 受控复制和差异白名单;
- PSP 目录、JSON/INI 和 shell 语法;
- 文本编码、软链接和不可访问路径;运行时目录软链接不跟随扫描,但必须由资源映射和打包检查单独证明;
- 旧项目残留;
- BSP handoff、路径、大小和 hash;
- GPIO、Media、Sensor 枚举映射、AIPlugin、SDK 和模型引用的一致性,以及 ISP/PQ 的用户负责状态;
- 硬件拓扑证据、四视图差异分类(无参考时为 N/A 并附 Release→生效值目录)、配置依赖闭包和分区闭包;
- 应用与 PSP 的同名配置覆盖清单;
- 未决、警告和批准例外。
@@ -98,6 +108,8 @@ candidate -> packaged -> device_verified
按 [Etunel 交接](etunel-handoff.md)把 packaged candidate 提供给 Hub。应用问题必须携带 PSP commit、应用 commit、BSP build、复现步骤、期望/实际、日志和阻塞影响,经 Hub 创建新的底层任务。
无参考项目时渲染交接用 `render_psp_handoff.py --no-reference`,并附精确 Release/profile/hash 和逐字段审计报告;不可把 Release 目录或其他板卡虚构成参考 PSP。
职责内问题形成新 candidate commit;跨 BSP、应用、硬件或公共组件的问题只提交证据和归属建议,由 Hub 路由。不得 force-push 已供打包、设备或应用验证的 commit。
## 9. 完成状态
@@ -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(
reference: Path,
reference: Path | None,
target: Path,
manifest_path: Path,
old_tokens: list[str],
@@ -464,9 +464,10 @@ def prepare(
psp_root: Path = DEFAULT_PSP_ROOT,
) -> dict[str, object]:
psp_root = psp_root_directory(psp_root)
reference = ordinary_directory(reference, "reference")
if reference is not None:
reference = ordinary_directory(reference, "reference")
target = project_directory(target, psp_root, "target")
if reference == target or reference in target.parents or target in reference.parents:
if reference is not None and (reference == target or reference in target.parents or target in reference.parents):
raise GuardError("reference、target 不能互相包含")
target_git = git_state(target)
target_repository = repository_identity(target, target_git)
@@ -482,17 +483,22 @@ def prepare(
if manifest_path.exists() or manifest_path.is_symlink():
raise GuardError(f"manifest 已存在,禁止覆盖: {manifest_path}")
links = scan_symlinks(reference)
if links:
raise GuardError("参考 PSP 包含软链接,未复制: " + ", ".join(links))
credentials = scan_credentials(reference)
if credentials:
raise GuardError("参考 PSP 包含疑似凭据文件,未复制: " + ", ".join(credentials))
source_git = git_state(reference)
if reference is not None:
links = scan_symlinks(reference)
if links:
raise GuardError("参考 PSP 包含软链接,未复制: " + ", ".join(links))
credentials = scan_credentials(reference)
if credentials:
raise GuardError("参考 PSP 包含疑似凭据文件,未复制: " + ", ".join(credentials))
source_git = git_state(reference) if reference is not None else None
staging = target.parent / f".{target.name}.psp-staging-{uuid.uuid4().hex[:12]}"
if staging.exists() or staging.is_symlink():
raise GuardError(f"随机 staging 已存在: {staging}")
if source_git.get("dirty") is True and not include_dirty:
if reference is None:
staging.mkdir()
baseline_mode = "empty"
warnings = ["无参考 PSP:必须按 BSP handoff、硬件资料、应用 schema 和精确 Release 建立每个配置;不可将 Release 当完整 PSP"]
elif source_git.get("dirty") is True and not include_dirty:
copy_committed_head(reference, staging, source_git)
baseline_mode = "committed-head"
warnings = ["参考 PSP 工作区有未提交内容;staging 使用已提交 HEAD,不包含这些修改"]
@@ -512,7 +518,7 @@ def prepare(
"schema_version": 2,
"created_at": now_utc(),
"psp_root": str(psp_root),
"reference": str(reference),
"reference": str(reference) if reference is not None else None,
"target": str(target),
"staging": str(staging),
"target_mode": "precreated-empty-repository",
@@ -521,7 +527,7 @@ def prepare(
"reference_baseline": baseline_mode,
"include_dirty_reference": include_dirty,
"old_tokens": sorted(set(token for token in old_tokens if token)),
"excluded_paths": excluded_paths(reference),
"excluded_paths": excluded_paths(reference) if reference is not None else [],
"source_inventory": source_before,
"staging_inventory_before_edits": staging_inventory,
"warnings": warnings,
@@ -529,7 +535,7 @@ def prepare(
write_json_atomic(manifest_path, payload)
return {
"passed": True,
"reference": str(reference),
"reference": str(reference) if reference is not None else None,
"target": str(target),
"staging": str(staging),
"psp_root": str(psp_root),
@@ -550,12 +556,13 @@ def verify(
) -> dict[str, object]:
manifest_path = manifest_path.expanduser().resolve(strict=True)
manifest = load_json(manifest_path)
reference = ordinary_directory(Path(str(manifest.get("reference"))), "reference")
reference = (ordinary_directory(Path(str(manifest["reference"])), "reference")
if manifest.get("reference") is not None else None)
staging = ordinary_directory(Path(str(manifest.get("staging"))), "staging")
source = manifest.get("source_inventory")
if not isinstance(source, dict):
raise GuardError("manifest 缺少 source_inventory")
if not reference_matches_manifest(reference, manifest):
if reference is not None and not reference_matches_manifest(reference, manifest):
raise GuardError("prepare 后参考 PSP 发生变化;必须重新 prepare")
links = scan_symlinks(staging)
credentials = scan_credentials(staging)
@@ -586,7 +593,7 @@ def verify(
"schema_version": 1,
"checked_at": now_utc(),
"manifest": str(manifest_path),
"reference": str(reference),
"reference": str(reference) if reference is not None else None,
"staging": str(staging),
"target": manifest.get("target"),
"allowed_paths": sorted(allowed),
@@ -693,7 +700,8 @@ def audit_final(
raise GuardError("manifest 缺少 psp_root")
psp_root = psp_root_directory(Path(root_value))
target = project_directory(Path(str(manifest.get("target"))), psp_root, "target")
reference = ordinary_directory(Path(str(manifest.get("reference"))), "reference")
reference = (ordinary_directory(Path(str(manifest["reference"])), "reference")
if manifest.get("reference") is not None else None)
source = manifest.get("source_inventory")
if not isinstance(source, dict):
raise GuardError("manifest 缺少 source_inventory")
@@ -732,8 +740,9 @@ def audit_final(
"schema_version": 1,
"checked_at": now_utc(),
"manifest": str(manifest_path),
"reference": str(reference),
"reference_drifted_since_prepare": not reference_matches_manifest(reference, manifest),
"reference": str(reference) if reference is not None else None,
"reference_drifted_since_prepare": (not reference_matches_manifest(reference, manifest)
if reference is not None else False),
"target": str(target),
"allowed_paths": sorted(allowed),
"allowed_residue_paths": sorted(residue_allow),
@@ -782,6 +791,11 @@ def build_parser() -> argparse.ArgumentParser:
prepare_parser.add_argument("--old-token", action="append", default=[])
prepare_parser.add_argument("--include-dirty", action="store_true")
empty_parser = subparsers.add_parser("prepare-empty", help="无参考 PSP:为预创建空仓库准备空 staging")
empty_parser.add_argument("--psp-root", type=Path, default=DEFAULT_PSP_ROOT)
empty_parser.add_argument("--target", type=Path, required=True)
empty_parser.add_argument("--manifest", type=Path, required=True)
verify_parser = subparsers.add_parser("verify", help="审计 staging 的精确差异与残留")
verify_parser.add_argument("--manifest", type=Path, required=True)
verify_parser.add_argument("--allow-path", action="append", default=[])
@@ -817,6 +831,8 @@ def main() -> int:
args.include_dirty,
args.psp_root,
)
elif args.command == "prepare-empty":
result = prepare(None, args.target, args.manifest, [], False, args.psp_root)
elif args.command == "verify":
result = verify(args.manifest, args.allow_path, args.allow_residue, args.report)
elif args.command == "finalize":
@@ -251,6 +251,8 @@ def create_contract(project_root: Path, chip: str, destination: Path) -> dict[st
SIZE_RE = re.compile(r"^(\d+)([KMG])$", re.IGNORECASE)
SIZE_MULTIPLIERS = {"K": 1024, "M": 1024 * 1024, "G": 1024 * 1024 * 1024}
UBI_LEB_SIZE = 126976
UBI_PEB_SIZE = 131072
UBI_REQUIRED_RESERVE_PEBS = 8
def parse_size(value: object, field: str) -> int | None:
@@ -415,7 +417,13 @@ def packaging_preflight(
errors.append(
f"DATA 组包资源没有明确来源映射: EmxResToProduct 缺少 {required_dst}"
)
counts = {int(value) for value in re.findall(r"mkAiData\.py\s+(\d+)\b", release_text)}
counts: set[int] = set()
for line in release_text.splitlines():
if line.lstrip().startswith("#"):
continue
counts.update(
int(value) for value in re.findall(r"mkAiData\.py\s+(\d+)\b", line)
)
if len(counts) != 1:
errors.append("无法从 release.py 唯一确定 mkAiData.py 的容量参数")
elif isinstance(data_row.get("size_bytes"), int):
@@ -423,9 +431,11 @@ def packaging_preflight(
if count <= 0:
errors.append("mkAiData.py 的容量参数必须大于 0")
requested = count * UBI_LEB_SIZE
if requested > int(data_row["size_bytes"]):
reserved = UBI_REQUIRED_RESERVE_PEBS * UBI_PEB_SIZE
if requested + reserved > int(data_row["size_bytes"]):
errors.append(
f"mkAiData.py 参数需要约 {requested} 字节,超过 data 分区 {data_row['size_bytes']} 字节"
f"mkAiData.py 参数需要 {requested} 字节并保留 {reserved} 字节,"
f"超过 data 分区 {data_row['size_bytes']} 字节"
)
bsp_lock_path = source / "metadata" / "bsp.lock.json"
@@ -287,7 +287,7 @@ def render(
status: str,
psp_repo: Path,
repository_url: str | None,
reference_psp: str,
reference_psp: str | None,
reference_commit: str | None,
static_path: Path,
bsp_lock_path: Path,
@@ -426,7 +426,9 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--status", required=True, choices=STATUSES)
parser.add_argument("--psp-repo", type=Path, required=True)
parser.add_argument("--repository-url")
parser.add_argument("--reference-psp", required=True)
reference_group = parser.add_mutually_exclusive_group(required=True)
reference_group.add_argument("--reference-psp")
reference_group.add_argument("--no-reference", action="store_true")
parser.add_argument("--reference-commit")
parser.add_argument("--static-validation", type=Path, required=True)
parser.add_argument("--bsp-lock", type=Path, required=True)
@@ -453,6 +455,8 @@ def build_parser() -> argparse.ArgumentParser:
def main() -> int:
args = build_parser().parse_args()
try:
if args.no_reference and args.reference_commit:
raise HandoffError("无参考 PSP 时不能填写 --reference-commit")
remote_verification = None
if args.verify_remote:
if not args.repository_url:
@@ -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["repository_unchanged"] is True
no_reference = create_empty_project(psp_root, "NO-REFERENCE")
empty_manifest = root / "evidence" / "empty-manifest.json"
empty_prepared = psp_project_guard.prepare(
None, no_reference, empty_manifest, [], False, psp_root
)
empty_staging = Path(empty_prepared["staging"])
assert list(empty_staging.iterdir()) == []
assert json.loads(empty_manifest.read_text(encoding="utf-8"))["reference"] is None
(empty_staging / "psp.json").write_text('{"project":"NO-REFERENCE"}', encoding="utf-8")
empty_report = root / "evidence" / "empty-verify.json"
assert psp_project_guard.verify(empty_manifest, ["psp.json"], [], empty_report)["passed"]
psp_project_guard.finalize(empty_manifest, empty_report)
assert psp_project_guard.audit_final(empty_manifest, ["psp.json"], [], None)["passed"]
assert (no_reference / ".git").is_dir()
assert (no_reference / "psp.json").is_file()
missing = psp_root / "MISSING"
expect_guard_error(
lambda: psp_project_guard.prepare(
@@ -155,6 +155,14 @@ def main() -> int:
assert candidate["passed"] is True, candidate
assert candidate["etunel"]["sent"] is False
without_reference = render_psp_handoff.render(
"NEW", "CV1841CP", "candidate", psp, None, None, None,
static, bsp, None, None, None, None, None, None, None,
[], [], [], None, False,
)
assert without_reference["passed"] is True, without_reference
assert without_reference["reference_psp"] == {"source": None, "commit": None}
packaged = render_psp_handoff.render(
"NEW",
"CV1841CP",
@@ -3,8 +3,10 @@
import hashlib
import json
import os
import tempfile
from pathlib import Path
from unittest.mock import patch
import validate_bsp_handoff
@@ -12,7 +14,8 @@ import validate_bsp_handoff
def main() -> int:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
imgs = root / "imgs" / "20260902_1"
output_root = root / "outputs"
imgs = output_root / "CV1841CP-BOARD_dualos" / "imgs" / "20260902_1"
imgs.mkdir(parents=True)
artifact = imgs / "boot.bin"
artifact.write_bytes(b"boot")
@@ -66,6 +69,15 @@ def main() -> int:
margin_path = project / "doc" / "bsp_adaptation" / "validation" / "image_partition_margin_20260902_1.json"
margin_path.parent.mkdir(parents=True)
margin_path.write_text(json.dumps(margin), encoding="utf-8")
permissions = {
"schema_version": 1,
"passed": True,
"policy": "a+rX",
"output_root": str(output_root),
"imgs_version_path": str(imgs),
}
permissions_path = margin_path.parent / "output_permissions_20260902_1.json"
permissions_path.write_text(json.dumps(permissions), encoding="utf-8")
manifest = root / "psp_handoff.json"
payload = {
"schema_version": 1,
@@ -86,6 +98,7 @@ def main() -> int:
"completion_marker_seen": True,
"imgs_version_path": str(imgs),
"image_partition_margin_path": str(margin_path),
"output_permissions_path": str(permissions_path),
},
"artifacts": [
{
@@ -98,12 +111,17 @@ def main() -> int:
],
"validation": {
"image_partition_margin": margin,
"output_permissions": permissions,
"hardware": "pending",
"owner_acceptance": "pending",
"release": "not-authorized",
},
}
manifest.write_text(json.dumps(payload), encoding="utf-8")
for directory in (output_root, output_root / "CV1841CP-BOARD_dualos", imgs.parent, imgs):
os.chmod(directory, 0o755)
for image in (artifact, kernel, rootfs):
os.chmod(image, 0o644)
result = validate_bsp_handoff.validate_handoff(
manifest,
"CV1841CP-BOARD_dualos",
@@ -116,9 +134,74 @@ def main() -> int:
assert result["dependency"]["build_id"] == "20260902_1"
assert result["dependency"]["chip"] == "CV1841CP"
assert result["dependency"]["image_partition_margin"]["passed"] is True
assert result["dependency"]["output_permissions"]["current_check"]["passed"] is True
assert result["dependency"]["artifacts"][0]["sha256"]
assert result["warnings"]
os.chmod(output_root, 0o700)
os.chmod(imgs, 0o700)
os.chmod(kernel, 0o600)
other_build = root / "another-build"
other_build.mkdir(mode=0o700)
(imgs / "external-link").symlink_to(other_build, target_is_directory=True)
readonly = validate_bsp_handoff.validate_handoff(manifest)
assert readonly["passed"] is False
assert any("共享读取/进入权限" in error for error in readonly["errors"])
assert output_root.stat().st_mode & 0o777 == 0o700
repaired = validate_bsp_handoff.validate_handoff(
manifest,
expected_project="CV1841CP-BOARD_dualos",
repair_output_permissions=True,
)
assert repaired["passed"] is True, repaired
assert repaired["dependency"]["output_permissions"]["current_check"]["repaired"] is True
assert repaired["dependency"]["output_permissions"]["current_check"]["symlinks_skipped"] == 1
assert output_root.stat().st_mode & 0o555 == 0o555
assert other_build.stat().st_mode & 0o777 == 0o700
assert kernel.stat().st_mode & 0o444 == 0o444
assert kernel.stat().st_mode & 0o111 == 0
no_evidence_payload = json.loads(json.dumps(payload))
no_evidence_payload["validation"].pop("output_permissions")
no_evidence_manifest = root / "no_permissions_handoff.json"
no_evidence_manifest.write_text(json.dumps(no_evidence_payload), encoding="utf-8")
no_evidence = validate_bsp_handoff.validate_handoff(
no_evidence_manifest,
expected_project="CV1841CP-BOARD_dualos",
repair_output_permissions=True,
)
assert no_evidence["passed"] is False
assert any("权限证据" in error for error in no_evidence["errors"])
os.chmod(kernel, 0o600)
bad_project = validate_bsp_handoff.validate_handoff(
manifest,
expected_project="NOT-THE-PROJECT",
repair_output_permissions=True,
)
assert bad_project["passed"] is False
assert kernel.stat().st_mode & 0o444 != 0o444
bad_evidence_payload = json.loads(json.dumps(payload))
bad_evidence_payload["validation"]["output_permissions"]["output_root"] = str(root / "wrong-root")
bad_evidence_manifest = root / "bad_permissions_handoff.json"
bad_evidence_manifest.write_text(json.dumps(bad_evidence_payload), encoding="utf-8")
bad_evidence = validate_bsp_handoff.validate_handoff(
bad_evidence_manifest,
expected_project="CV1841CP-BOARD_dualos",
repair_output_permissions=True,
)
assert bad_evidence["passed"] is False
assert kernel.stat().st_mode & 0o444 != 0o444
with patch.object(validate_bsp_handoff.os, "chmod", side_effect=PermissionError("denied")):
unrepairable = validate_bsp_handoff.validate_handoff(
manifest,
expected_project="CV1841CP-BOARD_dualos",
repair_output_permissions=True,
)
assert unrepairable["passed"] is False
assert any("无法检查或修复" in error for error in unrepairable["errors"])
os.chmod(kernel, 0o644)
missing_margin_payload = json.loads(json.dumps(payload))
missing_margin_payload["validation"].pop("image_partition_margin")
missing_margin_payload["build"].pop("image_partition_margin_path")
@@ -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 tempfile
from pathlib import Path
from unittest.mock import patch
import validate_psp
@@ -16,7 +17,8 @@ def main() -> int:
(psp / "configs").mkdir(parents=True)
(psp / "scripts").mkdir()
(psp / "AiPlugin").mkdir()
artifact_root = root / "imgs" / "20260902_1"
output_root = root / "outputs"
artifact_root = output_root / "CV1841CP-NEW_dualos" / "imgs" / "20260902_1"
artifact_root.mkdir(parents=True)
artifact = artifact_root / "boot.bin"
artifact.write_bytes(b"boot")
@@ -38,6 +40,12 @@ def main() -> int:
(psp / "AiPlugin" / "profile.json").write_text(
json.dumps({"EmxSdkVersion": "EmxSdk-1"}), encoding="utf-8"
)
runtime_configs = root / "runtime-configs"
runtime_configs.mkdir()
(runtime_configs / "invalid.json").write_text("{", encoding="utf-8")
(runtime_configs / "invalid.ini").write_text("[", encoding="utf-8")
(runtime_configs / "invalid.sh").write_text("if\n", encoding="utf-8")
(psp / "AiPlugin" / "configs").symlink_to(runtime_configs, target_is_directory=True)
(psp / "configs" / "same.json").write_text(
json.dumps(
{
@@ -126,6 +134,15 @@ def main() -> int:
margin_path = bsp_project / "doc" / "bsp_adaptation" / "validation" / "image_partition_margin_20260902_1.json"
margin_path.parent.mkdir(parents=True)
margin_path.write_text(json.dumps(margin), encoding="utf-8")
permissions = {
"schema_version": 1,
"passed": True,
"policy": "a+rX",
"output_root": str(output_root),
"imgs_version_path": str(artifact_root),
}
permissions_path = margin_path.parent / "output_permissions_20260902_1.json"
permissions_path.write_text(json.dumps(permissions), encoding="utf-8")
handoff.write_text(
json.dumps(
{
@@ -148,6 +165,7 @@ def main() -> int:
"completion_marker_seen": True,
"imgs_version_path": str(artifact_root),
"image_partition_margin_path": str(margin_path),
"output_permissions_path": str(permissions_path),
},
"artifacts": [
{
@@ -160,6 +178,7 @@ def main() -> int:
],
"validation": {
"image_partition_margin": margin,
"output_permissions": permissions,
"hardware": "pending",
"owner_acceptance": "pending",
"release": "not-authorized",
@@ -188,9 +207,24 @@ def main() -> int:
expected_sensors=expected_sensors,
)
assert result["passed"] is True, result
scanned_files = {
path.relative_to(psp).as_posix()
for path in validate_psp.iter_regular_files(psp)
}
assert "AiPlugin/configs/invalid.json" not in scanned_files
assert "AiPlugin/configs/invalid.ini" not in scanned_files
assert "AiPlugin/configs/invalid.sh" not in scanned_files
with patch.object(Path, "exists", side_effect=PermissionError(13, "denied")):
exists, access_error = validate_psp.probe_path_exists(Path("/target/runtime"))
assert exists is False
assert access_error is not None and "denied" in access_error
assert lock.is_file()
lock_payload = json.loads(lock.read_text(encoding="utf-8"))
assert lock_payload["image_partition_margin"]["required_margin_bytes"] == 512 * 1024
assert lock_payload["output_permissions"]["passed"] is True
assert lock_payload["output_permissions"]["evidence_path"] == str(permissions_path)
assert output_root.stat().st_mode & 0o555 == 0o555
assert artifact.stat().st_mode & 0o444 == 0o444
assert result["checks"]["configuration_overrides"][0]["allowed"] is True
assert any("个人或临时目录" in warning for warning in result["warnings"])
assert any(
@@ -1,17 +1,22 @@
#!/usr/bin/env python3
"""Validate a BSP-to-PSP handoff and its immutable artifact hashes."""
"""Validate a BSP-to-PSP handoff, output permissions, and artifact hashes."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import stat
import xml.etree.ElementTree as ET
from pathlib import Path
REQUIRED_IMAGE_MARGIN_BYTES = 512 * 1024
REQUIRED_PARTITION_IMAGES = {"kernel.bin", "rootfs.bin"}
DIRECTORY_ACCESS_BITS = 0o555
FILE_READ_BITS = 0o444
FILE_EXECUTE_BITS = 0o111
def sha256_file(path: Path) -> str:
@@ -30,6 +35,159 @@ def within(path: Path, root: Path) -> bool:
return False
def validate_output_permissions(
payload: object,
build: dict[str, object],
producer: dict[str, object],
imgs_path: Path | None,
errors: list[str],
repair: bool,
) -> dict[str, object] | None:
"""Check BSP evidence and live modes; optionally repair only its exact output."""
if not isinstance(payload, dict) or payload.get("passed") is not True:
errors.append("BSP handoff 缺少通过的输出路径权限证据")
return None
if payload.get("schema_version") != 1 or payload.get("policy") != "a+rX":
errors.append("输出路径权限证据的 schema_version/policy 不匹配")
return None
root_value = payload.get("output_root")
version_value = payload.get("imgs_version_path")
project_name = producer.get("project")
build_version = build.get("version")
if (
not isinstance(root_value, str)
or not Path(root_value).is_absolute()
or not isinstance(version_value, str)
or not Path(version_value).is_absolute()
or not isinstance(project_name, str)
or project_name in ("", ".", "..")
or Path(project_name).name != project_name
or not isinstance(build_version, str)
or build_version in ("", ".", "..")
or Path(build_version).name != build_version
or imgs_path is None
):
errors.append("输出路径权限证据缺少精确的绝对输出目录、项目或构建版本")
return None
output_root = Path(root_value)
if (
len(output_root.parts) < 3
or output_root == Path.home()
or output_root.is_symlink()
):
errors.append("输出路径权限证据的根路径过宽或是软链接")
return None
expected_imgs_path = output_root / project_name / "imgs" / build_version
if Path(version_value) != expected_imgs_path or imgs_path != expected_imgs_path:
errors.append("输出路径权限证据与 BSP 项目、构建版本或 imgs_version_path 不一致")
return None
evidence_value = build.get("output_permissions_path")
project_value = producer.get("project_path")
if (
not isinstance(project_value, str)
or not Path(project_value).is_absolute()
or not isinstance(evidence_value, str)
or not Path(evidence_value).is_absolute()
):
errors.append("build.output_permissions_path 必须是 BSP 项目内的绝对路径")
return None
evidence_path = Path(evidence_value)
expected_evidence = (
Path(project_value)
/ "doc" / "bsp_adaptation" / "validation"
/ f"output_permissions_{build_version}.json"
)
if evidence_path != expected_evidence or evidence_path.is_symlink():
errors.append("输出路径权限证据文件路径不是预期 BSP 项目内的普通文件")
return None
try:
external_payload = json.loads(evidence_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
errors.append(f"输出路径权限证据文件不可读或解析失败: {exc}")
return None
if external_payload != payload:
errors.append("handoff 内嵌权限证据与外部证据文件不一致")
return None
paths: list[tuple[Path, int, str]] = []
for path in (
output_root,
output_root / project_name,
output_root / project_name / "imgs",
expected_imgs_path,
):
if path.is_symlink() or not path.is_dir():
errors.append(f"构建输出路径节点不是普通目录: {path}")
return None
paths.append((path, DIRECTORY_ACCESS_BITS, "目录"))
def walk_error(exc: OSError) -> None:
raise exc
skipped_symlinks = 0
try:
for parent, directory_names, file_names in os.walk(
expected_imgs_path, followlinks=False, onerror=walk_error
):
parent_path = Path(parent)
for name in list(directory_names):
path = parent_path / name
if path.is_symlink():
directory_names.remove(name)
skipped_symlinks += 1
elif path.is_dir():
paths.append((path, DIRECTORY_ACCESS_BITS, "目录"))
else:
errors.append(f"构建输出包含非普通目录: {path}")
return None
for name in file_names:
path = parent_path / name
if path.is_symlink():
skipped_symlinks += 1
elif path.is_file():
bits = FILE_READ_BITS
if stat.S_IMODE(path.stat().st_mode) & FILE_EXECUTE_BITS:
bits |= FILE_EXECUTE_BITS
paths.append((path, bits, "文件"))
else:
errors.append(f"构建输出包含非普通文件: {path}")
return None
except OSError as exc:
errors.append(f"无法遍历构建输出目录: {exc}")
return None
changed = 0
for path, bits, kind in paths:
try:
if path.is_symlink():
errors.append(f"权限处理拒绝软链接: {path}")
return None
before = stat.S_IMODE(path.stat().st_mode)
if before & bits != bits:
if not repair:
errors.append(f"构建输出{kind}缺少共享读取/进入权限: {path}")
continue
os.chmod(path, before | bits, follow_symlinks=False)
changed += 1
after = stat.S_IMODE(path.stat().st_mode)
if after & bits != bits:
errors.append(f"构建输出{kind}权限修复后仍不满足要求: {path}")
except OSError as exc:
errors.append(f"无法检查或修复构建输出权限 {path}: {exc}")
return {
**payload,
"current_check": {
"passed": not errors,
"repaired": changed > 0,
"changed_count": changed,
"checked_paths": len(paths),
"symlinks_skipped": skipped_symlinks,
},
}
def validate_image_partition_margin(
payload: object,
build: dict[str, object],
@@ -155,6 +313,7 @@ def validate_handoff(
expected_sdk: str | None = None,
expected_build_type: str | None = None,
expected_system_form: str | None = None,
repair_output_permissions: bool = False,
) -> dict[str, object]:
errors: list[str] = []
warnings: list[str] = []
@@ -196,6 +355,8 @@ def validate_handoff(
for field, (actual, expected) in expected_values.items():
if expected is not None and actual != expected:
errors.append(f"{field} 不匹配:期望 {expected!r},实际 {actual!r}")
if repair_output_permissions and expected_project is None:
errors.append("自动修复输出权限时必须提供预期的 BSP 项目名 --project")
if build.get("status") != "build_passed" or build.get("exit_code") != 0:
errors.append("BSP 构建状态不是 build_passed/exit_code=0")
@@ -210,6 +371,16 @@ def validate_handoff(
if not imgs_path.is_dir() or imgs_path.is_symlink():
errors.append(f"imgs_version_path 不存在或不是普通目录: {imgs_path}")
# Never modify BSP output if the handoff identity is already inconsistent.
output_permissions = validate_output_permissions(
validation.get("output_permissions"),
build,
producer,
imgs_path,
errors,
repair=repair_output_permissions and not errors and expected_project == producer.get("project"),
)
verified = 0
verified_artifacts: list[dict[str, object]] = []
seen_relative_paths: set[str] = set()
@@ -297,6 +468,8 @@ def validate_handoff(
"verified_artifact_count": verified,
"artifacts": verified_artifacts,
"image_partition_margin": image_partition_margin,
"output_permissions_path": build.get("output_permissions_path"),
"output_permissions": output_permissions,
"hardware_validation": validation.get("hardware"),
"owner_acceptance": validation.get("owner_acceptance"),
"release": validation.get("release"),
@@ -312,6 +485,11 @@ def main() -> int:
parser.add_argument("--sdk")
parser.add_argument("--build-type")
parser.add_argument("--system-form", choices=("single-os", "dual-os"))
parser.add_argument(
"--repair-output-permissions",
action="store_true",
help="仅对已核对项目身份及证据的本次 imgs 路径补齐 a+rX 权限",
)
args = parser.parse_args()
try:
result = validate_handoff(
@@ -321,6 +499,7 @@ def main() -> int:
args.sdk,
args.build_type,
args.system_form,
repair_output_permissions=args.repair_output_permissions,
)
except (OSError, json.JSONDecodeError) as exc:
result = {"passed": False, "errors": [str(exc)], "warnings": []}
@@ -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
def iter_regular_files(root: Path) -> Iterator[Path]:
"""Yield repository files without following runtime or Git directory links."""
for current, dirnames, filenames in os.walk(root, followlinks=False):
current_path = Path(current)
dirnames[:] = sorted(
name
for name in dirnames
if name != ".git" and not (current_path / name).is_symlink()
)
for name in sorted(filenames):
path = current_path / name
if path.is_symlink() or not path.is_file():
continue
yield path
def probe_path_exists(path: Path) -> tuple[bool, str | None]:
try:
return path.exists(), None
except OSError as exc:
return False, str(exc)
def collect_json(root: Path, errors: list[str]) -> dict[str, Any]:
payloads: dict[str, Any] = {}
for path in sorted(root.rglob("*.json")):
if ".git" in path.parts:
for path in iter_regular_files(root):
if path.suffix.lower() != ".json":
continue
relative = path.relative_to(root).as_posix()
try:
@@ -102,8 +125,8 @@ def collect_json(root: Path, errors: list[str]) -> dict[str, Any]:
def validate_ini(root: Path, errors: list[str]) -> int:
count = 0
for path in sorted(root.rglob("*.ini")):
if ".git" in path.parts:
for path in iter_regular_files(root):
if path.suffix.lower() != ".ini":
continue
parser = configparser.ConfigParser(interpolation=None, strict=False)
try:
@@ -200,11 +223,9 @@ def validate_sensor_baseline(
errors.append("非 CV181X/CV184X 项目不应提供 CV18 Sensor 枚举校验参数")
return result
candidates = sorted(
path
for path in psp_root.rglob("sensor_cfg.ini")
if ".git" not in path.parts and path.is_file() and not path.is_symlink()
)
candidates = [
path for path in iter_regular_files(psp_root) if path.name == "sensor_cfg.ini"
]
if len(candidates) != 1:
errors.append(
f"CV18 PSP 必须唯一定位一个 sensor_cfg.ini,实际 {len(candidates)} 个"
@@ -320,8 +341,8 @@ def validate_sensor_baseline(
def validate_shell(root: Path, errors: list[str]) -> int:
count = 0
for path in sorted(root.rglob("*.sh")):
if ".git" in path.parts:
for path in iter_regular_files(root):
if path.suffix.lower() != ".sh":
continue
result = subprocess.run(
["bash", "-n", str(path)],
@@ -346,12 +367,9 @@ def path_risk(path: Path) -> str | None:
def text_absolute_paths(root: Path) -> Iterator[tuple[str, str, str]]:
for path in sorted(root.rglob("*")):
for path in iter_regular_files(root):
if (
not path.is_file()
or path.is_symlink()
or ".git" in path.parts
or path.suffix.lower() not in TEXT_PATH_SUFFIXES
path.suffix.lower() not in TEXT_PATH_SUFFIXES
or path.stat().st_size > 4 * 1024 * 1024
):
continue
@@ -409,7 +427,7 @@ def validate_absolute_paths(
continue
seen.add(key)
path = Path(value).resolve(strict=False)
exists = path.exists()
exists, access_error = probe_path_exists(path)
allowed_by = next((item for item in normalized_allowed if under(path, item)), None)
risk_root = None if allowed_by is not None else path_risk(path)
build_input = source in BUILD_INPUT_JSONS
@@ -419,12 +437,19 @@ def validate_absolute_paths(
"path": value,
"resolved_path": str(path),
"exists": exists,
"access_error": access_error,
"build_input": build_input,
"allowed_by": str(allowed_by) if allowed_by else None,
"personal_or_temporary_root": risk_root,
}
entries.append(entry)
if not exists:
if access_error:
message = (
f"外部绝对路径无法在当前主机检查 {source}:{location}: "
f"{value}: {access_error}"
)
(errors if build_input else warnings).append(message)
elif not exists:
message = f"外部绝对路径不存在 {source}:{location}: {value}"
if build_input:
errors.append(f"外部绝对路径不存在 {source}:{location}: {value}")
@@ -503,7 +528,7 @@ def scan_overrides(
if not psp_dir.is_dir():
errors.append(f"PSP 配置目录不存在: {psp_dir}")
continue
for app_path in sorted(path for path in app_dir.rglob("*") if path.is_file()):
for app_path in iter_regular_files(app_dir):
relative = app_path.relative_to(app_dir)
psp_path = psp_dir / relative
if not psp_path.is_file():
@@ -533,6 +558,10 @@ def scan_overrides(
def build_bsp_lock(dependency: dict[str, object], warnings: list[str]) -> dict[str, object]:
permissions = dependency.get("output_permissions")
permissions = permissions if isinstance(permissions, dict) else {}
live_check = permissions.get("current_check")
live_check = live_check if isinstance(live_check, dict) else {}
return {
"schema_version": 1,
"handoff_path": dependency.get("manifest_path"),
@@ -548,6 +577,13 @@ def build_bsp_lock(dependency: dict[str, object], warnings: list[str]) -> dict[s
"imgs_version_path": dependency.get("imgs_version_path"),
"artifacts": dependency.get("artifacts", []),
"image_partition_margin": dependency.get("image_partition_margin"),
"output_permissions": {
"policy": permissions.get("policy"),
"evidence_path": dependency.get("output_permissions_path"),
"output_root": permissions.get("output_root"),
"imgs_version_path": permissions.get("imgs_version_path"),
"passed": live_check.get("passed") is True,
},
"mixed_build": None,
"warnings": warnings,
}
@@ -736,6 +772,7 @@ def validate_psp(
expected_sdk=expected_bsp_sdk,
expected_build_type=expected_build_type,
expected_system_form=expected_system_form,
repair_output_permissions=True,
)
if bsp_result.get("passed") is not True:
errors.extend(f"BSP handoff: {item}" for item in bsp_result.get("errors", []))