874 lines
33 KiB
Python
Executable File
874 lines
33 KiB
Python
Executable File
#!/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())
|