feat: 发布固件底层开发 Skill 套件

This commit is contained in:
tangfahong committed 2026-09-11 15:06:55 +08:00
commit c7ae3b4ce1
72 files changed
+10212

No files matched your search

@@ -0,0 +1,838 @@
#!/usr/bin/env python3
"""Safely adapt a reference PSP into a pre-created project repository."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import shutil
import stat
import subprocess
import sys
import tempfile
import uuid
from datetime import datetime, timezone
from pathlib import Path, PurePosixPath
EXCLUDED_DIR_NAMES = {
".git",
".cache",
".mypy_cache",
".pytest_cache",
".ruff_cache",
"__pycache__",
"build",
"logs",
"out",
"output",
"temp",
"tmp",
}
EXCLUDED_FILE_NAMES = {".git", ".DS_Store", "psp.lock.json"}
EXCLUDED_FILE_SUFFIXES = {".log", ".pyc", ".tmp"}
CREDENTIAL_FILE_NAMES = {
".env",
"credentials.json",
"credentials.yaml",
"credentials.yml",
"id_ed25519",
"id_rsa",
}
CREDENTIAL_FILE_SUFFIXES = {".key", ".p12", ".pem", ".pfx"}
NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
DEFAULT_PSP_ROOT = Path.home() / "work/project/EMX2.1/AppTeam/Psp"
class GuardError(RuntimeError):
"""A safety or validation gate failed."""
def now_utc() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for block in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def write_json_atomic(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, prefix=f".{path.name}.", 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 load_json(path: Path) -> dict[str, object]:
payload = json.loads(path.expanduser().read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise GuardError(f"JSON 根节点必须是对象: {path}")
return payload
def ordinary_directory(path: Path, field: str, must_exist: bool = True) -> Path:
path = path.expanduser().absolute()
if must_exist:
if not path.is_dir() or path.is_symlink():
raise GuardError(f"{field} 必须是存在的普通目录: {path}")
return path.resolve()
if path.exists() or path.is_symlink():
raise GuardError(f"{field} 已存在,禁止覆盖: {path}")
if not path.parent.is_dir() or path.parent.is_symlink():
raise GuardError(f"{field} 的父目录不存在或不是普通目录: {path.parent}")
return path
def psp_root_directory(path: Path) -> Path:
return ordinary_directory(path, "psp-root")
def project_directory(path: Path, root: Path, field: str) -> Path:
project = ordinary_directory(path, field)
if not NAME_RE.fullmatch(project.name):
raise GuardError(f"{field} 目录名非法: {project.name!r}")
if project.parent != root:
raise GuardError(f"{field} 必须是 PSP 固定根目录的直接子目录: {root}")
return project
def target_payload_entries(path: Path) -> list[str]:
"""Return every top-level entry other than the repository metadata."""
return sorted(entry.name for entry in path.iterdir() if entry.name != ".git")
def is_excluded(path: Path, is_dir: bool) -> bool:
if is_dir:
return path.name in EXCLUDED_DIR_NAMES
return path.name in EXCLUDED_FILE_NAMES or path.suffix.lower() in EXCLUDED_FILE_SUFFIXES
def copy_ignore(directory: str, names: list[str]) -> set[str]:
base = Path(directory)
return {name for name in names if is_excluded(base / name, (base / name).is_dir())}
def excluded_paths(root: Path) -> list[str]:
hits: list[str] = []
for current, dirs, files in os.walk(root, followlinks=False):
base = Path(current)
kept: list[str] = []
for name in sorted(dirs):
path = base / name
if is_excluded(path, True):
hits.append(path.relative_to(root).as_posix() + "/")
elif path.is_symlink():
kept.append(name)
else:
kept.append(name)
dirs[:] = kept
for name in sorted(files):
path = base / name
if is_excluded(path, False):
hits.append(path.relative_to(root).as_posix())
return sorted(hits)
def scan_symlinks(root: Path) -> list[str]:
links: list[str] = []
for current, dirs, files in os.walk(root, followlinks=False):
base = Path(current)
kept: list[str] = []
for name in sorted(dirs):
path = base / name
if is_excluded(path, True):
continue
if path.is_symlink():
links.append(path.relative_to(root).as_posix())
else:
kept.append(name)
dirs[:] = kept
for name in sorted(files):
path = base / name
if path.is_symlink() and not is_excluded(path, False):
links.append(path.relative_to(root).as_posix())
return sorted(links)
def scan_credentials(root: Path) -> list[str]:
hits: list[str] = []
for current, dirs, files in os.walk(root, followlinks=False):
base = Path(current)
dirs[:] = [
name
for name in dirs
if not is_excluded(base / name, True) and not (base / name).is_symlink()
]
for name in files:
path = base / name
if path.is_symlink() or is_excluded(path, False):
continue
if name in CREDENTIAL_FILE_NAMES or path.suffix.lower() in CREDENTIAL_FILE_SUFFIXES:
hits.append(path.relative_to(root).as_posix())
return sorted(hits)
def inventory(root: Path) -> dict[str, dict[str, object]]:
entries: dict[str, dict[str, object]] = {}
for current, dirs, files in os.walk(root, followlinks=False):
base = Path(current)
kept: list[str] = []
for name in sorted(dirs):
path = base / name
if is_excluded(path, True):
continue
if path.is_symlink():
rel = path.relative_to(root).as_posix()
entries[rel] = {"kind": "symlink", "target": os.readlink(path)}
else:
kept.append(name)
dirs[:] = kept
for name in sorted(files):
path = base / name
if is_excluded(path, False):
continue
rel = path.relative_to(root).as_posix()
if path.is_symlink():
entries[rel] = {"kind": "symlink", "target": os.readlink(path)}
elif path.is_file():
item = path.stat()
entries[rel] = {
"kind": "file",
"mode": stat.S_IMODE(item.st_mode),
"size": item.st_size,
"sha256": sha256_file(path),
}
return dict(sorted(entries.items()))
def run_git(path: Path, *args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", "-C", str(path), *args],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
def excluded_git_status_line(line: str) -> bool:
value = line[3:] if len(line) > 3 else line
if " -> " in value:
value = value.split(" -> ", 1)[1]
value = value.strip().strip('"')
pure = PurePosixPath(value)
return any(part in EXCLUDED_DIR_NAMES for part in pure.parts) or (
pure.name in EXCLUDED_FILE_NAMES
or PurePosixPath(pure.name).suffix.lower() in EXCLUDED_FILE_SUFFIXES
)
def git_state(path: Path) -> dict[str, object]:
top = run_git(path, "rev-parse", "--show-toplevel")
if top.returncode != 0:
return {"tracked": False, "repository_root": None, "commit": None, "dirty": None}
status = run_git(path, "status", "--porcelain", "--untracked-files=all", "--", ".")
if status.returncode != 0:
raise GuardError(f"无法读取 PSP Git 状态: {status.stderr}")
commit = run_git(path, "rev-parse", "--verify", "HEAD")
branch = run_git(path, "symbolic-ref", "--quiet", "--short", "HEAD")
git_dir = run_git(path, "rev-parse", "--absolute-git-dir")
if git_dir.returncode != 0:
raise GuardError(f"无法读取 PSP Git 元数据目录: {git_dir.stderr}")
all_lines = [line for line in status.stdout.splitlines() if line.strip()]
lines = [line for line in all_lines if not excluded_git_status_line(line)]
return {
"tracked": True,
"repository_root": str(Path(top.stdout.strip()).resolve()),
"git_dir": str(Path(git_dir.stdout.strip()).resolve()),
"commit": commit.stdout.strip() if commit.returncode == 0 else None,
"unborn": commit.returncode != 0,
"branch": branch.stdout.strip() if branch.returncode == 0 else None,
"dirty": bool(lines),
"status": lines,
"ignored_generated_status": [
line for line in all_lines if excluded_git_status_line(line)
],
}
def repository_identity(path: Path, state: dict[str, object] | None = None) -> dict[str, object]:
state = git_state(path) if state is None else state
if state.get("tracked") is not True:
raise GuardError(f"目标 PSP 必须是预先创建的独立 Git 仓库: {path}")
expected_git_dir = path / ".git"
git_dir = Path(str(state.get("git_dir"))).resolve()
if state.get("repository_root") != str(path) or git_dir != expected_git_dir:
raise GuardError(f"目标 PSP 必须以自身目录和 .git 作为独立 Git 仓库: {path}")
if not expected_git_dir.is_dir() or expected_git_dir.is_symlink():
raise GuardError(f"目标 PSP 的 .git 必须是普通目录: {expected_git_dir}")
config = expected_git_dir / "config"
head = expected_git_dir / "HEAD"
if not config.is_file() or config.is_symlink() or not head.is_file() or head.is_symlink():
raise GuardError(f"目标 PSP Git config/HEAD 缺失或不是普通文件: {path}")
remotes: dict[str, list[dict[str, str]]] = {}
remote_names = run_git(path, "remote")
if remote_names.returncode != 0:
raise GuardError(f"无法读取目标 PSP Git 远端: {remote_names.stderr}")
for name in sorted(line for line in remote_names.stdout.splitlines() if line):
urls = run_git(path, "remote", "get-url", "--all", name)
if urls.returncode != 0:
raise GuardError(f"无法读取目标 PSP Git 远端 {name}: {urls.stderr}")
remotes[name] = [
{"sha256": hashlib.sha256(url.encode("utf-8")).hexdigest()}
for url in urls.stdout.splitlines()
]
return {
"repository_root": str(path),
"git_dir": str(expected_git_dir),
"commit": state.get("commit"),
"unborn": state.get("unborn"),
"branch": state.get("branch"),
"config_sha256": sha256_file(config),
"head_sha256": sha256_file(head),
"remotes": remotes,
}
def copy_committed_head(reference: Path, staging: Path, state: dict[str, object]) -> None:
repository_value = state.get("repository_root")
commit = state.get("commit")
if not isinstance(repository_value, str) or not isinstance(commit, str):
raise GuardError("参考 PSP 缺少可追踪的 Git HEAD")
repository = Path(repository_value)
try:
prefix = reference.relative_to(repository)
except ValueError as exc:
raise GuardError("参考 PSP 不在其 Git repository_root 内") from exc
command = ["git", "-C", str(repository), "ls-tree", "-r", "-z", commit]
if prefix.parts:
command.extend(["--", prefix.as_posix()])
listing = subprocess.run(
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False
)
if listing.returncode != 0:
raise GuardError(f"无法读取参考 PSP HEAD 清单: {listing.stderr.decode(errors='replace')}")
staging.mkdir()
entries = [item for item in listing.stdout.split(b"\0") if item]
for raw in entries:
try:
metadata, raw_path = raw.split(b"\t", 1)
mode, kind, _object_id = metadata.decode("ascii").split(" ", 2)
repository_path = PurePosixPath(raw_path.decode("utf-8"))
except (ValueError, UnicodeDecodeError) as exc:
raise GuardError("参考 PSP Git tree 含无法解析的条目") from exc
if kind != "blob" or mode not in {"100644", "100755"}:
raise GuardError(
f"参考 PSP HEAD 含不支持的 symlink/submodule/特殊条目: {repository_path}"
)
if prefix.parts:
try:
relative = repository_path.relative_to(PurePosixPath(prefix.as_posix()))
except ValueError as exc:
raise GuardError(f"Git tree 条目逃逸参考 PSP: {repository_path}") from exc
else:
relative = repository_path
relative_path = Path(relative.as_posix())
if any(part in EXCLUDED_DIR_NAMES for part in relative.parts) or is_excluded(
relative_path, False
):
continue
destination = staging / relative_path
destination.parent.mkdir(parents=True, exist_ok=True)
content = subprocess.run(
["git", "-C", str(repository), "show", f"{commit}:{repository_path.as_posix()}"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
if content.returncode != 0:
raise GuardError(
f"无法读取参考 PSP HEAD 文件 {repository_path}: "
f"{content.stderr.decode(errors='replace')}"
)
destination.write_bytes(content.stdout)
destination.chmod(0o755 if mode == "100755" else 0o644)
def reference_matches_manifest(reference: Path, manifest: dict[str, object]) -> bool:
mode = manifest.get("reference_baseline")
if mode == "committed-head":
expected = manifest.get("reference_git")
if not isinstance(expected, dict):
return False
current = git_state(reference)
return current.get("commit") == expected.get("commit")
source = manifest.get("source_inventory")
return isinstance(source, dict) and inventory(reference) == source
def normalize_paths(values: list[str], field: str) -> set[str]:
normalized: set[str] = set()
for value in values:
pure = PurePosixPath(value)
if pure.is_absolute() or ".." in pure.parts or value in {"", "."}:
raise GuardError(f"{field} 必须是目录内精确相对路径: {value!r}")
if any(character in value for character in "*?[]"):
raise GuardError(f"{field} 不允许通配符: {value!r}")
normalized.add(pure.as_posix())
return normalized
def compare(
source: dict[str, dict[str, object]], candidate: dict[str, dict[str, object]]
) -> dict[str, list[str]]:
source_paths = set(source)
candidate_paths = set(candidate)
return {
"added": sorted(candidate_paths - source_paths),
"removed": sorted(source_paths - candidate_paths),
"modified": sorted(
path for path in source_paths & candidate_paths if source[path] != candidate[path]
),
}
def read_text_for_scan(path: Path) -> str | None:
if path.stat().st_size > 4 * 1024 * 1024:
return None
data = path.read_bytes()
if b"\x00" in data:
return None
try:
return data.decode("utf-8")
except UnicodeDecodeError:
return None
def scan_residues(root: Path, tokens: list[str], allowed_paths: set[str]) -> list[dict[str, object]]:
hits: list[dict[str, object]] = []
lowered = [(token, token.casefold()) for token in tokens if token]
if not lowered:
return hits
for relative, item in inventory(root).items():
if item.get("kind") != "file" or relative in allowed_paths:
continue
path = root / relative
text = read_text_for_scan(path)
if text is None:
continue
folded = text.casefold()
for token, token_folded in lowered:
count = folded.count(token_folded)
if count:
hits.append({"path": relative, "token": token, "count": count})
return hits
def inspect_project(target: Path, psp_root: Path = DEFAULT_PSP_ROOT) -> dict[str, object]:
psp_root = psp_root_directory(psp_root)
target = project_directory(target, psp_root, "target")
state = git_state(target)
identity = repository_identity(target, state)
entries = target_payload_entries(target)
return {
"passed": True,
"checked_at": now_utc(),
"psp_root": str(psp_root),
"target": str(target),
"project": target.name,
"mode": "existing-project" if entries else "precreated-empty-repository",
"payload_entries": entries,
"git": state,
"repository_identity": identity,
}
def prepare(
reference: Path,
target: Path,
manifest_path: Path,
old_tokens: list[str],
include_dirty: bool,
psp_root: Path = DEFAULT_PSP_ROOT,
) -> dict[str, object]:
psp_root = psp_root_directory(psp_root)
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:
raise GuardError("reference、target 不能互相包含")
target_git = git_state(target)
target_repository = repository_identity(target, target_git)
payload_entries = target_payload_entries(target)
if payload_entries:
raise GuardError(
"目标 PSP 已有业务内容,禁止按新项目参考复制流程覆盖;"
f"请转入现有项目更新流程: {', '.join(payload_entries)}"
)
if target_git.get("dirty") is True:
raise GuardError("目标 PSP 工作区不干净,禁止导入参考内容")
manifest_path = manifest_path.expanduser().absolute()
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)
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:
copy_committed_head(reference, staging, source_git)
baseline_mode = "committed-head"
warnings = ["参考 PSP 工作区有未提交内容;staging 使用已提交 HEAD,不包含这些修改"]
else:
source_before_copy = inventory(reference)
shutil.copytree(reference, staging, copy_function=shutil.copy2, ignore=copy_ignore)
if source_before_copy != inventory(reference):
raise GuardError(f"复制期间参考 PSP 发生变化;staging 保留: {staging}")
baseline_mode = "worktree"
warnings = []
staging_inventory = inventory(staging)
if baseline_mode == "worktree" and staging_inventory != source_before_copy:
raise GuardError(f"staging 与过滤后的参考清单不一致;staging 保留: {staging}")
source_before = staging_inventory
payload: dict[str, object] = {
"schema_version": 2,
"created_at": now_utc(),
"psp_root": str(psp_root),
"reference": str(reference),
"target": str(target),
"staging": str(staging),
"target_mode": "precreated-empty-repository",
"target_repository_before_import": target_repository,
"reference_git": source_git,
"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),
"source_inventory": source_before,
"staging_inventory_before_edits": staging_inventory,
"warnings": warnings,
}
write_json_atomic(manifest_path, payload)
return {
"passed": True,
"reference": str(reference),
"target": str(target),
"staging": str(staging),
"psp_root": str(psp_root),
"target_mode": payload["target_mode"],
"target_repository": target_repository,
"manifest": str(manifest_path),
"reference_git": source_git,
"excluded_paths": payload["excluded_paths"],
"warnings": warnings,
}
def verify(
manifest_path: Path,
allow_paths: list[str],
allowed_residues: list[str],
report_path: Path | None,
) -> 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")
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):
raise GuardError("prepare 后参考 PSP 发生变化;必须重新 prepare")
links = scan_symlinks(staging)
credentials = scan_credentials(staging)
unexpected_excluded = excluded_paths(staging)
allowed = normalize_paths(allow_paths, "allow-path")
residue_allow = normalize_paths(allowed_residues, "allow-residue")
candidate_inventory = inventory(staging)
changes = compare(source, candidate_inventory)
violations = sorted(
path for group in changes.values() for path in group if path not in allowed
)
old_tokens = manifest.get("old_tokens")
if not isinstance(old_tokens, list) or not all(isinstance(item, str) for item in old_tokens):
raise GuardError("manifest.old_tokens 非法")
residues = scan_residues(staging, old_tokens, residue_allow)
errors: list[str] = []
if links:
errors.append("staging 包含软链接")
if credentials:
errors.append("staging 包含疑似凭据")
if unexpected_excluded:
errors.append("staging 包含缓存、构建输出、日志或临时文件")
if violations:
errors.append("存在未列入精确差异白名单的修改")
if residues:
errors.append("存在未确认的参考项目残留")
report: dict[str, object] = {
"schema_version": 1,
"checked_at": now_utc(),
"manifest": str(manifest_path),
"reference": str(reference),
"staging": str(staging),
"target": manifest.get("target"),
"allowed_paths": sorted(allowed),
"allowed_residue_paths": sorted(residue_allow),
"changes": changes,
"violations": violations,
"residues": residues,
"symlinks": links,
"credential_files": credentials,
"unexpected_excluded_paths": unexpected_excluded,
"verified_inventory": candidate_inventory,
"errors": errors,
"passed": not errors,
}
if report_path is not None:
write_json_atomic(report_path, report)
return report
def populate_precreated_repository(staging: Path, target: Path) -> None:
children = sorted(staging.iterdir(), key=lambda path: path.name)
collisions = [child.name for child in children if (target / child.name).exists()]
collisions.extend(
child.name for child in children if (target / child.name).is_symlink()
)
if collisions:
raise GuardError(
"目标 PSP 与 staging 存在路径冲突: "
+ ", ".join(sorted(set(collisions)))
)
created: list[Path] = []
try:
for source in children:
destination = target / source.name
if source.is_dir():
shutil.copytree(source, destination, copy_function=shutil.copy2)
else:
shutil.copy2(source, destination)
created.append(destination)
except OSError:
for path in reversed(created):
if path.is_dir() and not path.is_symlink():
shutil.rmtree(path)
elif path.exists() or path.is_symlink():
path.unlink()
raise
def finalize(manifest_path: Path, verify_report_path: Path) -> dict[str, object]:
manifest_path = manifest_path.expanduser().resolve(strict=True)
verify_report_path = verify_report_path.expanduser().resolve(strict=True)
manifest = load_json(manifest_path)
report = load_json(verify_report_path)
if report.get("passed") is not True:
raise GuardError("verify 报告未通过,禁止 finalize")
if report.get("manifest") != str(manifest_path):
raise GuardError("verify 报告不属于当前 manifest")
staging = ordinary_directory(Path(str(manifest.get("staging"))), "staging")
root_value = manifest.get("psp_root")
if not isinstance(root_value, str):
raise GuardError("manifest 缺少 psp_root")
psp_root = psp_root_directory(Path(root_value))
target = project_directory(Path(str(manifest.get("target"))), psp_root, "target")
expected_repository = manifest.get("target_repository_before_import")
if not isinstance(expected_repository, dict):
raise GuardError("manifest 缺少 target_repository_before_import")
current_repository = repository_identity(target)
if current_repository != expected_repository:
raise GuardError("prepare 后目标 PSP 的 Git 分支、HEAD、远端或配置已变化")
payload_entries = target_payload_entries(target)
if payload_entries:
raise GuardError(
"prepare 后目标 PSP 出现业务内容,禁止覆盖: " + ", ".join(payload_entries)
)
verified_inventory = report.get("verified_inventory")
if not isinstance(verified_inventory, dict) or inventory(staging) != verified_inventory:
raise GuardError("verify 后 staging 已变化;必须重新 verify")
populate_precreated_repository(staging, target)
if repository_identity(target) != expected_repository:
raise GuardError("导入内容时目标 PSP Git 元数据发生变化")
shutil.rmtree(staging)
return {
"passed": True,
"finalized_at": now_utc(),
"target": str(target),
"target_mode": manifest.get("target_mode"),
"repository_preserved": True,
"target_repository": expected_repository,
"manifest": str(manifest_path),
"verify_report": str(verify_report_path),
}
def audit_final(
manifest_path: Path,
allow_paths: list[str],
allowed_residues: list[str],
report_path: Path | None,
) -> dict[str, object]:
manifest_path = manifest_path.expanduser().resolve(strict=True)
manifest = load_json(manifest_path)
root_value = manifest.get("psp_root")
if not isinstance(root_value, str):
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")
source = manifest.get("source_inventory")
if not isinstance(source, dict):
raise GuardError("manifest 缺少 source_inventory")
allowed = normalize_paths(allow_paths, "allow-path")
residue_allow = normalize_paths(allowed_residues, "allow-residue")
target_inventory = inventory(target)
changes = compare(source, target_inventory)
violations = sorted(
path for group in changes.values() for path in group if path not in allowed
)
old_tokens = manifest.get("old_tokens")
if not isinstance(old_tokens, list) or not all(isinstance(item, str) for item in old_tokens):
raise GuardError("manifest.old_tokens 非法")
residues = scan_residues(target, old_tokens, residue_allow)
links = scan_symlinks(target)
credentials = scan_credentials(target)
unexpected_excluded = [path for path in excluded_paths(target) if path != ".git/"]
expected_repository = manifest.get("target_repository_before_import")
repository_unchanged = isinstance(expected_repository, dict) and (
repository_identity(target) == expected_repository
)
errors: list[str] = []
if violations:
errors.append("正式 PSP 存在未列入精确差异白名单的修改")
if residues:
errors.append("正式 PSP 存在未确认的参考项目残留")
if links:
errors.append("正式 PSP 包含软链接")
if credentials:
errors.append("正式 PSP 包含疑似凭据")
if unexpected_excluded:
errors.append("正式 PSP 包含缓存、构建输出、日志或临时文件")
if not repository_unchanged:
errors.append("正式 PSP 的 Git 分支、HEAD、远端或配置偏离 prepare 基线")
report: dict[str, object] = {
"schema_version": 1,
"checked_at": now_utc(),
"manifest": str(manifest_path),
"reference": str(reference),
"reference_drifted_since_prepare": not reference_matches_manifest(reference, manifest),
"target": str(target),
"allowed_paths": sorted(allowed),
"allowed_residue_paths": sorted(residue_allow),
"changes": changes,
"violations": violations,
"residues": residues,
"symlinks": links,
"credential_files": credentials,
"unexpected_excluded_paths": unexpected_excluded,
"repository_unchanged": repository_unchanged,
"target_repository": expected_repository,
"errors": errors,
"passed": not errors,
"audited_inventory": target_inventory,
}
if report_path is not None:
write_json_atomic(report_path, report)
return report
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
subparsers = parser.add_subparsers(dest="command", required=True)
inspect_parser = subparsers.add_parser(
"inspect", help="只读校验固定根目录下的预创建项目仓库"
)
inspect_parser.add_argument(
"--psp-root",
type=Path,
default=DEFAULT_PSP_ROOT,
help=f"PSP 固定根目录(默认: {DEFAULT_PSP_ROOT})",
)
inspect_parser.add_argument("--target", type=Path, required=True)
prepare_parser = subparsers.add_parser("prepare", help="复制参考 PSP 到唯一 staging")
prepare_parser.add_argument(
"--psp-root",
type=Path,
default=DEFAULT_PSP_ROOT,
help=f"PSP 固定根目录(默认: {DEFAULT_PSP_ROOT})",
)
prepare_parser.add_argument("--reference", type=Path, required=True)
prepare_parser.add_argument("--target", type=Path, required=True)
prepare_parser.add_argument("--manifest", type=Path, required=True)
prepare_parser.add_argument("--old-token", action="append", default=[])
prepare_parser.add_argument("--include-dirty", action="store_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=[])
verify_parser.add_argument("--allow-residue", action="append", default=[])
verify_parser.add_argument("--report", type=Path)
finalize_parser = subparsers.add_parser(
"finalize", help="把已验证 staging 导入预创建的空项目仓库"
)
finalize_parser.add_argument("--manifest", type=Path, required=True)
finalize_parser.add_argument("--verify-report", type=Path, required=True)
audit_parser = subparsers.add_parser("audit-final", help="按 prepare 基线复核正式 PSP")
audit_parser.add_argument("--manifest", type=Path, required=True)
audit_parser.add_argument("--allow-path", action="append", default=[])
audit_parser.add_argument("--allow-residue", action="append", default=[])
audit_parser.add_argument("--report", type=Path)
return parser
def main() -> int:
args = build_parser().parse_args()
try:
if args.command == "inspect":
result = inspect_project(args.target, args.psp_root)
elif args.command == "prepare":
result = prepare(
args.reference,
args.target,
args.manifest,
args.old_token,
args.include_dirty,
args.psp_root,
)
elif args.command == "verify":
result = verify(args.manifest, args.allow_path, args.allow_residue, args.report)
elif args.command == "finalize":
result = finalize(args.manifest, args.verify_report)
elif args.command == "audit-final":
result = audit_final(
args.manifest, args.allow_path, args.allow_residue, args.report
)
else:
raise GuardError(f"未知命令: {args.command}")
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0 if result.get("passed") is True else 2
except (GuardError, OSError, 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,844 @@
#!/usr/bin/env python3
"""检查 EMX2.1 PSP 固定组包流程并记录精确产物。"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import subprocess
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from pathlib import PurePosixPath
from typing import Any
PRODUCT_COMMAND = "./product.py"
RELEASE_COMMANDS = {"./release.py 0", "./release.py 1"}
MAX_LOG_SIZE = 64 * 1024 * 1024
ANSI_RED_RE = re.compile(r"\x1b\[(?:1;)?91m")
ANSI_YELLOW_RE = re.compile(r"\x1b\[(?:1;)?93m")
FAILURE_RE = re.compile(
r"(?:^|\s)(?:Traceback|FATAL|FAILED|ERROR:|Error:|No such file|cannot stat)(?:\s|$)",
re.IGNORECASE,
)
WARNING_RE = re.compile(r"(?:^|\s)(?:WARNING:|Warning:|WARN:)(?:\s|$)", re.IGNORECASE)
class PackagingError(RuntimeError):
"""组包证据缺失、过期或不一致。"""
def now_utc() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for block in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def write_json_new(path: Path, payload: dict[str, object]) -> None:
path = path.expanduser().resolve(strict=False)
if path.exists() or path.is_symlink():
raise PackagingError(f"证据文件已存在,禁止覆盖: {path}")
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
"w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", 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 load_json(path: Path) -> dict[str, Any]:
payload = json.loads(path.expanduser().read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise PackagingError(f"JSON 根节点必须是对象: {path}")
return payload
def exact_absolute_path(value: object, field: str) -> Path:
if not isinstance(value, str) or not value.strip():
raise PackagingError(f"打包契约 {field} 必须是非空绝对路径")
path = Path(value).expanduser()
if not path.is_absolute():
raise PackagingError(f"打包契约 {field} 必须是绝对路径: {value!r}")
return path.resolve(strict=False)
def exact_directory(path: Path, field: str) -> Path:
expanded = path.expanduser()
if expanded.is_symlink():
raise PackagingError(f"{field} 不允许使用软链接: {expanded}")
resolved = expanded.resolve(strict=True)
if not resolved.is_dir():
raise PackagingError(f"{field} 必须是存在的普通目录: {resolved}")
return resolved
def exact_file(path: Path, field: str) -> Path:
expanded = path.expanduser()
if expanded.is_symlink():
raise PackagingError(f"{field} 不允许使用软链接: {expanded}")
resolved = expanded.resolve(strict=True)
if not resolved.is_file():
raise PackagingError(f"{field} 必须是存在的普通文件: {resolved}")
return resolved
def require_string_list(payload: dict[str, Any], field: str) -> list[str]:
value = payload.get(field)
if (
not isinstance(value, list)
or not value
or not all(isinstance(item, str) and item.strip() for item in value)
):
raise PackagingError(f"打包契约 {field} 必须是非空字符串数组")
return list(value)
def load_contract(path: Path) -> tuple[Path, dict[str, Any], str]:
path = path.expanduser().resolve(strict=True)
if not path.is_file() or path.is_symlink():
raise PackagingError(f"打包契约必须是普通文件: {path}")
payload = load_json(path)
if payload.get("schema_version") != 2:
raise PackagingError("打包契约 schema_version 必须为 2")
if payload.get("contract_type") != "emx21-project-packaging":
raise PackagingError(
"打包契约 contract_type 必须为 emx21-project-packaging"
)
if not isinstance(payload.get("contract_id"), str) or not payload["contract_id"].strip():
raise PackagingError("打包契约 contract_id 不能为空")
require_string_list(payload, "chips")
if payload.get("product_command") != PRODUCT_COMMAND:
raise PackagingError(f"product_command 必须精确为 {PRODUCT_COMMAND}")
release_commands = require_string_list(payload, "release_commands")
if set(release_commands) != RELEASE_COMMANDS:
raise PackagingError("release_commands 必须精确包含 ./release.py 0 和 ./release.py 1")
exit_codes = payload.get("success_exit_codes")
if (
not isinstance(exit_codes, list)
or not exit_codes
or not all(isinstance(item, int) and not isinstance(item, bool) for item in exit_codes)
):
raise PackagingError("打包契约 success_exit_codes 必须是非空整数数组")
paths = {
field: exact_absolute_path(payload.get(field), field)
for field in ("project_root", "psp_path", "working_directory", "output_root")
}
if paths["working_directory"] != paths["project_root"] / "scripts":
raise PackagingError("working_directory 必须精确为 <project_root>/scripts")
if paths["psp_path"] != paths["project_root"] / "psp":
raise PackagingError("psp_path 必须精确为 <project_root>/psp")
if paths["output_root"] != paths["project_root"] / "output":
raise PackagingError("output_root 必须精确为 <project_root>/output")
return path, payload, sha256_file(path)
def run_git(path: Path, *args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", "-C", str(path), *args],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
def git_state(path: Path) -> dict[str, object]:
path = exact_directory(path, "Git 路径")
top = run_git(path, "rev-parse", "--show-toplevel")
if top.returncode != 0:
return {"tracked": False, "path": str(path), "commit": None, "dirty": None}
commit = run_git(path, "rev-parse", "HEAD")
status = run_git(path, "status", "--porcelain", "--untracked-files=all", "--", ".")
if commit.returncode != 0 or status.returncode != 0:
raise PackagingError(f"无法读取 Git 状态: {commit.stderr or status.stderr}")
lines = [line for line in status.stdout.splitlines() if line.strip()]
return {
"tracked": True,
"path": str(path),
"repository_root": str(Path(top.stdout.strip()).resolve()),
"commit": commit.stdout.strip(),
"dirty": bool(lines),
"status": lines,
}
def version_state(path: Path) -> dict[str, object]:
if not path.is_dir() or path.is_symlink():
raise PackagingError(f"output 的直接子项必须是普通版本目录: {path}")
files: list[dict[str, object]] = []
tree = hashlib.sha256()
for item in sorted(path.rglob("*")):
if item.is_symlink():
raise PackagingError(f"版本输出中不允许软链接: {item}")
if not item.is_file():
continue
relative = item.relative_to(path).as_posix()
digest = sha256_file(item)
size = item.stat().st_size
files.append(
{"relative_path": relative, "absolute_path": str(item), "size": size, "sha256": digest}
)
tree.update(relative.encode("utf-8"))
tree.update(b"\0")
tree.update(str(size).encode("ascii"))
tree.update(b"\0")
tree.update(digest.encode("ascii"))
tree.update(b"\n")
return {
"name": path.name,
"path": str(path),
"tree_sha256": tree.hexdigest(),
"file_count": len(files),
"total_size": sum(int(item["size"]) for item in files),
"files": files,
}
def output_state(output_root: Path) -> dict[str, object]:
output_root = output_root.expanduser().resolve(strict=False)
if not output_root.exists():
return {"path": str(output_root), "exists": False, "versions": []}
if not output_root.is_dir() or output_root.is_symlink():
raise PackagingError(f"output_root 必须是普通目录: {output_root}")
versions = [version_state(item) for item in sorted(output_root.iterdir())]
return {"path": str(output_root), "exists": True, "versions": versions}
def contract_evidence(path: Path, payload: dict[str, Any], digest: str) -> dict[str, object]:
return {"path": str(path), "sha256": digest, "contract_id": payload["contract_id"]}
def create_contract(project_root: Path, chip: str, destination: Path) -> dict[str, object]:
"""根据团队固定目录和命令生成本地机器契约。"""
project = exact_directory(project_root, "应用工程")
psp = (project / "psp").resolve(strict=False)
if psp.exists() or psp.is_symlink():
psp = exact_directory(psp, "应用工程 psp 子仓库")
scripts = exact_directory(project / "scripts", "应用工程 scripts 目录")
exact_file(scripts / "product.py", "product.py")
exact_file(scripts / "release.py", "release.py")
if not chip.strip():
raise PackagingError("芯片名称不能为空")
payload: dict[str, object] = {
"schema_version": 2,
"contract_type": "emx21-project-packaging",
"contract_id": f"emx21:{project.name}:{chip.strip()}",
"chips": [chip.strip()],
"project_root": str(project),
"psp_path": str(psp),
"working_directory": str(scripts),
"output_root": str((project / "output").resolve(strict=False)),
"product_command": PRODUCT_COMMAND,
"release_commands": sorted(RELEASE_COMMANDS),
"success_exit_codes": [0],
}
write_json_new(destination, payload)
return payload
SIZE_RE = re.compile(r"^(\d+)([KMG])$", re.IGNORECASE)
SIZE_MULTIPLIERS = {"K": 1024, "M": 1024 * 1024, "G": 1024 * 1024 * 1024}
UBI_LEB_SIZE = 126976
def parse_size(value: object, field: str) -> int | None:
if value == "-":
return None
if not isinstance(value, str):
raise PackagingError(f"{field} 必须使用 K、M、G 或 - 表示")
match = SIZE_RE.fullmatch(value.strip())
if match is None:
raise PackagingError(f"{field} 格式非法: {value!r}")
return int(match.group(1)) * SIZE_MULTIPLIERS[match.group(2).upper()]
def safe_destination(value: object, field: str) -> str:
if not isinstance(value, str) or not value.strip():
raise PackagingError(f"{field} 不能为空")
path = PurePosixPath(value)
if path.is_absolute() or ".." in path.parts:
raise PackagingError(f"{field} 必须是工程内安全相对路径: {value!r}")
return path.as_posix()
def packaging_preflight(
contract_path: Path,
destination: Path,
psp_source: Path | None = None,
psp_commit: str | None = None,
formal: bool = False,
) -> dict[str, object]:
"""在 product.py 前检查 PSP 资源、分区和 BSP 证据是否一致。"""
errors: list[str] = []
warnings: list[str] = []
contract_path, contract, digest = load_contract(contract_path)
project = exact_directory(exact_absolute_path(contract["project_root"], "project_root"), "应用工程")
scripts = exact_directory(exact_absolute_path(contract["working_directory"], "working_directory"), "打包脚本目录")
product_script = exact_file(scripts / "product.py", "product.py")
release_script = exact_file(scripts / "release.py", "release.py")
contract_psp = exact_absolute_path(contract["psp_path"], "psp_path")
source = exact_directory(
psp_source or exact_directory(contract_psp, "契约 PSP"), "待检查 PSP"
)
psp_git = git_state(source)
if formal:
if source != contract_psp:
errors.append("正式组包前检查必须使用应用工程中的 psp 子仓库")
if psp_git.get("tracked") is not True or psp_git.get("repository_root") != psp_git.get("path"):
errors.append("正式组包前 PSP 必须是独立 Git 仓库根目录")
if not psp_commit:
errors.append("正式组包前必须提供完整 PSP commit")
elif psp_git.get("commit") != psp_commit:
errors.append(
f"PSP commit 不匹配:期望 {psp_commit!r},实际 {psp_git.get('commit')!r}"
)
if psp_git.get("dirty") is not False:
errors.append("正式组包前 PSP 工作区必须干净")
psp_json_path = exact_file(source / "psp.json", "psp.json")
psp_json = load_json(psp_json_path)
mappings = psp_json.get("EmxResToProduct")
if not isinstance(mappings, list):
errors.append("psp.json.EmxResToProduct 必须是数组")
mappings = []
resolved_mappings: list[dict[str, object]] = []
destinations: dict[str, int] = {}
public_emxres = project / "Public" / "EmxRes"
for index, item in enumerate(mappings):
if not isinstance(item, dict):
errors.append(f"EmxResToProduct[{index}] 必须是对象")
continue
try:
dst = safe_destination(item.get("dst"), f"EmxResToProduct[{index}].dst")
except PackagingError as exc:
errors.append(str(exc))
continue
raw_src = item.get("src")
if not isinstance(raw_src, str) or not raw_src.strip():
errors.append(f"EmxResToProduct[{index}].src 不能为空")
continue
raw_path = Path(raw_src).expanduser()
resolved_src = (
raw_path.resolve(strict=False)
if raw_path.is_absolute()
else (public_emxres / raw_path).resolve(strict=False)
)
if not resolved_src.is_file() or resolved_src.is_symlink():
errors.append(
f"组包输入不存在或不是普通文件 EmxResToProduct[{index}].src: {resolved_src}"
)
if dst in destinations:
errors.append(
f"EmxResToProduct 目标重复: {dst},索引 {destinations[dst]} 和 {index}"
)
else:
destinations[dst] = index
resolved_mappings.append(
{"index": index, "src": raw_src, "resolved_src": str(resolved_src), "dst": dst}
)
partition = psp_json.get("partition")
partition_rows: dict[str, dict[str, object]] = {}
flash_size = None
if not isinstance(partition, dict):
errors.append("psp.json.partition 必须是对象")
else:
try:
flash_size = parse_size(partition.get("flashImgSize"), "partition.flashImgSize")
if flash_size is None:
errors.append("partition.flashImgSize 不能使用 -")
except PackagingError as exc:
errors.append(str(exc))
table = partition.get("partitionTable")
if not isinstance(table, list) or not table:
errors.append("partition.partitionTable 必须是非空数组")
table = []
allocated = 0
remainder_count = 0
for index, item in enumerate(table):
if not isinstance(item, dict):
errors.append(f"partitionTable[{index}] 必须是对象")
continue
name = item.get("name")
if not isinstance(name, str) or not name.strip():
errors.append(f"partitionTable[{index}].name 不能为空")
continue
if name in partition_rows:
errors.append(f"分区名称重复: {name}")
continue
try:
size_bytes = parse_size(item.get("size"), f"partitionTable[{index}].size")
except PackagingError as exc:
errors.append(str(exc))
size_bytes = None
if item.get("size") == "-":
remainder_count += 1
elif size_bytes is not None:
allocated += size_bytes
partition_rows[name] = {
"index": index,
"name": name,
"file": item.get("file"),
"size": item.get("size"),
"size_bytes": size_bytes,
}
if remainder_count > 1:
errors.append("分区表最多只能有一个 size=- 的剩余空间分区")
if flash_size is not None and allocated > flash_size:
errors.append(
f"已分配分区总量 {allocated} 字节超过 flashImgSize {flash_size} 字节"
)
release_text = release_script.read_text(encoding="utf-8", errors="replace")
data_row = partition_rows.get("data")
if data_row is not None:
data_file = data_row.get("file")
if not isinstance(data_file, str) or not data_file.strip():
errors.append("data 分区缺少 file,release.py 组包时会失败")
if "mkAiData.py" in release_text:
for required_dst in ("imgs/mkAiData.py", "imgs/data_ubinize.cfg"):
if required_dst not in destinations:
errors.append(
f"DATA 组包资源没有明确来源映射: EmxResToProduct 缺少 {required_dst}"
)
counts = {int(value) for value in re.findall(r"mkAiData\.py\s+(\d+)\b", release_text)}
if len(counts) != 1:
errors.append("无法从 release.py 唯一确定 mkAiData.py 的容量参数")
elif isinstance(data_row.get("size_bytes"), int):
count = next(iter(counts))
if count <= 0:
errors.append("mkAiData.py 的容量参数必须大于 0")
requested = count * UBI_LEB_SIZE
if requested > int(data_row["size_bytes"]):
errors.append(
f"mkAiData.py 参数需要约 {requested} 字节,超过 data 分区 {data_row['size_bytes']} 字节"
)
bsp_lock_path = source / "metadata" / "bsp.lock.json"
bsp_checks: list[dict[str, object]] = []
if not bsp_lock_path.is_file() or bsp_lock_path.is_symlink():
errors.append("缺少普通文件 metadata/bsp.lock.json")
else:
bsp_lock = load_json(bsp_lock_path)
margin = bsp_lock.get("image_partition_margin")
checks = margin.get("checks") if isinstance(margin, dict) else None
if not isinstance(margin, dict) or margin.get("passed") is not True or not isinstance(checks, list):
errors.append("bsp.lock.json 缺少已通过的 kernel/rootfs 分区余量证据")
else:
required_margin = margin.get("required_margin_bytes")
checked_aliases: set[str] = set()
for check in checks:
if not isinstance(check, dict) or not isinstance(check.get("partitions"), list):
continue
image = check.get("image")
image_size = check.get("image_size_bytes")
for bsp_partition in check["partitions"]:
if not isinstance(bsp_partition, dict):
continue
alias = bsp_partition.get("alias")
if not isinstance(alias, str) or alias not in partition_rows:
errors.append(f"PSP 分区表缺少 BSP 已验证分区: {alias!r}")
continue
checked_aliases.add(alias)
psp_partition = partition_rows[alias]
bsp_size = bsp_partition.get("size_bytes")
if psp_partition.get("size_bytes") != bsp_size:
errors.append(
f"分区 {alias} 与 BSP 不一致:PSP {psp_partition.get('size_bytes')} 字节,BSP {bsp_size} 字节"
)
if psp_partition.get("file") != image:
errors.append(
f"分区 {alias} 的 file 与 BSP 不一致:PSP {psp_partition.get('file')!r},BSP {image!r}"
)
if (
isinstance(image_size, int)
and isinstance(psp_partition.get("size_bytes"), int)
and isinstance(required_margin, int)
and int(psp_partition["size_bytes"]) - image_size < required_margin
):
errors.append(f"分区 {alias} 相对 {image} 的余量不足 {required_margin} 字节")
bsp_checks.append(
{
"partition": alias,
"image": image,
"psp_size_bytes": psp_partition.get("size_bytes"),
"bsp_size_bytes": bsp_size,
}
)
expected_aliases = {
name
for name in partition_rows
if name == "kernel"
or name == "rootfs"
or name.startswith("kernel.")
or name.startswith("rootfs.")
}
missing_aliases = sorted(expected_aliases - checked_aliases)
if missing_aliases:
errors.append(f"BSP 分区余量证据未覆盖 PSP 分区: {missing_aliases}")
result: dict[str, object] = {
"schema_version": 1,
"passed": not errors,
"mode": "formal" if formal else "pre-candidate",
"checked_at": now_utc(),
"contract": contract_evidence(contract_path, contract, digest),
"project_root": str(project),
"psp_source": str(source),
"psp_git": psp_git,
"scripts": {"product": str(product_script), "release": str(release_script)},
"mappings": resolved_mappings,
"partitions": list(partition_rows.values()),
"bsp_partition_checks": bsp_checks,
"errors": errors,
"warnings": warnings,
}
write_json_new(destination, result)
return result
def log_evidence(path: Path) -> dict[str, object]:
path = path.expanduser().resolve(strict=True)
if not path.is_file() or path.is_symlink():
raise PackagingError(f"打包日志必须是存在的普通文件: {path}")
if path.stat().st_size > MAX_LOG_SIZE:
raise PackagingError("打包日志超过 64 MiB,拒绝无界读取")
text = path.read_text(encoding="utf-8", errors="replace")
failures: list[dict[str, object]] = []
warnings: list[dict[str, object]] = []
for line_number, line in enumerate(text.splitlines(), 1):
kind = None
target = None
if ANSI_RED_RE.search(line) or FAILURE_RE.search(line):
kind = "failure"
target = failures
elif ANSI_YELLOW_RE.search(line) or WARNING_RE.search(line):
kind = "warning"
target = warnings
if target is not None:
target.append(
{
"line": line_number,
"kind": kind,
"line_sha256": hashlib.sha256(line.encode("utf-8")).hexdigest(),
}
)
return {
"path": str(path),
"size": path.stat().st_size,
"sha256": sha256_file(path),
"failure_markers": failures,
"warning_markers": warnings,
}
def snapshot(contract_path: Path, destination: Path) -> dict[str, object]:
contract_path, contract, digest = load_contract(contract_path)
psp = git_state(exact_absolute_path(contract["psp_path"], "psp_path"))
if psp.get("tracked") is not True or psp.get("repository_root") != psp.get("path"):
raise PackagingError("打包环境中的 PSP 必须是独立 Git 仓库根目录")
if psp.get("dirty") is not False:
raise PackagingError("打包前 PSP 工作区必须干净")
result = {
"schema_version": 3,
"snapshot_at": now_utc(),
"contract": contract_evidence(contract_path, contract, digest),
"psp": psp,
"project": git_state(exact_absolute_path(contract["project_root"], "project_root")),
"output": output_state(exact_absolute_path(contract["output_root"], "output_root")),
}
write_json_new(destination, result)
return result
def record(
before_path: Path,
contract_path: Path,
destination: Path,
chip: str,
product_exit_code: int,
release_exit_code: int,
product_command: str,
release_command: str,
psp_repo: Path,
psp_commit: str,
bsp_build_id: str,
project_root: Path,
working_directory: Path,
product_log_path: Path,
release_log_path: Path,
image_path: Path,
formal: bool,
clean_rebuild_confirmed: bool = False,
) -> dict[str, object]:
errors: list[str] = []
warnings: list[str] = []
before_path = before_path.expanduser().resolve(strict=True)
before = load_json(before_path)
old_output = before.get("output")
old_psp = before.get("psp")
before_contract = before.get("contract")
if (
before.get("schema_version") != 3
or not isinstance(old_output, dict)
or not isinstance(old_psp, dict)
or not isinstance(before_contract, dict)
):
raise PackagingError("before snapshot schema 非法或缺少打包契约/PSP/output")
contract_path, contract, digest = load_contract(contract_path)
contract_info = contract_evidence(contract_path, contract, digest)
if before_contract != contract_info:
raise PackagingError("打包契约在 snapshot 后发生变化或不是同一文件")
actual_psp = exact_directory(psp_repo, "PSP 仓库")
actual_project = exact_directory(project_root, "应用工程")
actual_working = exact_directory(working_directory, "打包工作目录")
expected_psp = exact_absolute_path(contract["psp_path"], "psp_path")
expected_project = exact_absolute_path(contract["project_root"], "project_root")
expected_working = exact_absolute_path(contract["working_directory"], "working_directory")
expected_output = exact_absolute_path(contract["output_root"], "output_root")
if chip not in contract["chips"]:
errors.append(f"芯片 {chip!r} 不在打包契约 chips 中")
if actual_psp != expected_psp:
errors.append(f"PSP 路径不符合契约:期望 {expected_psp},实际 {actual_psp}")
if actual_project != expected_project:
errors.append(f"应用工程路径不符合契约:期望 {expected_project},实际 {actual_project}")
if actual_working != expected_working:
errors.append(f"打包工作目录不符合契约:期望 {expected_working},实际 {actual_working}")
if product_command != contract["product_command"]:
errors.append(f"资源复制命令必须精确为 {PRODUCT_COMMAND}")
if release_command not in contract["release_commands"]:
errors.append("实际 release 命令未被当前契约精确批准")
if release_command == "./release.py 1" and not clean_rebuild_confirmed:
errors.append("clean rebuild 使用 ./release.py 1 前缺少用户确认")
if product_exit_code not in contract["success_exit_codes"]:
errors.append(f"product.py 退出码不在契约成功码中: {product_exit_code}")
if release_exit_code not in contract["success_exit_codes"]:
errors.append(f"release.py 退出码不在契约成功码中: {release_exit_code}")
product_log = log_evidence(product_log_path)
release_log = log_evidence(release_log_path)
if product_log["failure_markers"]:
errors.append("product.py 日志检测到失败标记,禁止继续认定 release 结果")
if release_log["failure_markers"]:
errors.append("release.py 日志检测到失败标记")
if product_log["warning_markers"]:
warnings.append("product.py 日志包含警告标记;仅记录行号和行 hash")
if release_log["warning_markers"]:
warnings.append("release.py 日志包含警告标记;仅记录行号和行 hash")
current_output = output_state(expected_output)
if old_output.get("path") != current_output.get("path"):
raise PackagingError("契约 output_root 与 before snapshot 不一致")
old_versions = {
str(item.get("name")): item
for item in old_output.get("versions", [])
if isinstance(item, dict)
}
new_versions = {
str(item.get("name")): item
for item in current_output.get("versions", [])
if isinstance(item, dict)
}
added_names = sorted(set(new_versions) - set(old_versions))
removed_names = sorted(set(old_versions) - set(new_versions))
modified_names = sorted(
name
for name in set(old_versions) & set(new_versions)
if old_versions[name].get("tree_sha256") != new_versions[name].get("tree_sha256")
)
if len(added_names) != 1:
errors.append(f"本轮必须唯一新增一个 output 版本目录,实际新增 {added_names}")
if removed_names:
errors.append(f"本轮打包删除了既有 output 版本目录: {removed_names}")
if modified_names:
errors.append(f"本轮打包修改了既有 output 版本目录: {modified_names}")
version = new_versions[added_names[0]] if len(added_names) == 1 else None
image = image_path.expanduser()
if not image.is_absolute():
raise PackagingError(f"烧录包必须使用唯一绝对路径: {image}")
image = image.resolve(strict=False)
image_state: dict[str, object] = {"path": str(image), "exists": image.exists()}
if not image.is_file() or image.is_symlink() or image.stat().st_size <= 0:
errors.append("用户/项目契约指定的烧录包不是非空普通文件")
else:
image_state.update(
{
"ordinary_file": True,
"size": image.stat().st_size,
"mtime_ns": image.stat().st_mtime_ns,
"sha256": sha256_file(image),
}
)
if version is None:
errors.append("无法把烧录包绑定到本轮唯一 output 版本目录")
else:
version_path = Path(str(version["path"])).resolve(strict=True)
try:
image.relative_to(version_path)
except ValueError:
errors.append("烧录包不在本轮新生成的 output 版本目录内")
psp = git_state(actual_psp)
if psp.get("tracked") is not True or psp.get("repository_root") != psp.get("path"):
errors.append("打包环境中的 PSP 不是独立 Git 仓库根目录")
if old_psp.get("commit") != psp_commit or psp.get("commit") != psp_commit:
errors.append(
f"PSP commit 不匹配:期望 {psp_commit!r},打包前 {old_psp.get('commit')!r},打包后 {psp.get('commit')!r}"
)
if old_psp.get("dirty") is not False or psp.get("dirty") is not False:
errors.append("打包前后 PSP 工作区都必须干净")
project = git_state(actual_project)
if project.get("tracked") is not True:
message = "应用工程不是可追踪 Git worktree"
(errors if formal else warnings).append(message)
elif project.get("dirty") is True:
warnings.append("打包后应用工程存在修改;已记录状态,需归因 product/release 输出")
result: dict[str, object] = {
"schema_version": 2,
"passed": not errors,
"recorded_at": now_utc(),
"mode": "formal" if formal else "local",
"before_snapshot": str(before_path),
"contract": contract_info,
"chip": chip,
"commands": {
"product": {"command": product_command, "exit_code": product_exit_code, "log": product_log},
"release": {"command": release_command, "exit_code": release_exit_code, "log": release_log},
"clean_rebuild_confirmed": clean_rebuild_confirmed,
},
"working_directory": str(actual_working),
"psp": psp,
"expected_psp_commit": psp_commit,
"bsp_build_id": bsp_build_id,
"project": project,
"framework": project,
"output": {
"root": str(expected_output),
"added_versions": added_names,
"removed_versions": removed_names,
"modified_versions": modified_names,
"version": version,
},
"image": image_state,
"fresh_output": len(added_names) == 1 and not removed_names and not modified_names,
"errors": errors,
"warnings": warnings,
}
write_json_new(destination, result)
return result
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
subparsers = parser.add_subparsers(dest="subcommand", required=True)
contract_parser = subparsers.add_parser(
"contract", help="根据 EMX2.1 固定目录和命令生成本地打包契约"
)
contract_parser.add_argument("--project-root", type=Path, required=True)
contract_parser.add_argument("--chip", required=True)
contract_parser.add_argument("--output", type=Path, required=True)
preflight_parser = subparsers.add_parser(
"preflight", help="在 product.py 前检查资源来源、分区和 BSP 证据"
)
preflight_parser.add_argument("--contract", type=Path, required=True)
preflight_parser.add_argument("--state", type=Path, required=True)
preflight_parser.add_argument(
"--psp-source",
type=Path,
help="候选提交前可指定 staging;正式组包时省略并使用工程内 psp",
)
preflight_parser.add_argument("--psp-commit")
preflight_parser.add_argument("--formal", action="store_true")
snapshot_parser = subparsers.add_parser("snapshot", help="记录 product/release 前的 output 与 PSP 状态")
snapshot_parser.add_argument("--contract", type=Path, required=True)
snapshot_parser.add_argument("--state", type=Path, required=True)
record_parser = subparsers.add_parser("record", help="验证 product/release 结果、版本目录和烧录包")
record_parser.add_argument("--before", type=Path, required=True)
record_parser.add_argument("--contract", type=Path, required=True)
record_parser.add_argument("--result", type=Path, required=True)
record_parser.add_argument("--chip", required=True)
record_parser.add_argument("--product-exit-code", type=int, required=True)
record_parser.add_argument("--release-exit-code", type=int, required=True)
record_parser.add_argument("--product-command", default=PRODUCT_COMMAND)
record_parser.add_argument("--release-command", required=True, choices=sorted(RELEASE_COMMANDS))
record_parser.add_argument("--clean-rebuild-confirmed", action="store_true")
record_parser.add_argument("--psp-repo", type=Path, required=True)
record_parser.add_argument("--psp-commit", required=True)
record_parser.add_argument("--bsp-build-id", required=True)
record_parser.add_argument("--project-root", type=Path, required=True)
record_parser.add_argument("--working-directory", type=Path, required=True)
record_parser.add_argument("--product-log", type=Path, required=True)
record_parser.add_argument("--release-log", type=Path, required=True)
record_parser.add_argument("--image", type=Path, required=True)
record_parser.add_argument("--formal", action="store_true")
return parser
def main() -> int:
args = build_parser().parse_args()
try:
if args.subcommand == "contract":
result = create_contract(args.project_root, args.chip, args.output)
elif args.subcommand == "preflight":
result = packaging_preflight(
args.contract,
args.state,
args.psp_source,
args.psp_commit,
args.formal,
)
elif args.subcommand == "snapshot":
result = snapshot(args.contract, args.state)
else:
result = record(
args.before,
args.contract,
args.result,
args.chip,
args.product_exit_code,
args.release_exit_code,
args.product_command,
args.release_command,
args.psp_repo,
args.psp_commit,
args.bsp_build_id,
args.project_root,
args.working_directory,
args.product_log,
args.release_log,
args.image,
args.formal,
args.clean_rebuild_confirmed,
)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0 if args.subcommand in {"contract", "snapshot"} or result.get("passed") is True else 2
except (PackagingError, OSError, 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,494 @@
#!/usr/bin/env python3
"""Render a PSP evidence handoff for user review or Etunel Hub; never send it."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import subprocess
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
STATUSES = ("candidate", "packaged", "device_verified", "app_integrated", "accepted")
class HandoffError(RuntimeError):
"""Handoff evidence is incomplete or inconsistent."""
def now_utc() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def load_object(path: Path, name: str) -> dict[str, Any]:
payload = json.loads(path.expanduser().read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise HandoffError(f"{name} JSON 根节点必须是对象: {path}")
return payload
def write_json_new(path: Path, payload: dict[str, object]) -> None:
path = path.expanduser().resolve(strict=False)
if path.exists() or path.is_symlink():
raise HandoffError(f"交接文件已存在,禁止覆盖: {path}")
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
"w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", 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 run_git(path: Path, *args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", "-C", str(path), *args],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
def psp_git_state(path: Path) -> dict[str, object]:
path = path.expanduser().resolve(strict=True)
if not path.is_dir() or path.is_symlink():
raise HandoffError(f"PSP 仓库必须是普通目录: {path}")
top = run_git(path, "rev-parse", "--show-toplevel")
commit = run_git(path, "rev-parse", "HEAD")
status = run_git(path, "status", "--porcelain", "--untracked-files=all", "--", ".")
if top.returncode or commit.returncode or status.returncode:
raise HandoffError("PSP 必须是可读取 HEAD 和工作区状态的 Git worktree")
lines = [line for line in status.stdout.splitlines() if line.strip()]
commit_value = commit.stdout.strip()
tags = run_git(path, "tag", "--points-at", commit_value)
if tags.returncode != 0:
raise HandoffError(f"无法读取 PSP 状态 tag: {tags.stderr.strip()}")
return {
"path": str(path),
"repository_root": str(Path(top.stdout.strip()).resolve()),
"commit": commit_value,
"dirty": bool(lines),
"status": lines,
"tags": sorted(line for line in tags.stdout.splitlines() if line),
}
def annotated_tag(path: Path, name: str) -> bool:
result = run_git(path, "cat-file", "-t", f"refs/tags/{name}")
return result.returncode == 0 and result.stdout.strip() == "tag"
def shared_repository_url(value: str) -> bool:
stripped = value.strip()
return bool(stripped) and not stripped.startswith(("/", "./", "../", "~", "file:"))
def verify_remote(repository_url: str, commit: str) -> dict[str, object]:
checked_at = now_utc()
environment = os.environ.copy()
environment["GIT_TERMINAL_PROMPT"] = "0"
try:
result = subprocess.run(
["git", "ls-remote", "--heads", "--tags", repository_url],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
timeout=30,
env=environment,
)
except subprocess.TimeoutExpired:
return {
"schema_version": 1,
"passed": False,
"checked_at": checked_at,
"repository_url": repository_url,
"commit": commit,
"refs": [],
"errors": ["git ls-remote 超时"],
}
except OSError as exc:
return {
"schema_version": 1,
"passed": False,
"checked_at": checked_at,
"repository_url": repository_url,
"commit": commit,
"refs": [],
"errors": [f"无法执行 git ls-remote: {exc}"],
}
refs: list[str] = []
for line in result.stdout.splitlines():
fields = line.split("\t", 1)
if len(fields) == 2 and fields[0] == commit:
refs.append(fields[1])
errors: list[str] = []
if result.returncode != 0:
errors.append(f"git ls-remote 失败: {result.stderr.strip() or result.returncode}")
elif not refs:
errors.append("共享远端 heads/tags 中不存在当前完整 PSP commit")
return {
"schema_version": 1,
"passed": not errors,
"checked_at": checked_at,
"repository_url": repository_url,
"commit": commit,
"refs": sorted(refs),
"errors": errors,
}
def require_passed(payload: dict[str, Any] | None, name: str, errors: list[str]) -> None:
if payload is None:
errors.append(f"缺少 {name} 证据")
elif payload.get("passed") is not True and payload.get("result") != "PASS":
errors.append(f"{name} 结果不是 PASS")
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for block in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def validate_packaging_record(payload: dict[str, Any] | None, errors: list[str]) -> None:
require_passed(payload, "PSP 打包", errors)
if payload is None:
return
if payload.get("schema_version") != 2:
errors.append("PSP 打包证据必须由契约版工具生成(schema_version=2)")
contract = payload.get("contract")
if not isinstance(contract, dict):
errors.append("PSP 打包证据缺少固定契约 path/hash/id")
else:
path_value = contract.get("path")
expected_hash = contract.get("sha256")
if not isinstance(path_value, str) or not isinstance(expected_hash, str):
errors.append("PSP 打包契约 path/hash 非法")
else:
contract_path = Path(path_value)
if not contract_path.is_file() or contract_path.is_symlink():
errors.append("PSP 打包契约文件当前不可读")
elif sha256_file(contract_path) != expected_hash:
errors.append("PSP 打包契约文件 hash 已变化")
image = payload.get("image")
if not isinstance(image, dict):
errors.append("PSP 打包证据缺少 image")
else:
path_value = image.get("path")
expected_hash = image.get("sha256")
if not isinstance(path_value, str) or not isinstance(expected_hash, str):
errors.append("PSP 烧录包 path/hash 非法")
else:
image_path = Path(path_value)
if not image_path.is_file() or image_path.is_symlink():
errors.append("PSP 烧录包当前不可读")
elif sha256_file(image_path) != expected_hash:
errors.append("PSP 烧录包 hash 与打包证据不一致")
def evidence_commit(payload: dict[str, Any] | None) -> str | None:
if payload is None:
return None
direct = payload.get("psp_commit")
if isinstance(direct, str):
return direct
expected = payload.get("expected_psp_commit")
if isinstance(expected, str):
return expected
psp = payload.get("psp")
if isinstance(psp, dict) and isinstance(psp.get("commit"), str):
return psp["commit"]
return None
def validate_device(
payload: dict[str, Any] | None,
packaging: dict[str, Any] | None,
commit: str,
errors: list[str],
) -> None:
if payload is None:
errors.append("缺少设备验证证据")
return
if payload.get("result") != "PASS":
errors.append("设备验证结果不是 PASS")
if payload.get("evidence_source") != "USER_REPORTED":
errors.append("当前人工烧录阶段的 evidence_source 必须为 USER_REPORTED")
if payload.get("psp_commit") != commit:
errors.append("设备验证的 PSP commit 与当前仓库不一致")
unexecuted = payload.get("unexecuted")
if not isinstance(unexecuted, list) or unexecuted:
errors.append("设备验证仍有未执行的适用检查")
checks = payload.get("checks")
if not isinstance(checks, list) or not checks:
errors.append("设备验证缺少非空 checks")
elif any(
not isinstance(item, dict) or item.get("result") not in {"PASS", "N/A"}
for item in checks
):
errors.append("设备验证 checks 含失败或非法结果")
if packaging:
image = packaging.get("image")
if not isinstance(image, dict):
errors.append("打包证据缺少 image")
else:
if payload.get("image_path") != image.get("path"):
errors.append("设备验证镜像路径与打包结果不一致")
if payload.get("image_sha256") != image.get("sha256"):
errors.append("设备验证镜像 hash 与打包结果不一致")
def validate_application(
payload: dict[str, Any] | None, commit: str, errors: list[str]
) -> None:
if payload is None:
errors.append("缺少应用集成证据")
return
if payload.get("result") != "PASS":
errors.append("应用集成结果不是 PASS")
if payload.get("psp_commit") != commit:
errors.append("应用集成的 PSP commit 与当前仓库不一致")
if not isinstance(payload.get("application_commit"), str) or not payload.get(
"application_commit"
):
errors.append("应用集成缺少 application_commit")
unexecuted = payload.get("unexecuted")
if not isinstance(unexecuted, list) or unexecuted:
errors.append("应用集成仍有未执行的适用检查")
approval = payload.get("internal_approved")
if approval is not True:
errors.append("应用集成缺少应用负责人确认")
def validate_approval(payload: dict[str, Any] | None, errors: list[str]) -> None:
if payload is None:
errors.append("缺少嵌入式底层负责人确认")
return
if payload.get("internal_approved") is not True:
errors.append("internal_approved 不是 true")
for field in ("confirmed_by", "confirmed_at", "confirmed_scope"):
if not isinstance(payload.get(field), str) or not payload.get(field):
errors.append(f"负责人确认缺少 {field}")
def render(
project: str,
chip: str,
status: str,
psp_repo: Path,
repository_url: str | None,
reference_psp: str,
reference_commit: str | None,
static_path: Path,
bsp_lock_path: Path,
packaging_path: Path | None,
device_path: Path | None,
application_path: Path | None,
etunel_context_path: Path | None,
approval_path: Path | None,
media_version: str | None,
ai_plugin_version: str | None,
risks: list[str],
warnings: list[str],
unexecuted: list[str],
remote_verification: dict[str, object] | None,
for_etunel: bool,
) -> dict[str, object]:
errors: list[str] = []
git = psp_git_state(psp_repo)
commit = str(git["commit"])
if git["repository_root"] != git["path"]:
errors.append("PSP 必须是独立 Git 仓库根目录")
if git["dirty"] is True:
errors.append("PSP 工作区不干净,不能形成不可变交接")
static = load_object(static_path, "static validation")
bsp_lock = load_object(bsp_lock_path, "BSP lock")
expected_bsp_lock = Path(str(git["path"])) / "metadata" / "bsp.lock.json"
if bsp_lock_path.expanduser().resolve(strict=True) != expected_bsp_lock:
errors.append("BSP lock 必须是当前 PSP 仓库的 metadata/bsp.lock.json")
if isinstance(static.get("psp_root"), str) and Path(static["psp_root"]).resolve(
strict=False
) != Path(str(git["path"])):
errors.append("静态验证结果不属于当前 PSP 仓库")
if isinstance(static.get("project"), str) and static["project"] != project:
errors.append("静态验证结果的项目名与交接项目不一致")
packaging = load_object(packaging_path, "packaging") if packaging_path else None
device = load_object(device_path, "device validation") if device_path else None
application = (
load_object(application_path, "application integration") if application_path else None
)
etunel_context = (
load_object(etunel_context_path, "Etunel context") if etunel_context_path else None
)
approval = load_object(approval_path, "owner approval") if approval_path else None
require_passed(static, "PSP 静态验证", errors)
if bsp_lock.get("schema_version") != 1:
errors.append("BSP lock schema_version 必须为 1")
if status in {"packaged", "device_verified", "app_integrated", "accepted"}:
validate_packaging_record(packaging, errors)
if packaging and evidence_commit(packaging) != commit:
errors.append("打包证据的 PSP commit 与当前仓库不一致")
if status in {"device_verified", "accepted"}:
validate_device(device, packaging, commit, errors)
if status in {"app_integrated", "accepted"}:
validate_application(application, commit, errors)
if for_etunel or status == "accepted":
validate_approval(approval, errors)
if for_etunel:
if status not in {"packaged", "accepted"}:
errors.append("Etunel 应用交接只允许 packaged 或 accepted 状态")
if not repository_url or not shared_repository_url(repository_url):
errors.append("Etunel 交接必须提供团队共享 PSP 仓库地址")
if remote_verification is None or remote_verification.get("passed") is not True:
errors.append("Etunel 交接缺少通过的共享远端 commit 可达性验证")
elif (
remote_verification.get("repository_url") != repository_url
or remote_verification.get("commit") != commit
):
errors.append("远端可达性验证与当前仓库地址或 PSP commit 不一致")
if etunel_context is None:
errors.append("Etunel 交接缺少有效上下文")
else:
for field in ("work_id", "subtask_id", "phase", "wave", "role"):
if not isinstance(etunel_context.get(field), str) or not etunel_context.get(field):
errors.append(f"Etunel 上下文缺少 {field}")
if status == "accepted":
accepted_tag = f"accepted/{commit[:12]}"
if accepted_tag not in git["tags"] or not annotated_tag(Path(str(git["path"])), accepted_tag):
errors.append(f"当前 commit 缺少 annotated accepted 状态 tag: {accepted_tag}")
revoked_tag = f"revoked/{commit[:12]}"
if revoked_tag in git["tags"]:
errors.append(f"当前 PSP commit 已被 revoked: {revoked_tag}")
evidence = {
"static_validation": static,
"packaging": packaging,
"device_validation": device,
"application_integration": application,
}
result: dict[str, object] = {
"schema_version": 1,
"handoff_type": "psp-to-application",
"generated_at": now_utc(),
"passed": not errors,
"project": project,
"chip": chip,
"status": status,
"psp": {
"repository_url": repository_url,
"remote_verification": remote_verification,
**git,
},
"reference_psp": {
"source": reference_psp,
"commit": reference_commit,
},
"bsp_lock": bsp_lock,
"component_baseline": {
"media_version": media_version,
"ai_plugin_version": ai_plugin_version,
},
"evidence": evidence,
"etunel": {
"context": etunel_context,
"remote_commit_reachable": bool(
remote_verification and remote_verification.get("passed") is True
),
"prepared_for_send": for_etunel and not errors,
"sent": False,
},
"owner_approval": approval,
"self_check_result": "PASS" if not errors else "FAIL",
"unexecuted": unexecuted,
"warnings": warnings,
"risks": risks,
"errors": errors,
}
return result
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--project", required=True)
parser.add_argument("--chip", required=True)
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)
parser.add_argument("--reference-commit")
parser.add_argument("--static-validation", type=Path, required=True)
parser.add_argument("--bsp-lock", type=Path, required=True)
parser.add_argument("--packaging-result", type=Path)
parser.add_argument("--device-validation", type=Path)
parser.add_argument("--application-integration", type=Path)
parser.add_argument("--etunel-context", type=Path)
parser.add_argument("--owner-approval", type=Path)
parser.add_argument("--media-version")
parser.add_argument("--ai-plugin-version")
parser.add_argument("--risk", action="append", default=[])
parser.add_argument("--warning", action="append", default=[])
parser.add_argument("--unexecuted", action="append", default=[])
parser.add_argument("--for-etunel", action="store_true")
parser.add_argument(
"--verify-remote",
action="store_true",
help="用 git ls-remote 证明当前 commit 可从 repository-url 获取",
)
parser.add_argument("--output", type=Path, required=True)
return parser
def main() -> int:
args = build_parser().parse_args()
try:
remote_verification = None
if args.verify_remote:
if not args.repository_url:
raise HandoffError("--verify-remote 必须同时提供 --repository-url")
current_commit = str(psp_git_state(args.psp_repo)["commit"])
remote_verification = verify_remote(args.repository_url, current_commit)
result = render(
args.project,
args.chip,
args.status,
args.psp_repo,
args.repository_url,
args.reference_psp,
args.reference_commit,
args.static_validation,
args.bsp_lock,
args.packaging_result,
args.device_validation,
args.application_integration,
args.etunel_context,
args.owner_approval,
args.media_version,
args.ai_plugin_version,
args.risk,
args.warning,
args.unexecuted,
remote_verification,
args.for_etunel,
)
write_json_new(args.output, result)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0 if result.get("passed") is True else 2
except (HandoffError, OSError, 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,184 @@
#!/usr/bin/env python3
"""Behavioral tests for psp_project_guard.py."""
import json
import subprocess
import tempfile
from pathlib import Path
import psp_project_guard
def git(path: Path, *args: str) -> str:
result = subprocess.run(
["git", "-C", str(path), *args],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True,
)
return result.stdout.strip()
def create_empty_project(psp_root: Path, name: str) -> Path:
project = psp_root / name
project.mkdir()
git(project, "init", "-q")
git(project, "remote", "add", "origin", f"ssh://git.example/Psp/{name}.git")
return project
def expect_guard_error(action, message: str) -> None:
try:
action()
except psp_project_guard.GuardError:
return
raise AssertionError(message)
def main() -> int:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
psp_root = root / "Psp"
psp_root.mkdir()
reference = psp_root / "REF"
reference.mkdir()
(reference / "configs").mkdir()
(reference / "configs" / "board.json").write_text(
json.dumps({"project": "REF"}), encoding="utf-8"
)
(reference / "psp.json").write_text(
json.dumps({"project": "REF"}), encoding="utf-8"
)
(reference / "build").mkdir()
(reference / "build" / "generated.bin").write_bytes(b"generated")
git(reference, "init", "-q")
git(reference, "add", "configs/board.json", "psp.json")
git(
reference,
"-c",
"user.name=PSP Test",
"-c",
"user.email=psp-test@example.invalid",
"commit",
"-qm",
"reference",
)
target = create_empty_project(psp_root, "LY-SM-YT42-AI")
original_branch = git(target, "symbolic-ref", "--short", "HEAD")
original_remote = git(target, "remote", "get-url", "origin")
inspected = psp_project_guard.inspect_project(target, psp_root)
assert inspected["mode"] == "precreated-empty-repository"
assert inspected["project"] == "LY-SM-YT42-AI"
manifest = root / "evidence" / "clone-manifest.json"
prepared = psp_project_guard.prepare(
reference, target, manifest, ["REF"], include_dirty=False, psp_root=psp_root
)
staging = Path(prepared["staging"])
assert prepared["target_mode"] == "precreated-empty-repository"
assert prepared["psp_root"] == str(psp_root)
assert not (staging / ".git").exists()
assert not (staging / "build").exists()
(staging / "psp.json").write_text(
json.dumps({"project": "NEW"}), encoding="utf-8"
)
failing = psp_project_guard.verify(manifest, ["psp.json"], [], None)
assert failing["passed"] is False
assert failing["residues"]
(staging / "configs" / "board.json").write_text(
json.dumps({"project": "NEW"}), encoding="utf-8"
)
report_path = root / "evidence" / "verify.json"
verified = psp_project_guard.verify(
manifest, ["psp.json", "configs/board.json"], [], report_path
)
assert verified["passed"] is True, verified
finalized = psp_project_guard.finalize(manifest, report_path)
assert finalized["target"] == str(target)
assert finalized["repository_preserved"] is True
assert not staging.exists()
assert (target / ".git").is_dir()
assert git(target, "symbolic-ref", "--short", "HEAD") == original_branch
assert git(target, "remote", "get-url", "origin") == original_remote
assert not (target / ".git" / "refs" / "heads" / original_branch).exists()
audit_path = root / "evidence" / "final-audit.json"
audited = psp_project_guard.audit_final(
manifest, ["psp.json", "configs/board.json"], [], audit_path
)
assert audited["passed"] is True, audited
assert audited["repository_unchanged"] is True
missing = psp_root / "MISSING"
expect_guard_error(
lambda: psp_project_guard.prepare(
reference, missing, root / "missing-manifest.json", [], False, psp_root
),
"missing target must be rejected",
)
outside = create_empty_project(root, "OUTSIDE")
expect_guard_error(
lambda: psp_project_guard.prepare(
reference, outside, root / "outside-manifest.json", [], False, psp_root
),
"target outside the fixed PSP root must be rejected",
)
populated = create_empty_project(psp_root, "POPULATED")
(populated / "README.md").write_text("existing\n", encoding="utf-8")
assert psp_project_guard.inspect_project(populated, psp_root)["mode"] == "existing-project"
expect_guard_error(
lambda: psp_project_guard.prepare(
reference, populated, root / "populated-manifest.json", [], False, psp_root
),
"populated target must use the existing-project update flow",
)
changed = create_empty_project(psp_root, "CHANGED")
changed_manifest = root / "evidence" / "changed-manifest.json"
changed_prepared = psp_project_guard.prepare(
reference, changed, changed_manifest, [], False, psp_root
)
changed_report = root / "evidence" / "changed-verify.json"
changed_verified = psp_project_guard.verify(changed_manifest, [], [], changed_report)
assert changed_verified["passed"] is True
git(changed, "remote", "set-url", "origin", "ssh://git.example/changed.git")
expect_guard_error(
lambda: psp_project_guard.finalize(changed_manifest, changed_report),
"Git metadata drift after prepare must block finalization",
)
assert Path(changed_prepared["staging"]).is_dir()
assert not (changed / "psp.json").exists()
(reference / "psp.json").write_text(
json.dumps({"project": "UNCOMMITTED"}), encoding="utf-8"
)
head_target = create_empty_project(psp_root, "FROM-HEAD")
head_manifest = root / "evidence" / "head-manifest.json"
from_head = psp_project_guard.prepare(
reference, head_target, head_manifest, ["REF"], False, psp_root
)
assert from_head["warnings"]
assert json.loads((Path(from_head["staging"]) / "psp.json").read_text())["project"] == "REF"
unsafe = root / "unsafe"
unsafe.mkdir()
(unsafe / "psp.json").write_text("{}", encoding="utf-8")
(unsafe / ".env").write_text("TOKEN=secret", encoding="utf-8")
unsafe_target = create_empty_project(psp_root, "UNSAFE")
expect_guard_error(
lambda: psp_project_guard.prepare(
unsafe, unsafe_target, root / "unsafe-manifest.json", [], False, psp_root
),
"credential-like files must be rejected",
)
print("PSP project guard tests passed")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,357 @@
#!/usr/bin/env python3
"""检查 PSP 组包契约、前置检查和结果记录。"""
import json
import subprocess
import tempfile
from pathlib import Path
import record_packaging_result
def git(path: Path, *args: str) -> str:
result = subprocess.run(
["git", "-C", str(path), *args],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True,
)
return result.stdout.strip()
def init_repo(path: Path, filename: str) -> str:
path.mkdir(parents=True, exist_ok=True)
(path / filename).write_text("fixture", encoding="utf-8")
git(path, "init", "-q")
git(path, "add", filename)
git(
path,
"-c",
"user.name=PSP Test",
"-c",
"user.email=psp-test@example.invalid",
"commit",
"-qm",
"fixture",
)
return git(path, "rev-parse", "HEAD")
def write_contract(path: Path, project: Path) -> None:
path.write_text(
json.dumps(
{
"schema_version": 2,
"contract_type": "emx21-project-packaging",
"contract_id": "fixture-cv1841cp-v2",
"chips": ["CV1841CP"],
"project_root": str(project),
"psp_path": str(project / "psp"),
"working_directory": str(project / "scripts"),
"output_root": str(project / "output"),
"product_command": "./product.py",
"release_commands": ["./release.py 0", "./release.py 1"],
"success_exit_codes": [0],
}
),
encoding="utf-8",
)
def record(
root: Path,
before: Path,
contract: Path,
result_name: str,
commit: str,
project: Path,
image: Path,
product_log: Path,
release_log: Path,
release_command: str = "./release.py 0",
clean_confirmed: bool = False,
) -> dict[str, object]:
return record_packaging_result.record(
before,
contract,
root / "evidence" / result_name,
"CV1841CP",
0,
0,
"./product.py",
release_command,
project / "psp",
commit,
"20260903_1",
project,
project / "scripts",
product_log,
release_log,
image,
formal=True,
clean_rebuild_confirmed=clean_confirmed,
)
def main() -> int:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
project = root / "application"
init_repo(project, "product.json")
(project / "scripts").mkdir()
(project / "scripts" / "product.py").write_text("# fixture\n", encoding="utf-8")
(project / "scripts" / "release.py").write_text("# fixture\n", encoding="utf-8")
psp = project / "psp"
commit = init_repo(psp, "psp.json")
output = project / "output"
output.mkdir()
old_version = output / "20260902_1"
old_version.mkdir()
(old_version / "old.bin").write_bytes(b"old")
contract = root / "packaging-contract.json"
write_contract(contract, project)
product_log = root / "product.log"
release_log = root / "release.log"
product_log.write_text("resources copied\n", encoding="utf-8")
release_log.write_text("package complete\n", encoding="utf-8")
before = root / "evidence" / "before.json"
snapshot = record_packaging_result.snapshot(contract, before)
assert snapshot["schema_version"] == 3
version = output / "20260903_1"
version.mkdir()
image = version / "ep_flash_sd.bin"
image.write_bytes(b"image-v1")
result = record(
root,
before,
contract,
"result.json",
commit,
project,
image,
product_log,
release_log,
)
assert result["passed"] is True, result
assert result["fresh_output"] is True
assert result["output"]["version"]["path"] == str(version)
assert result["image"]["sha256"]
stale_before = root / "evidence" / "stale-before.json"
record_packaging_result.snapshot(contract, stale_before)
stale = record(
root,
stale_before,
contract,
"stale-result.json",
commit,
project,
image,
product_log,
release_log,
)
assert stale["passed"] is False
assert any("唯一新增" in error for error in stale["errors"])
clean_before = root / "evidence" / "clean-before.json"
record_packaging_result.snapshot(contract, clean_before)
clean_version = output / "20260903_2"
clean_version.mkdir()
clean_image = clean_version / "ep_flash_sd.bin"
clean_image.write_bytes(b"image-v2")
unconfirmed_clean = record(
root,
clean_before,
contract,
"unconfirmed-clean.json",
commit,
project,
clean_image,
product_log,
release_log,
release_command="./release.py 1",
)
assert unconfirmed_clean["passed"] is False
assert any("用户确认" in error for error in unconfirmed_clean["errors"])
failure_before = root / "evidence" / "failure-before.json"
record_packaging_result.snapshot(contract, failure_before)
failed_version = output / "20260903_3"
failed_version.mkdir()
failed_image = failed_version / "ep_flash_sd.bin"
failed_image.write_bytes(b"image-v3")
product_log.write_text("\x1b[91mcopy failed\x1b[0m\n", encoding="utf-8")
failed = record(
root,
failure_before,
contract,
"failed-product.json",
commit,
project,
failed_image,
product_log,
release_log,
)
assert failed["passed"] is False
assert any("product.py 日志" in error for error in failed["errors"])
assert "copy failed" not in json.dumps(failed, ensure_ascii=False)
product_log.write_text("resources copied\n", encoding="utf-8")
dirty_before = root / "evidence" / "dirty-before.json"
record_packaging_result.snapshot(contract, dirty_before)
dirty_version = output / "20260903_4"
dirty_version.mkdir()
dirty_image = dirty_version / "ep_flash_sd.bin"
dirty_image.write_bytes(b"image-v4")
(psp / "psp.json").write_text("dirty", encoding="utf-8")
dirty = record(
root,
dirty_before,
contract,
"dirty-result.json",
commit,
project,
dirty_image,
product_log,
release_log,
)
assert dirty["passed"] is False
assert any("必须干净" in error for error in dirty["errors"])
try:
record_packaging_result.snapshot(
root / "missing-contract.json", root / "evidence" / "must-not-exist.json"
)
except (record_packaging_result.PackagingError, FileNotFoundError):
pass
else:
raise AssertionError("missing packaging contract must fail closed")
contract_only_project = root / "contract-only-application"
contract_only_project.mkdir()
contract_only_scripts = contract_only_project / "scripts"
contract_only_scripts.mkdir()
(contract_only_scripts / "product.py").write_text("# 复制资源\n", encoding="utf-8")
(contract_only_scripts / "release.py").write_text("# 组包\n", encoding="utf-8")
contract_only_path = root / "contract-before-psp.json"
contract_only = record_packaging_result.create_contract(
contract_only_project, "CV1841CP", contract_only_path
)
assert contract_only["psp_path"] == str(contract_only_project / "psp")
ready_project = root / "ready-application"
init_repo(ready_project, "product.json")
ready_scripts = ready_project / "scripts"
ready_scripts.mkdir()
(ready_scripts / "product.py").write_text("# 复制资源\n", encoding="utf-8")
(ready_scripts / "release.py").write_text(
'os.system("./mkAiData.py 4 none")\n', encoding="utf-8"
)
resource_scripts = ready_project / "Public" / "EmxRes" / "imgs" / "scripts"
resource_scripts.mkdir(parents=True)
(resource_scripts / "mkAiData.py").write_text("# 工具\n", encoding="utf-8")
(resource_scripts / "data_ubinize.cfg").write_text("# 配置\n", encoding="utf-8")
ready_psp = ready_project / "psp"
init_repo(ready_psp, "README.md")
ready_payload = {
"partition": {
"flashImgSize": "16M",
"partitionTable": [
{"name": "kernel.A", "file": "kernel.bin", "size": "2M"},
{"name": "rootfs.A", "file": "rootfs.bin", "size": "2M"},
{"name": "data", "file": "data.bin", "size": "2M"},
{"name": "configs", "size": "-"},
],
},
"EmxResToProduct": [
{"src": "imgs/scripts/mkAiData.py", "dst": "imgs/mkAiData.py"},
{
"src": "imgs/scripts/data_ubinize.cfg",
"dst": "imgs/data_ubinize.cfg",
},
],
}
(ready_psp / "psp.json").write_text(
json.dumps(ready_payload), encoding="utf-8"
)
metadata = ready_psp / "metadata"
metadata.mkdir()
(metadata / "bsp.lock.json").write_text(
json.dumps(
{
"schema_version": 1,
"image_partition_margin": {
"passed": True,
"required_margin_bytes": 524288,
"checks": [
{
"image": "kernel.bin",
"image_size_bytes": 1048576,
"partitions": [
{"alias": "kernel.A", "size_bytes": 2097152}
],
},
{
"image": "rootfs.bin",
"image_size_bytes": 1048576,
"partitions": [
{"alias": "rootfs.A", "size_bytes": 2097152}
],
},
],
},
}
),
encoding="utf-8",
)
git(ready_psp, "add", "psp.json", "metadata/bsp.lock.json")
git(
ready_psp,
"-c",
"user.name=PSP Test",
"-c",
"user.email=psp-test@example.invalid",
"commit",
"-qm",
"ready fixture",
)
ready_commit = git(ready_psp, "rev-parse", "HEAD")
generated_contract = root / "generated-contract.json"
generated = record_packaging_result.create_contract(
ready_project, "CV1841CP", generated_contract
)
assert generated["product_command"] == "./product.py"
assert generated["release_commands"] == ["./release.py 0", "./release.py 1"]
ready_state = root / "evidence" / "ready-state.json"
preflight = record_packaging_result.packaging_preflight(
generated_contract,
ready_state,
psp_commit=ready_commit,
formal=True,
)
assert preflight["passed"] is True, preflight
del ready_payload["partition"]["partitionTable"][2]["file"]
(ready_psp / "psp.json").write_text(
json.dumps(ready_payload), encoding="utf-8"
)
failed_state = root / "evidence" / "failed-preflight.json"
failed_preflight = record_packaging_result.packaging_preflight(
generated_contract,
failed_state,
psp_source=ready_psp,
)
assert failed_preflight["passed"] is False
assert any("data 分区缺少 file" in error for error in failed_preflight["errors"])
print("PSP 组包证据测试通过")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,312 @@
#!/usr/bin/env python3
"""Behavioral tests for render_psp_handoff.py."""
import json
import subprocess
import tempfile
from pathlib import Path
import render_psp_handoff
def git(path: Path, *args: str) -> str:
result = subprocess.run(
["git", "-C", str(path), *args],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True,
)
return result.stdout.strip()
def write(path: Path, payload: dict) -> Path:
path.write_text(json.dumps(payload), encoding="utf-8")
return path
def main() -> int:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
psp = root / "psp"
psp.mkdir()
(psp / "psp.json").write_text("{}", encoding="utf-8")
(psp / "metadata").mkdir()
bsp = write(
psp / "metadata" / "bsp.lock.json", {"schema_version": 1, "build_id": "B1"}
)
git(psp, "init", "-q")
git(psp, "add", "psp.json", "metadata/bsp.lock.json")
git(
psp,
"-c",
"user.name=PSP Test",
"-c",
"user.email=psp-test@example.invalid",
"commit",
"-qm",
"candidate",
)
commit = git(psp, "rev-parse", "HEAD")
image = root / "flash.img"
image.write_bytes(b"flash")
image_hash = render_psp_handoff.subprocess.run(
["sha256sum", str(image)], text=True, stdout=subprocess.PIPE, check=True
).stdout.split()[0]
contract = write(
root / "packaging-contract.json",
{
"schema_version": 2,
"contract_type": "emx21-project-packaging",
},
)
contract_hash = render_psp_handoff.sha256_file(contract)
static = write(root / "static.json", {"passed": True})
packaging = write(
root / "packaging.json",
{
"schema_version": 2,
"passed": True,
"contract": {
"path": str(contract),
"sha256": contract_hash,
"contract_id": "fixture",
},
"expected_psp_commit": commit,
"image": {"path": str(image), "sha256": image_hash},
},
)
device = write(
root / "device.json",
{
"result": "PASS",
"evidence_source": "USER_REPORTED",
"psp_commit": commit,
"image_path": str(image),
"image_sha256": image_hash,
"checks": [{"name": "boot", "result": "PASS"}],
"unexecuted": [],
},
)
application = write(
root / "application.json",
{
"result": "PASS",
"psp_commit": commit,
"application_commit": "a" * 40,
"unexecuted": [],
"internal_approved": True,
},
)
approval = write(
root / "approval.json",
{
"internal_approved": True,
"confirmed_by": "embedded-lowlevel owner",
"confirmed_at": "2026-09-02T00:00:00+00:00",
"confirmed_scope": "packaged candidate",
},
)
context = write(
root / "context.json",
{
"work_id": "WORK-1",
"subtask_id": "SUB-1",
"phase": "implementation",
"wave": "wave-1",
"role": "embedded-lowlevel",
},
)
repository_url = "ssh://git.example/psp-NEW.git"
remote = {
"schema_version": 1,
"passed": True,
"checked_at": "2026-09-02T00:00:00+00:00",
"repository_url": repository_url,
"commit": commit,
"refs": ["refs/heads/candidate/SUB-1"],
"errors": [],
}
candidate = render_psp_handoff.render(
"NEW",
"CV1841CP",
"candidate",
psp,
None,
"psp-REF",
None,
static,
bsp,
None,
None,
None,
None,
None,
None,
None,
[],
[],
[],
None,
False,
)
assert candidate["passed"] is True, candidate
assert candidate["etunel"]["sent"] is False
packaged = render_psp_handoff.render(
"NEW",
"CV1841CP",
"packaged",
psp,
repository_url,
"psp-REF",
None,
static,
bsp,
packaging,
None,
None,
context,
approval,
"Media-1",
"AI-1",
[],
[],
[],
remote,
True,
)
assert packaged["passed"] is True, packaged
assert packaged["etunel"]["prepared_for_send"] is True
assert packaged["etunel"]["sent"] is False
before_tag = render_psp_handoff.render(
"NEW",
"CV1841CP",
"accepted",
psp,
repository_url,
"psp-REF",
None,
static,
bsp,
packaging,
device,
application,
context,
approval,
"Media-1",
"AI-1",
[],
[],
[],
remote,
True,
)
assert before_tag["passed"] is False
assert any("accepted" in error for error in before_tag["errors"])
git(
psp,
"-c",
"user.name=PSP Test",
"-c",
"user.email=psp-test@example.invalid",
"tag",
"-a",
f"accepted/{commit[:12]}",
"-m",
"accepted fixture",
)
accepted = render_psp_handoff.render(
"NEW",
"CV1841CP",
"accepted",
psp,
repository_url,
"psp-REF",
None,
static,
bsp,
packaging,
device,
application,
context,
approval,
"Media-1",
"AI-1",
[],
[],
[],
remote,
True,
)
assert accepted["passed"] is True, accepted
no_remote = render_psp_handoff.render(
"NEW",
"CV1841CP",
"packaged",
psp,
repository_url,
"psp-REF",
None,
static,
bsp,
packaging,
None,
None,
context,
approval,
"Media-1",
"AI-1",
[],
[],
[],
None,
True,
)
assert no_remote["passed"] is False
assert any("远端" in error for error in no_remote["errors"])
candidate_etunel = render_psp_handoff.render(
"NEW",
"CV1841CP",
"candidate",
psp,
repository_url,
"psp-REF",
None,
static,
bsp,
None,
None,
None,
context,
approval,
None,
None,
[],
[],
[],
remote,
True,
)
assert candidate_etunel["passed"] is False
assert any("packaged" in error for error in candidate_etunel["errors"])
bare = root / "remote.git"
subprocess.run(["git", "init", "--bare", "-q", str(bare)], check=True)
git(psp, "remote", "add", "fixture", str(bare))
git(psp, "push", "-q", "fixture", f"{commit}:refs/heads/candidate")
reachable = render_psp_handoff.verify_remote(str(bare), commit)
assert reachable["passed"] is True, reachable
print("PSP handoff renderer tests passed")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""Behavioral tests for validate_bsp_handoff.py."""
import hashlib
import json
import tempfile
from pathlib import Path
import validate_bsp_handoff
def main() -> int:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
imgs = root / "imgs" / "20260902_1"
imgs.mkdir(parents=True)
artifact = imgs / "boot.bin"
artifact.write_bytes(b"boot")
kernel = imgs / "kernel.bin"
kernel.write_bytes(b"kernel")
rootfs = imgs / "rootfs.bin"
rootfs.write_bytes(b"rootfs")
project = root / "CV1841CP-BOARD_dualos"
partition = project / "configs" / "board" / "partition" / "partition_spinand.xml"
partition.parent.mkdir(parents=True)
partition.write_text(
"""<physical_partition type="spinand">
<partition label="BOOT" alias="kernel.A" size_in_kb="1024" file="kernel.bin"/>
<partition label="ROOTFS" alias="rootfs.A" size_in_kb="1024" file="rootfs.bin"/>
</physical_partition>
""",
encoding="utf-8",
)
checks = []
for image in (kernel, rootfs):
size = image.stat().st_size
checks.append(
{
"image": image.name,
"image_path": str(image),
"image_size_bytes": size,
"partitions": [
{
"label": "BOOT" if image.name == "kernel.bin" else "ROOTFS",
"alias": "kernel.A" if image.name == "kernel.bin" else "rootfs.A",
"file": image.name,
"size_in_kb": 1024,
"size_bytes": 1024 * 1024,
}
],
"effective_partition_size_bytes": 1024 * 1024,
"required_margin_bytes": 512 * 1024,
"actual_margin_bytes": 1024 * 1024 - size,
"passed": True,
}
)
margin = {
"schema_version": 1,
"passed": True,
"checked_at": "2026-09-02T00:00:00+00:00",
"partition_table": str(partition),
"partition_table_sha256": hashlib.sha256(partition.read_bytes()).hexdigest(),
"required_margin_bytes": 512 * 1024,
"checks": checks,
}
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")
manifest = root / "psp_handoff.json"
payload = {
"schema_version": 1,
"handoff_type": "bsp-to-psp",
"producer": {
"project": "CV1841CP-BOARD_dualos",
"project_path": str(project),
},
"baseline": {
"chip": "CV1841CP",
"sdk": "cv184x_v6.3.3",
"board_build_type": "cv1841cp_wevb_0015a_spinand",
},
"build": {
"version": "20260902_1",
"status": "build_passed",
"exit_code": 0,
"completion_marker_seen": True,
"imgs_version_path": str(imgs),
"image_partition_margin_path": str(margin_path),
},
"artifacts": [
{
"relative_path": item.name,
"absolute_path": str(item),
"size": item.stat().st_size,
"sha256": hashlib.sha256(item.read_bytes()).hexdigest(),
}
for item in (artifact, kernel, rootfs)
],
"validation": {
"image_partition_margin": margin,
"hardware": "pending",
"owner_acceptance": "pending",
"release": "not-authorized",
},
}
manifest.write_text(json.dumps(payload), encoding="utf-8")
result = validate_bsp_handoff.validate_handoff(
manifest,
"CV1841CP-BOARD_dualos",
"CV1841CP",
"cv184x_v6.3.3",
"cv1841cp_wevb_0015a_spinand",
)
assert result["passed"] is True, result
assert result["dependency"]["verified_artifact_count"] == 3
assert result["dependency"]["build_id"] == "20260902_1"
assert result["dependency"]["chip"] == "CV1841CP"
assert result["dependency"]["image_partition_margin"]["passed"] is True
assert result["dependency"]["artifacts"][0]["sha256"]
assert result["warnings"]
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")
missing_margin_manifest = root / "missing_margin_handoff.json"
missing_margin_manifest.write_text(
json.dumps(missing_margin_payload), encoding="utf-8"
)
missing_margin = validate_bsp_handoff.validate_handoff(missing_margin_manifest)
assert missing_margin["passed"] is False
assert any("分区余量证据" in error for error in missing_margin["errors"])
artifact.write_bytes(b"tampered")
tampered = validate_bsp_handoff.validate_handoff(manifest)
assert tampered["passed"] is False
assert any("大小不匹配" in error for error in tampered["errors"])
print("BSP handoff consumer tests passed")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,325 @@
#!/usr/bin/env python3
"""Behavioral tests for validate_psp.py."""
import hashlib
import json
import tempfile
from pathlib import Path
import validate_psp
def main() -> int:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
psp = root / "psp"
(psp / "configs").mkdir(parents=True)
(psp / "scripts").mkdir()
(psp / "AiPlugin").mkdir()
artifact_root = root / "imgs" / "20260902_1"
artifact_root.mkdir(parents=True)
artifact = artifact_root / "boot.bin"
artifact.write_bytes(b"boot")
digest = hashlib.sha256(artifact.read_bytes()).hexdigest()
kernel = artifact_root / "kernel.bin"
kernel.write_bytes(b"kernel")
rootfs = artifact_root / "rootfs.bin"
rootfs.write_bytes(b"rootfs")
(psp / "psp.json").write_text(
json.dumps(
{
"EmxMediaVersion": "EmxMedia-1",
"EmxAiPluginVersion": "EmxAiPlugin-1",
"EmxResToProduct": [{"src": str(artifact), "dst": "imgs/boot.bin"}],
}
),
encoding="utf-8",
)
(psp / "AiPlugin" / "profile.json").write_text(
json.dumps({"EmxSdkVersion": "EmxSdk-1"}), encoding="utf-8"
)
(psp / "configs" / "same.json").write_text(
json.dumps(
{
"source": "psp",
"tool_cache": "/var/tmp/psp-tool-only",
"endpoint": "https://git.example/psp",
}
),
encoding="utf-8",
)
sensor_dir = psp / "configs" / "media" / "sensor"
sensor_dir.mkdir(parents=True)
(sensor_dir / "sensor_cfg.ini").write_text(
"[sensor]\nname = 0X201C3B40\n", encoding="utf-8"
)
expected_sensors = [("sensor", "SMS_SC4336P_MIPI_4M_30FPS_10BIT")]
(psp / "scripts" / "start.sh").write_text("#!/bin/sh\ntrue\n", encoding="utf-8")
app = root / "orgConfigs"
app.mkdir()
(app / "same.json").write_text('{"source":"app"}', encoding="utf-8")
(psp / "metadata").mkdir()
(psp / "metadata" / "allowed-overrides.json").write_text(
json.dumps(
{
"schema_version": 1,
"overrides": [
{
"psp_path": "configs/same.json",
"application_path": "orgConfigs/same.json",
"winner": "psp",
"reason": "board config",
"confirmed_by": "PSP owner",
"confirmed_at": "2026-09-02T00:00:00+00:00",
}
],
}
),
encoding="utf-8",
)
handoff = root / "psp_handoff.json"
bsp_project = root / "CV1841CP-NEW_dualos"
partition = bsp_project / "configs" / "board" / "partition" / "partition_spinand.xml"
partition.parent.mkdir(parents=True)
partition.write_text(
"""<physical_partition type="spinand">
<partition label="BOOT" alias="kernel.A" size_in_kb="1024" file="kernel.bin"/>
<partition label="ROOTFS" alias="rootfs.A" size_in_kb="1024" file="rootfs.bin"/>
</physical_partition>
""",
encoding="utf-8",
)
margin_checks = []
for image in (kernel, rootfs):
size = image.stat().st_size
margin_checks.append(
{
"image": image.name,
"image_path": str(image),
"image_size_bytes": size,
"partitions": [
{
"label": "BOOT" if image.name == "kernel.bin" else "ROOTFS",
"alias": "kernel.A" if image.name == "kernel.bin" else "rootfs.A",
"file": image.name,
"size_in_kb": 1024,
"size_bytes": 1024 * 1024,
}
],
"effective_partition_size_bytes": 1024 * 1024,
"required_margin_bytes": 512 * 1024,
"actual_margin_bytes": 1024 * 1024 - size,
"passed": True,
}
)
margin = {
"schema_version": 1,
"passed": True,
"checked_at": "2026-09-02T00:00:00+00:00",
"partition_table": str(partition),
"partition_table_sha256": hashlib.sha256(partition.read_bytes()).hexdigest(),
"required_margin_bytes": 512 * 1024,
"checks": margin_checks,
}
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")
handoff.write_text(
json.dumps(
{
"schema_version": 1,
"handoff_type": "bsp-to-psp",
"producer": {
"project": "CV1841CP-NEW_dualos",
"project_path": str(bsp_project),
},
"baseline": {
"chip": "CV1841CP",
"sdk": "cv184x-v1",
"board_build_type": "cv1841cp_wevb_0015a_spinand",
"system_form": "dual-os",
},
"build": {
"version": "20260902_1",
"status": "build_passed",
"exit_code": 0,
"completion_marker_seen": True,
"imgs_version_path": str(artifact_root),
"image_partition_margin_path": str(margin_path),
},
"artifacts": [
{
"relative_path": item.name,
"absolute_path": str(item),
"size": item.stat().st_size,
"sha256": hashlib.sha256(item.read_bytes()).hexdigest(),
}
for item in (artifact, kernel, rootfs)
],
"validation": {
"image_partition_margin": margin,
"hardware": "pending",
"owner_acceptance": "pending",
"release": "not-authorized",
},
}
),
encoding="utf-8",
)
lock = psp / "metadata" / "bsp.lock.json"
result = validate_psp.validate_psp(
psp,
"NEW",
"EmxSdk-1",
"EmxMedia-1",
"EmxAiPlugin-1",
handoff,
lock,
"CV1841CP-NEW_dualos",
"CV1841CP",
"cv184x-v1",
"cv1841cp_wevb_0015a_spinand",
"dual-os",
[],
[(app, "configs")],
formal=False,
expected_sensors=expected_sensors,
)
assert result["passed"] is True, result
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 result["checks"]["configuration_overrides"][0]["allowed"] is True
assert any("个人或临时目录" in warning for warning in result["warnings"])
assert any(
item["source"] == "configs/same.json"
for item in result["checks"]["absolute_paths"]
)
assert all(
"git.example" not in item["path"]
for item in result["checks"]["absolute_paths"]
)
assert result["checks"]["sensor_baseline"]["sensors"][0]["matched"] is True
assert result["checks"]["sensor_baseline"]["enum_header"]["source"] == "bundled"
assert result["checks"]["sensor_baseline"]["enum_header"]["path"] == str(
validate_psp.bundled_sensor_header("cv184x")
)
cv184_items = validate_psp.parse_sensor_enum(
validate_psp.bundled_sensor_header("cv184x"), "_SNS_TYPE_E"
)
assert len(cv184_items) == 244
assert cv184_items["SMS_SC4336P_MIPI_4M_30FPS_10BIT"] == 0X201C3B40
assert cv184_items["IMGDS_MIS40C1_MIPI_4M_20FPS_1L_10BIT"] == 0X00003A00
assert "BYD_BF2253L_MIPI_1200P_30FPS_10BIT" not in cv184_items
formal = validate_psp.validate_psp(
psp,
"NEW",
"EmxSdk-1",
"EmxMedia-1",
"EmxAiPlugin-1",
handoff,
None,
"CV1841CP-NEW_dualos",
"CV1841CP",
"cv184x-v1",
"cv1841cp_wevb_0015a_spinand",
"dual-os",
[artifact_root, handoff],
[(app, "configs")],
formal=True,
expected_sensors=expected_sensors,
)
assert formal["passed"] is False
assert any(
"configs/same.json" in error and "个人或临时目录" in error
for error in formal["errors"]
)
artifact.write_bytes(b"tampered")
tampered = validate_psp.validate_psp(
psp,
"NEW",
None,
None,
None,
handoff,
None,
"CV1841CP-NEW_dualos",
"CV1841CP",
"cv184x-v1",
"cv1841cp_wevb_0015a_spinand",
"dual-os",
[],
[],
formal=False,
expected_sensors=expected_sensors,
)
assert tampered["passed"] is False
assert any("BSP handoff" in error for error in tampered["errors"])
missing_identity = validate_psp.validate_psp(
psp,
"NEW",
None,
None,
None,
handoff,
None,
None,
None,
None,
None,
None,
[],
[],
formal=False,
expected_sensors=expected_sensors,
)
assert missing_identity["passed"] is False
assert any("必需身份字段" in error for error in missing_identity["errors"])
(sensor_dir / "sensor_cfg.ini").write_text(
"[sensor]\nname = SMS_SC4336P_MIPI_4M_30FPS_10BIT\n", encoding="utf-8"
)
sensor_errors: list[str] = []
validate_psp.validate_sensor_baseline(
psp,
"CV1841CP",
None,
expected_sensors,
sensor_errors,
)
assert any("必须填写" in error and "枚举值" in error for error in sensor_errors)
cv181_psp = root / "cv181-psp"
cv181_sensor_dir = cv181_psp / "configs" / "media" / "isp"
cv181_sensor_dir.mkdir(parents=True)
(cv181_sensor_dir / "sensor_cfg.ini").write_text(
"[sensor]\nname = SMS_SC4336P_MIPI_4M_30FPS_10BIT\n", encoding="utf-8"
)
cv181_errors: list[str] = []
cv181_result = validate_psp.validate_sensor_baseline(
cv181_psp,
"CV1815J",
None,
expected_sensors,
cv181_errors,
)
assert cv181_errors == [], cv181_errors
assert cv181_result["sensors"][0]["matched"] is True
assert cv181_result["enum_header"]["source"] == "bundled"
cv181_items = validate_psp.parse_sensor_enum(
validate_psp.bundled_sensor_header("cv181x"), "_SAMPLE_SNS_TYPE_E"
)
assert len(cv181_items) == 213
assert "SMS_SC4336P_MIPI_4M_30FPS_10BIT" in cv181_items
print("PSP validator tests passed")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,332 @@
#!/usr/bin/env python3
"""Validate a BSP-to-PSP handoff and its immutable artifact hashes."""
from __future__ import annotations
import argparse
import hashlib
import json
import xml.etree.ElementTree as ET
from pathlib import Path
REQUIRED_IMAGE_MARGIN_BYTES = 512 * 1024
REQUIRED_PARTITION_IMAGES = {"kernel.bin", "rootfs.bin"}
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for block in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def within(path: Path, root: Path) -> bool:
try:
path.relative_to(root)
return True
except ValueError:
return False
def validate_image_partition_margin(
payload: object,
build: dict[str, object],
producer: dict[str, object],
verified_artifacts: list[dict[str, object]],
errors: list[str],
) -> dict[str, object] | None:
if not isinstance(payload, dict) or payload.get("passed") is not True:
errors.append("BSP handoff 缺少通过的 kernel/rootfs 分区余量证据")
return None
if payload.get("schema_version") != 1:
errors.append("kernel/rootfs 分区余量证据 schema_version 必须为 1")
if payload.get("required_margin_bytes") != REQUIRED_IMAGE_MARGIN_BYTES:
errors.append("kernel/rootfs 分区余量阈值必须精确为 524288 字节")
project_value = producer.get("project_path")
table_value = payload.get("partition_table")
table_hash = payload.get("partition_table_sha256")
table_path: Path | None = None
if (
not isinstance(project_value, str)
or not Path(project_value).is_absolute()
or not isinstance(table_value, str)
or not Path(table_value).is_absolute()
):
errors.append("分区余量证据缺少绝对 BSP project_path/partition_table")
else:
project_path = Path(project_value).resolve(strict=False)
table_path = Path(table_value).resolve(strict=False)
if not within(table_path, project_path / "configs"):
errors.append("分区余量证据的 partition_table 不在 BSP 项目 configs/ 内")
if not table_path.is_file() or table_path.is_symlink():
errors.append(f"分区余量证据的 partition_table 不可读: {table_path}")
table_path = None
elif not isinstance(table_hash, str) or sha256_file(table_path) != table_hash:
errors.append("分区余量证据的 partition_table SHA-256 不匹配")
evidence_value = build.get("image_partition_margin_path")
if not isinstance(evidence_value, str) or not Path(evidence_value).is_absolute():
errors.append("build.image_partition_margin_path 必须是绝对路径")
else:
evidence_path = Path(evidence_value).resolve(strict=False)
if not evidence_path.is_file() or evidence_path.is_symlink():
errors.append(f"镜像分区余量证据文件不可读: {evidence_path}")
else:
try:
external_payload = json.loads(evidence_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
errors.append(f"镜像分区余量证据文件解析失败: {exc}")
else:
if external_payload != payload:
errors.append("handoff 内嵌分区余量证据与外部证据文件不一致")
partitions_by_image: dict[str, list[int]] = {}
if table_path is not None:
try:
table_root = ET.parse(table_path).getroot()
except ET.ParseError as exc:
errors.append(f"分区表 XML 解析失败: {exc}")
else:
for element in table_root.iter("partition"):
image_name = Path(element.attrib.get("file", "")).name
if image_name not in REQUIRED_PARTITION_IMAGES:
continue
raw_size = element.attrib.get("size_in_kb")
try:
size_bytes = int(raw_size or "", 10) * 1024
except ValueError:
errors.append(f"分区表中 {image_name} 的 size_in_kb 非法")
continue
partitions_by_image.setdefault(image_name, []).append(size_bytes)
artifacts_by_name: dict[str, list[dict[str, object]]] = {}
for artifact in verified_artifacts:
name = Path(str(artifact.get("relative_path", ""))).name
artifacts_by_name.setdefault(name, []).append(artifact)
checks = payload.get("checks")
if not isinstance(checks, list):
errors.append("kernel/rootfs 分区余量证据 checks 必须是数组")
return dict(payload)
checks_by_image = {
item.get("image"): item for item in checks if isinstance(item, dict)
}
if set(checks_by_image) != REQUIRED_PARTITION_IMAGES or len(checks) != 2:
errors.append("分区余量证据必须且只能包含 kernel.bin、rootfs.bin 两项")
for image_name in sorted(REQUIRED_PARTITION_IMAGES):
check = checks_by_image.get(image_name)
artifacts_for_image = artifacts_by_name.get(image_name, [])
if not isinstance(check, dict):
continue
if len(artifacts_for_image) != 1:
errors.append(f"已验证 artifacts 中 {image_name} 必须唯一")
continue
artifact = artifacts_for_image[0]
sizes = partitions_by_image.get(image_name, [])
if not sizes:
errors.append(f"当前分区表未找到 {image_name} 的有效分区")
continue
image_size = artifact.get("size")
effective_size = min(sizes)
actual_margin = effective_size - int(image_size) if isinstance(image_size, int) else None
if check.get("passed") is not True:
errors.append(f"{image_name} 分区余量检查未通过")
if check.get("image_path") != artifact.get("absolute_path"):
errors.append(f"{image_name} 分区余量证据的镜像路径不一致")
if check.get("image_size_bytes") != image_size:
errors.append(f"{image_name} 分区余量证据的镜像大小不一致")
if check.get("effective_partition_size_bytes") != effective_size:
errors.append(f"{image_name} 分区余量证据的最小分区大小不一致")
if check.get("required_margin_bytes") != REQUIRED_IMAGE_MARGIN_BYTES:
errors.append(f"{image_name} 要求余量不是 524288 字节")
if check.get("actual_margin_bytes") != actual_margin:
errors.append(f"{image_name} 分区实际余量计算不一致")
if actual_margin is None or actual_margin < REQUIRED_IMAGE_MARGIN_BYTES:
errors.append(f"{image_name} 相对最小对应分区的余量不足 512 KiB")
return dict(payload)
def validate_handoff(
manifest_path: Path,
expected_project: str | None = None,
expected_chip: str | None = None,
expected_sdk: str | None = None,
expected_build_type: str | None = None,
expected_system_form: str | None = None,
) -> dict[str, object]:
errors: list[str] = []
warnings: list[str] = []
manifest_path = manifest_path.expanduser().resolve(strict=True)
payload = json.loads(manifest_path.read_text(encoding="utf-8"))
if payload.get("schema_version") != 1:
errors.append("schema_version 必须为 1")
if payload.get("handoff_type") != "bsp-to-psp":
errors.append("handoff_type 必须为 bsp-to-psp")
producer = payload.get("producer")
baseline = payload.get("baseline")
build = payload.get("build")
validation = payload.get("validation")
artifacts = payload.get("artifacts")
if not isinstance(producer, dict):
errors.append("缺少 producer 对象")
producer = {}
if not isinstance(baseline, dict):
errors.append("缺少 baseline 对象")
baseline = {}
if not isinstance(build, dict):
errors.append("缺少 build 对象")
build = {}
if not isinstance(validation, dict):
errors.append("缺少 validation 对象")
validation = {}
if not isinstance(artifacts, list) or not artifacts:
errors.append("artifacts 必须是非空数组")
artifacts = []
expected_values = {
"project": (producer.get("project"), expected_project),
"chip": (baseline.get("chip"), expected_chip),
"sdk": (baseline.get("sdk"), expected_sdk),
"build_type": (baseline.get("board_build_type"), expected_build_type),
"system_form": (baseline.get("system_form"), expected_system_form),
}
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 build.get("status") != "build_passed" or build.get("exit_code") != 0:
errors.append("BSP 构建状态不是 build_passed/exit_code=0")
if build.get("completion_marker_seen") is not True:
errors.append("BSP 构建未记录正常完成标记")
imgs_value = build.get("imgs_version_path")
imgs_path: Path | None = None
if not isinstance(imgs_value, str) or not Path(imgs_value).is_absolute():
errors.append("imgs_version_path 必须是精确的绝对构建版本目录")
else:
imgs_path = Path(imgs_value).resolve(strict=False)
if not imgs_path.is_dir() or imgs_path.is_symlink():
errors.append(f"imgs_version_path 不存在或不是普通目录: {imgs_path}")
verified = 0
verified_artifacts: list[dict[str, object]] = []
seen_relative_paths: set[str] = set()
for index, artifact in enumerate(artifacts):
prefix = f"artifacts[{index}]"
if not isinstance(artifact, dict):
errors.append(f"{prefix} 必须是对象")
continue
relative = artifact.get("relative_path")
absolute = artifact.get("absolute_path")
size = artifact.get("size")
digest = artifact.get("sha256")
if not isinstance(relative, str) or not relative or Path(relative).is_absolute():
errors.append(f"{prefix}.relative_path 非法")
continue
if relative in seen_relative_paths:
errors.append(f"{prefix}.relative_path 重复: {relative}")
continue
seen_relative_paths.add(relative)
if not isinstance(absolute, str) or not Path(absolute).is_absolute():
errors.append(f"{prefix}.absolute_path 非法")
continue
path = Path(absolute).resolve(strict=False)
if imgs_path is None or not within(path, imgs_path):
errors.append(f"{prefix} 不在 imgs_version_path 内")
continue
if path != imgs_path / relative:
errors.append(f"{prefix} 的绝对/相对路径不一致")
continue
if not path.is_file() or path.is_symlink():
errors.append(f"{prefix} 的产物不存在或不是普通文件: {path}")
continue
if not isinstance(size, int) or size <= 0 or path.stat().st_size != size:
errors.append(f"{prefix} 的大小不匹配")
continue
if not isinstance(digest, str) or sha256_file(path) != digest:
errors.append(f"{prefix} 的 SHA-256 不匹配")
continue
verified += 1
verified_artifacts.append(
{
"relative_path": relative,
"absolute_path": str(path),
"size": size,
"sha256": digest,
}
)
image_partition_margin = validate_image_partition_margin(
validation.get("image_partition_margin"),
build,
producer,
verified_artifacts,
errors,
)
if validation.get("hardware") != "passed":
warnings.append(f"继承 BSP 硬件验证状态: {validation.get('hardware')!r}")
if validation.get("release") != "authorized":
warnings.append(f"继承 BSP 发布状态: {validation.get('release')!r}")
producer_git = producer.get("git")
if not isinstance(producer_git, dict) or not isinstance(producer_git.get("commit"), str):
warnings.append("BSP handoff 未记录可追踪的源码 commit")
return {
"passed": not errors,
"errors": errors,
"warnings": warnings,
"dependency": {
"module": "BSP",
"project": producer.get("project"),
"reference_project": producer.get("reference_project"),
"bsp_commit": producer_git.get("commit") if isinstance(producer_git, dict) else None,
"bsp_source_dirty": producer_git.get("dirty") if isinstance(producer_git, dict) else None,
"manifest_path": str(manifest_path),
"manifest_sha256": sha256_file(manifest_path),
"chip": baseline.get("chip"),
"sdk": baseline.get("sdk"),
"architecture": baseline.get("architecture"),
"flash": baseline.get("flash"),
"build_type": baseline.get("board_build_type"),
"system_form": baseline.get("system_form"),
"build_id": build.get("version"),
"imgs_version_path": str(imgs_path) if imgs_path else imgs_value,
"artifact_count": len(artifacts),
"verified_artifact_count": verified,
"artifacts": verified_artifacts,
"image_partition_margin": image_partition_margin,
"hardware_validation": validation.get("hardware"),
"owner_acceptance": validation.get("owner_acceptance"),
"release": validation.get("release"),
},
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("manifest", type=Path)
parser.add_argument("--project")
parser.add_argument("--chip")
parser.add_argument("--sdk")
parser.add_argument("--build-type")
parser.add_argument("--system-form", choices=("single-os", "dual-os"))
args = parser.parse_args()
try:
result = validate_handoff(
args.manifest,
args.project,
args.chip,
args.sdk,
args.build_type,
args.system_form,
)
except (OSError, json.JSONDecodeError) as exc:
result = {"passed": False, "errors": [str(exc)], "warnings": []}
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0 if result.get("passed") is True else 2
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,873 @@
#!/usr/bin/env python3
"""Validate a project PSP without changing BSP, application, or packaging inputs."""
from __future__ import annotations
import argparse
import configparser
import hashlib
import json
import os
import re
import subprocess
import sys
import tempfile
from pathlib import Path, PurePosixPath
from typing import Any, Iterator
import validate_bsp_handoff
PERSONAL_OR_TEMP_ROOTS = (Path("/home"), Path("/tmp"), Path("/var/tmp"))
TEXT_PATH_SUFFIXES = {".cfg", ".conf", ".ini", ".py", ".sh"}
ABSOLUTE_PATH_RE = re.compile(r"(?<![A-Za-z0-9_.-])(/[A-Za-z0-9_@%+=:,./-]+)")
BUILD_INPUT_JSONS = {"psp.json", "metadata/bsp.lock.json"}
SENSOR_SECTION_RE = re.compile(r"^sensor(?:\d+)?$", re.IGNORECASE)
C_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
SKILL_ROOT = Path(__file__).resolve().parents[2]
BUNDLED_SENSOR_HEADERS = {
"cv181x": SKILL_ROOT / "references" / "sensor-enums" / "cv181x" / "sensor_list.h",
"cv184x": SKILL_ROOT / "references" / "sensor-enums" / "cv184x" / "sensor_cfg.h",
}
class ValidationError(RuntimeError):
"""The requested validation cannot be performed safely."""
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for block in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def write_json_atomic(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, prefix=f".{path.name}.", 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 load_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def normalize_relative(value: str, field: str) -> str:
pure = PurePosixPath(value)
if pure.is_absolute() or ".." in pure.parts or value in {"", "."}:
raise ValidationError(f"{field} 必须是仓库内精确相对路径: {value!r}")
if any(character in value for character in "*?[]"):
raise ValidationError(f"{field} 不允许通配符: {value!r}")
return pure.as_posix()
def iter_strings(value: Any, location: str = "$") -> Iterator[tuple[str, str]]:
if isinstance(value, dict):
for key, child in value.items():
yield from iter_strings(child, f"{location}.{key}")
elif isinstance(value, list):
for index, child in enumerate(value):
yield from iter_strings(child, f"{location}[{index}]")
elif isinstance(value, str):
yield location, value
def under(path: Path, root: Path) -> bool:
try:
path.relative_to(root)
return True
except ValueError:
return False
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:
continue
relative = path.relative_to(root).as_posix()
try:
payloads[relative] = load_json(path)
except (OSError, json.JSONDecodeError) as exc:
errors.append(f"JSON 解析失败 {relative}: {exc}")
return payloads
def validate_ini(root: Path, errors: list[str]) -> int:
count = 0
for path in sorted(root.rglob("*.ini")):
if ".git" in path.parts:
continue
parser = configparser.ConfigParser(interpolation=None, strict=False)
try:
with path.open("r", encoding="utf-8") as handle:
parser.read_file(handle)
count += 1
except (OSError, UnicodeError, configparser.Error) as exc:
errors.append(f"INI 解析失败 {path.relative_to(root).as_posix()}: {exc}")
return count
def parse_expected_sensor(value: str) -> tuple[str, str]:
if "=" not in value:
raise argparse.ArgumentTypeError(
"expected-sensor 必须为 INI_SECTION=ENUM_NAME"
)
section, enum_name = (item.strip() for item in value.split("=", 1))
if not SENSOR_SECTION_RE.fullmatch(section):
raise argparse.ArgumentTypeError(f"Sensor section 非法: {section!r}")
if not C_IDENTIFIER_RE.fullmatch(enum_name):
raise argparse.ArgumentTypeError(f"Sensor 枚举名称非法: {enum_name!r}")
return section.casefold(), enum_name
def cv18_sensor_family(chip: str | None) -> str | None:
normalized = re.sub(r"[^A-Za-z0-9]", "", chip or "").upper()
if normalized.startswith("CV181"):
return "cv181x"
if normalized.startswith("CV184"):
return "cv184x"
return None
def bundled_sensor_header(family: str) -> Path:
path = BUNDLED_SENSOR_HEADERS.get(family)
if path is None:
raise ValidationError(f"没有 {family!r} 对应的内置 Sensor 枚举基线")
path = path.resolve(strict=True)
if not path.is_file() or path.is_symlink():
raise ValidationError(f"内置 Sensor 枚举基线缺失或不是普通文件: {path}")
return path
def parse_sensor_enum(header: Path, enum_tag: str) -> dict[str, int | None]:
header = header.expanduser().resolve(strict=True)
if not header.is_file() or header.is_symlink():
raise ValidationError(f"Sensor 枚举头文件必须是普通文件: {header}")
text = header.read_text(encoding="utf-8", errors="replace")
match = re.search(
rf"typedef\s+enum\s+{re.escape(enum_tag)}\s*\{{(.*?)\}}\s*"
r"[A-Za-z_][A-Za-z0-9_]*\s*;",
text,
flags=re.DOTALL,
)
if match is None:
raise ValidationError(f"头文件中未找到 {enum_tag}: {header}")
body = re.sub(r"/\*.*?\*/", "", match.group(1), flags=re.DOTALL)
body = "\n".join(
line.split("//", 1)[0]
for line in body.splitlines()
if not line.lstrip().startswith("#")
)
result: dict[str, int | None] = {}
for raw in body.split(","):
item = raw.strip()
if not item:
continue
name, separator, expression = item.partition("=")
name = name.strip()
if not C_IDENTIFIER_RE.fullmatch(name):
continue
numeric: int | None = None
if separator:
literal = expression.strip().rstrip("uUlL")
if re.fullmatch(r"(?:0[xX][0-9A-Fa-f]+|[0-9]+)", literal):
numeric = int(literal, 0)
result[name] = numeric
if not result:
raise ValidationError(f"{enum_tag} 未解析出任何枚举项: {header}")
return result
def validate_sensor_baseline(
psp_root: Path,
expected_chip: str | None,
sensor_enum_header: Path | None,
expected_sensors: list[tuple[str, str]],
errors: list[str],
) -> dict[str, object]:
family = cv18_sensor_family(expected_chip)
result: dict[str, object] = {"applicable": family is not None, "family": family}
if family is None:
if sensor_enum_header is not None or expected_sensors:
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()
)
if len(candidates) != 1:
errors.append(
f"CV18 PSP 必须唯一定位一个 sensor_cfg.ini,实际 {len(candidates)} 个"
)
result["config_candidates"] = [str(path) for path in candidates]
return result
config_path = candidates[0]
result["config_path"] = str(config_path)
result["config_sha256"] = sha256_file(config_path)
header_source = "override" if sensor_enum_header is not None else "bundled"
header = (
sensor_enum_header.expanduser().resolve(strict=True)
if sensor_enum_header is not None
else bundled_sensor_header(family)
)
expected_filename = "sensor_list.h" if family == "cv181x" else "sensor_cfg.h"
enum_tag = "_SAMPLE_SNS_TYPE_E" if family == "cv181x" else "_SNS_TYPE_E"
if header.name != expected_filename:
errors.append(
f"{family.upper()} 必须使用 {expected_filename},实际 {header.name}"
)
enum_items = parse_sensor_enum(header, enum_tag)
result["enum_header"] = {
"path": str(header),
"sha256": sha256_file(header),
"enum_tag": enum_tag,
"representation": "enum-name" if family == "cv181x" else "enum-value",
"source": header_source,
}
expected: dict[str, str] = {}
for section, enum_name in expected_sensors:
if section in expected:
errors.append(f"expected-sensor 重复指定 section: {section}")
expected[section] = enum_name
parser = configparser.ConfigParser(interpolation=None, strict=False)
try:
with config_path.open("r", encoding="utf-8") as handle:
parser.read_file(handle)
except (OSError, UnicodeError, configparser.Error) as exc:
errors.append(f"sensor_cfg.ini 解析失败: {exc}")
return result
configured = {
section.casefold(): parser.get(section, "name").strip()
for section in parser.sections()
if SENSOR_SECTION_RE.fullmatch(section) and parser.has_option(section, "name")
}
if not configured:
errors.append("sensor_cfg.ini 未找到带 name 的 [sensor*] section")
if not expected_sensors:
errors.append("CV18 Sensor 校验必须提供规格书选定的 expected-sensor")
missing_expectations = sorted(set(configured) - set(expected))
unknown_sections = sorted(set(expected) - set(configured))
if missing_expectations:
errors.append(
"以下 Sensor section 缺少规格书选定的枚举名称: "
+ ", ".join(missing_expectations)
)
if unknown_sections:
errors.append(
"expected-sensor 指向不存在或缺少 name 的 section: "
+ ", ".join(unknown_sections)
)
checks: list[dict[str, object]] = []
for section in sorted(configured):
configured_name = configured[section]
enum_name = expected.get(section)
item: dict[str, object] = {
"section": section,
"configured_name": configured_name,
"expected_enum_name": enum_name,
"matched": False,
}
if enum_name is None:
checks.append(item)
continue
if enum_name not in enum_items:
errors.append(f"{section} 规格枚举名称不在 {enum_tag} 中: {enum_name}")
checks.append(item)
continue
if family == "cv181x":
item["expected_config_value"] = enum_name
item["matched"] = configured_name == enum_name
if configured_name != enum_name:
errors.append(
f"{section}.name 必须填写枚举名称 {enum_name},实际 {configured_name}"
)
else:
expected_value = enum_items[enum_name]
item["expected_config_value"] = (
f"0X{expected_value:08X}" if expected_value is not None else None
)
try:
actual_value = int(configured_name, 0)
except ValueError:
actual_value = None
item["configured_numeric_value"] = actual_value
item["matched"] = expected_value is not None and actual_value == expected_value
if expected_value is None:
errors.append(f"{enum_name} 在 {enum_tag} 中不是可解析的整数枚举值")
elif actual_value != expected_value:
errors.append(
f"{section}.name 必须填写 {enum_name} 的枚举值 "
f"0X{expected_value:08X},实际 {configured_name}"
)
checks.append(item)
result["sensors"] = checks
return result
def validate_shell(root: Path, errors: list[str]) -> int:
count = 0
for path in sorted(root.rglob("*.sh")):
if ".git" in path.parts:
continue
result = subprocess.run(
["bash", "-n", str(path)],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
count += 1
if result.returncode != 0:
message = result.stderr.strip() or result.stdout.strip()
errors.append(f"Shell 语法失败 {path.relative_to(root).as_posix()}: {message}")
return count
def path_risk(path: Path) -> str | None:
resolved = path.resolve(strict=False)
for root in PERSONAL_OR_TEMP_ROOTS:
if under(resolved, root):
return str(root)
return None
def text_absolute_paths(root: Path) -> Iterator[tuple[str, str, str]]:
for path in sorted(root.rglob("*")):
if (
not path.is_file()
or path.is_symlink()
or ".git" in path.parts
or path.suffix.lower() not in TEXT_PATH_SUFFIXES
or path.stat().st_size > 4 * 1024 * 1024
):
continue
data = path.read_bytes()
if b"\x00" in data:
continue
try:
text = data.decode("utf-8")
except UnicodeDecodeError:
continue
source = path.relative_to(root).as_posix()
for line_number, line in enumerate(text.splitlines(), 1):
for match in ABSOLUTE_PATH_RE.finditer(line):
if (
match.start() >= 1
and line[match.start() - 1] == ":"
and match.group(1).startswith("//")
):
continue
value = match.group(1).rstrip(".,;:)]}'\"")
if value != "/":
yield source, f"line:{line_number}", value
def normalize_allowed_roots(values: list[Path]) -> list[Path]:
roots: list[Path] = []
for value in values:
expanded = value.expanduser()
if not expanded.is_absolute():
raise ValidationError(f"allowed-absolute-root 必须是绝对路径: {value}")
roots.append(expanded.resolve(strict=False))
return roots
def validate_absolute_paths(
root: Path,
payloads: dict[str, Any],
allowed_roots: list[Path],
formal: bool,
errors: list[str],
warnings: list[str],
) -> list[dict[str, object]]:
entries: list[dict[str, object]] = []
normalized_allowed = normalize_allowed_roots(allowed_roots)
references: list[tuple[str, str, str]] = []
for source, payload in payloads.items():
for location, value in iter_strings(payload):
if value.startswith("/"):
references.append((source, location, value))
references.extend(text_absolute_paths(root))
seen: set[tuple[str, str, str]] = set()
for source, location, value in references:
key = (source, location, value)
if key in seen:
continue
seen.add(key)
path = Path(value).resolve(strict=False)
exists = path.exists()
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
entry = {
"source": source,
"location": location,
"path": value,
"resolved_path": str(path),
"exists": exists,
"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:
message = f"外部绝对路径不存在 {source}:{location}: {value}"
if build_input:
errors.append(f"外部绝对路径不存在 {source}:{location}: {value}")
else:
warnings.append(message)
if risk_root:
message = f"绝对路径位于个人或临时目录 {source}:{location}: {value}"
(errors if formal else warnings).append(message)
return entries
def load_allowed_overrides(psp_root: Path, errors: list[str]) -> set[tuple[str, str]]:
path = psp_root / "metadata" / "allowed-overrides.json"
if not path.exists():
return set()
try:
payload = load_json(path)
except (OSError, json.JSONDecodeError) as exc:
errors.append(f"allowed-overrides.json 解析失败: {exc}")
return set()
if not isinstance(payload, dict) or payload.get("schema_version") != 1:
errors.append("metadata/allowed-overrides.json schema_version 必须为 1")
return set()
items = payload.get("overrides")
if not isinstance(items, list):
errors.append("metadata/allowed-overrides.json.overrides 必须是数组")
return set()
allowed: set[tuple[str, str]] = set()
for index, item in enumerate(items):
if not isinstance(item, dict):
errors.append(f"allowed-overrides.json.overrides[{index}] 必须是对象")
continue
try:
psp_path = normalize_relative(str(item.get("psp_path", "")), "psp_path")
app_path = normalize_relative(
str(item.get("application_path", "")), "application_path"
)
except ValidationError as exc:
errors.append(str(exc))
continue
if item.get("winner") != "psp":
errors.append(f"allowed-overrides.json.overrides[{index}].winner 必须为 psp")
for field in ("reason", "confirmed_by", "confirmed_at"):
if not isinstance(item.get(field), str) or not str(item.get(field)).strip():
errors.append(f"allowed-overrides.json.overrides[{index}].{field} 不能为空")
allowed.add((app_path, psp_path))
return allowed
def parse_override_map(value: str) -> tuple[Path, str]:
if "=" not in value:
raise argparse.ArgumentTypeError("override-map 必须为 APP_DIR=PSP_SUBDIR")
app, psp_subdir = value.split("=", 1)
try:
relative = normalize_relative(psp_subdir, "PSP_SUBDIR")
except ValidationError as exc:
raise argparse.ArgumentTypeError(str(exc)) from exc
return Path(app).expanduser(), relative
def scan_overrides(
psp_root: Path,
mappings: list[tuple[Path, str]],
allowed: set[tuple[str, str]],
formal: bool,
errors: list[str],
warnings: list[str],
) -> list[dict[str, object]]:
conflicts: list[dict[str, object]] = []
for app_dir_raw, psp_subdir in mappings:
app_dir = app_dir_raw.resolve(strict=False)
psp_dir = psp_root / psp_subdir
if not app_dir.is_dir():
errors.append(f"应用配置目录不存在: {app_dir}")
continue
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()):
relative = app_path.relative_to(app_dir)
psp_path = psp_dir / relative
if not psp_path.is_file():
continue
app_relative = app_path.as_posix()
if app_dir_raw.is_absolute():
app_key = f"{app_dir_raw.name}/{relative.as_posix()}"
else:
app_key = (app_dir_raw / relative).as_posix()
psp_key = (PurePosixPath(psp_subdir) / relative.as_posix()).as_posix()
is_allowed = (app_key, psp_key) in allowed
conflict = {
"application_path": app_relative,
"application_key": app_key,
"application_sha256": sha256_file(app_path),
"psp_path": psp_path.as_posix(),
"psp_key": psp_key,
"psp_sha256": sha256_file(psp_path),
"winner": "psp",
"allowed": is_allowed,
}
conflicts.append(conflict)
if not is_allowed:
message = f"未确认的 PSP 覆盖: {app_key} -> {psp_key}"
(errors if formal else warnings).append(message)
return conflicts
def build_bsp_lock(dependency: dict[str, object], warnings: list[str]) -> dict[str, object]:
return {
"schema_version": 1,
"handoff_path": dependency.get("manifest_path"),
"handoff_sha256": dependency.get("manifest_sha256"),
"bsp_project": dependency.get("project"),
"bsp_commit": dependency.get("bsp_commit"),
"bsp_source_dirty": dependency.get("bsp_source_dirty"),
"chip": dependency.get("chip"),
"sdk": dependency.get("sdk"),
"build_type": dependency.get("build_type"),
"system_form": dependency.get("system_form"),
"build_id": dependency.get("build_id"),
"imgs_version_path": dependency.get("imgs_version_path"),
"artifacts": dependency.get("artifacts", []),
"image_partition_margin": dependency.get("image_partition_margin"),
"mixed_build": None,
"warnings": warnings,
}
def mixed_artifact_map(psp_root: Path, errors: list[str]) -> dict[str, str]:
path = psp_root / "metadata" / "bsp.lock.json"
if not path.is_file():
return {}
try:
payload = load_json(path)
except (OSError, json.JSONDecodeError) as exc:
errors.append(f"metadata/bsp.lock.json 解析失败: {exc}")
return {}
if not isinstance(payload, dict) or payload.get("schema_version") != 1:
errors.append("metadata/bsp.lock.json schema_version 必须为 1")
return {}
mixed = payload.get("mixed_build")
if mixed is None:
return {}
if not isinstance(mixed, dict) or not isinstance(mixed.get("artifacts"), list):
errors.append("metadata/bsp.lock.json.mixed_build.artifacts 必须是数组")
return {}
result: dict[str, str] = {}
for index, item in enumerate(mixed["artifacts"]):
if not isinstance(item, dict):
errors.append(f"mixed_build.artifacts[{index}] 必须是对象")
continue
absolute = item.get("absolute_path")
digest = item.get("sha256")
required = ("build_id", "bsp_commit", "reason", "confirmed_by", "confirmed_at")
if not isinstance(absolute, str) or not Path(absolute).is_absolute():
errors.append(f"mixed_build.artifacts[{index}].absolute_path 非法")
continue
if not isinstance(digest, str) or len(digest) != 64:
errors.append(f"mixed_build.artifacts[{index}].sha256 非法")
continue
if any(not isinstance(item.get(field), str) or not item.get(field) for field in required):
errors.append(f"mixed_build.artifacts[{index}] 缺少来源/确认字段")
continue
result[str(Path(absolute).resolve(strict=False))] = digest
return result
def validate_bsp_sources(
psp_root: Path,
psp_json: dict[str, Any],
dependency: dict[str, object] | None,
errors: list[str],
) -> list[dict[str, object]]:
copied = psp_json.get("EmxResToProduct")
if not isinstance(copied, list):
return []
primary: dict[str, str] = {}
if dependency:
for item in dependency.get("artifacts", []):
if isinstance(item, dict) and isinstance(item.get("absolute_path"), str):
primary[str(Path(item["absolute_path"]).resolve(strict=False))] = str(
item.get("sha256", "")
)
mixed = mixed_artifact_map(psp_root, errors)
results: list[dict[str, object]] = []
for index, item in enumerate(copied):
if not isinstance(item, dict) or not isinstance(item.get("src"), str):
continue
value = item["src"]
if not value.startswith("/"):
continue
path = Path(value).resolve(strict=False)
source = "primary" if str(path) in primary else "mixed" if str(path) in mixed else None
expected_hash = primary.get(str(path)) or mixed.get(str(path))
actual_hash = sha256_file(path) if path.is_file() and not path.is_symlink() else None
if source is None:
errors.append(
f"psp.json.EmxResToProduct[{index}].src 未被 BSP handoff 或 mixed_build 锁定: {value}"
)
elif actual_hash != expected_hash:
errors.append(f"psp.json.EmxResToProduct[{index}].src hash 与 BSP 锁定不一致")
results.append(
{
"index": index,
"path": str(path),
"source": source,
"expected_sha256": expected_hash,
"actual_sha256": actual_hash,
}
)
return results
def find_key_values(payloads: dict[str, Any], normalized_key: str) -> list[dict[str, str]]:
found: list[dict[str, str]] = []
def visit(source: str, value: Any, location: str) -> None:
if isinstance(value, dict):
for key, child in value.items():
child_location = f"{location}.{key}"
if key.casefold().replace("_", "") == normalized_key and isinstance(child, str):
found.append({"source": source, "location": child_location, "value": child})
visit(source, child, child_location)
elif isinstance(value, list):
for index, child in enumerate(value):
visit(source, child, f"{location}[{index}]")
for source, payload in payloads.items():
visit(source, payload, "$")
return found
def validate_psp(
psp_root: Path,
project: str | None,
expected_sdk: str | None,
expected_media: str | None,
expected_ai_plugin: str | None,
bsp_handoff_path: Path | None,
bsp_lock_output: Path | None,
expected_bsp_project: str | None,
expected_chip: str | None,
expected_bsp_sdk: str | None,
expected_build_type: str | None,
expected_system_form: str | None,
allowed_absolute_roots: list[Path],
override_maps: list[tuple[Path, str]],
formal: bool,
sensor_enum_header: Path | None = None,
expected_sensors: list[tuple[str, str]] | None = None,
) -> dict[str, object]:
errors: list[str] = []
warnings: list[str] = []
psp_root = psp_root.expanduser().resolve(strict=True)
if not psp_root.is_dir() or psp_root.is_symlink():
raise ValidationError(f"PSP 根目录必须是普通目录: {psp_root}")
psp_json_path = psp_root / "psp.json"
if not psp_json_path.is_file() or psp_json_path.is_symlink():
raise ValidationError(f"缺少普通文件 psp.json: {psp_json_path}")
payloads = collect_json(psp_root, errors)
psp_json = payloads.get("psp.json")
if not isinstance(psp_json, dict):
errors.append("psp.json 根节点必须是对象")
psp_json = {}
ini_count = validate_ini(psp_root, errors)
shell_count = validate_shell(psp_root, errors)
paths = validate_absolute_paths(
psp_root, payloads, allowed_absolute_roots, formal, errors, warnings
)
required_identity = {
"project": project,
"expected_bsp_project": expected_bsp_project,
"expected_chip": expected_chip,
"expected_bsp_sdk": expected_bsp_sdk,
"expected_build_type": expected_build_type,
"expected_system_form": expected_system_form,
}
for field, value in required_identity.items():
if not isinstance(value, str) or not value.strip():
errors.append(f"缺少候选 PSP 必需身份字段: {field}")
version_checks: dict[str, object] = {}
checks = (
("emxsdkversion", expected_sdk, "SDK"),
("emxmediaversion", expected_media, "Media"),
("emxaipluginversion", expected_ai_plugin, "AIPlugin"),
)
for key, expected, label in checks:
values = find_key_values(payloads, key)
version_checks[label] = {"expected": expected, "found": values}
if expected is not None:
if not values:
errors.append(f"未找到可核对的 {label} 版本字段")
for item in values:
if item["value"] != expected:
errors.append(
f"{label} 版本不匹配 {item['source']}:{item['location']}: "
f"期望 {expected!r},实际 {item['value']!r}"
)
dependency: dict[str, object] | None = None
if bsp_handoff_path is not None:
bsp_result = validate_bsp_handoff.validate_handoff(
bsp_handoff_path,
expected_project=expected_bsp_project,
expected_chip=expected_chip,
expected_sdk=expected_bsp_sdk,
expected_build_type=expected_build_type,
expected_system_form=expected_system_form,
)
if bsp_result.get("passed") is not True:
errors.extend(f"BSP handoff: {item}" for item in bsp_result.get("errors", []))
warnings.extend(f"BSP handoff: {item}" for item in bsp_result.get("warnings", []))
raw_dependency = bsp_result.get("dependency")
if isinstance(raw_dependency, dict):
dependency = raw_dependency
if dependency and bsp_lock_output is not None and bsp_result.get("passed") is True:
output = bsp_lock_output.expanduser().resolve(strict=False)
if output != psp_root / "metadata" / "bsp.lock.json":
raise ValidationError("bsp-lock-output 必须精确为 PSP/metadata/bsp.lock.json")
write_json_atomic(output, build_bsp_lock(dependency, list(bsp_result["warnings"])))
elif bsp_lock_output is not None:
raise ValidationError("写 bsp.lock.json 时必须同时提供 --bsp-handoff")
else:
errors.append("未提供 BSP handoff;不能形成可交付 candidate")
bsp_sources = validate_bsp_sources(psp_root, psp_json, dependency, errors)
sensor_baseline = validate_sensor_baseline(
psp_root,
expected_chip,
sensor_enum_header,
expected_sensors or [],
errors,
)
allowed = load_allowed_overrides(psp_root, errors)
conflicts = scan_overrides(
psp_root, override_maps, allowed, formal, errors, warnings
)
result: dict[str, object] = {
"schema_version": 1,
"passed": not errors,
"mode": "formal" if formal else "local",
"project": project,
"psp_root": str(psp_root),
"checks": {
"json_files": len(payloads),
"ini_files": ini_count,
"shell_files": shell_count,
"absolute_paths": paths,
"versions": version_checks,
"bsp_dependency": dependency,
"bsp_sources": bsp_sources,
"sensor_baseline": sensor_baseline,
"configuration_overrides": conflicts,
},
"errors": errors,
"warnings": warnings,
}
return result
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("psp_root", type=Path)
parser.add_argument("--project", required=True)
parser.add_argument("--expected-sdk")
parser.add_argument("--expected-media-version")
parser.add_argument("--expected-ai-plugin-version")
parser.add_argument("--bsp-handoff", type=Path, required=True)
parser.add_argument("--bsp-lock-output", type=Path)
parser.add_argument("--expected-bsp-project", required=True)
parser.add_argument("--expected-chip", required=True)
parser.add_argument("--expected-bsp-sdk", required=True)
parser.add_argument("--expected-build-type", required=True)
parser.add_argument(
"--expected-system-form", required=True, choices=("single-os", "dual-os")
)
parser.add_argument(
"--sensor-enum-header",
type=Path,
help=(
"可选覆盖:CV181X 的 sensor_list.h 或 CV184X 的 sensor_cfg.h;"
"默认按芯片使用 skill 内置基线"
),
)
parser.add_argument(
"--expected-sensor",
action="append",
default=[],
type=parse_expected_sensor,
metavar="INI_SECTION=ENUM_NAME",
help="规格书选定的 Sensor 枚举名称;每个 [sensor*] section 重复一次",
)
parser.add_argument(
"--allowed-absolute-root",
action="append",
default=[],
type=Path,
help="显式批准的团队共享或当前验证根;可重复",
)
parser.add_argument(
"--override-map",
action="append",
default=[],
type=parse_override_map,
metavar="APP_DIR=PSP_SUBDIR",
)
parser.add_argument("--formal", action="store_true")
parser.add_argument("--output", type=Path)
return parser
def main() -> int:
args = build_parser().parse_args()
try:
result = validate_psp(
args.psp_root,
args.project,
args.expected_sdk,
args.expected_media_version,
args.expected_ai_plugin_version,
args.bsp_handoff,
args.bsp_lock_output,
args.expected_bsp_project,
args.expected_chip,
args.expected_bsp_sdk,
args.expected_build_type,
args.expected_system_form,
args.allowed_absolute_root,
args.override_map,
args.formal,
args.sensor_enum_header,
args.expected_sensor,
)
if args.output is not None:
write_json_atomic(args.output, result)
except (ValidationError, OSError, json.JSONDecodeError) as exc:
result = {"schema_version": 1, "passed": False, "errors": [str(exc)], "warnings": []}
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0 if result.get("passed") is True else 2
if __name__ == "__main__":
raise SystemExit(main())