Files

499 lines
19 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 | None,
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")
reference_group = parser.add_mutually_exclusive_group(required=True)
reference_group.add_argument("--reference-psp")
reference_group.add_argument("--no-reference", action="store_true")
parser.add_argument("--reference-commit")
parser.add_argument("--static-validation", type=Path, required=True)
parser.add_argument("--bsp-lock", type=Path, required=True)
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:
if args.no_reference and args.reference_commit:
raise HandoffError("无参考 PSP 时不能填写 --reference-commit")
remote_verification = None
if args.verify_remote:
if not args.repository_url:
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())