feat: 发布固件底层开发 Skill 套件
This commit is contained in:
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())
|
||||
+844
@@ -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())
|
||||
+494
@@ -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())
|
||||
+357
@@ -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())
|
||||
+312
@@ -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())
|
||||
+142
@@ -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())
|
||||
+332
@@ -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())
|
||||
Reference in new issue
Block a user