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

@@ -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", []))