183 lines
7.7 KiB
Python
Executable File
183 lines
7.7 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Validate hardware evidence and its explicit sensor_cfg.ini expectations."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import configparser
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
|
|
def sha256_file(path: Path) -> str:
|
|
value = hashlib.sha256()
|
|
with path.open("rb") as handle:
|
|
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
|
value.update(block)
|
|
return value.hexdigest()
|
|
|
|
|
|
def write_json(path: Path, payload: dict[str, object]) -> None:
|
|
path = path.expanduser().resolve(strict=False)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle:
|
|
json.dump(payload, handle, ensure_ascii=False, indent=2)
|
|
handle.write("\n")
|
|
temporary = Path(handle.name)
|
|
os.replace(temporary, path)
|
|
|
|
|
|
def normalized(value: object) -> str:
|
|
text = str(value).strip()
|
|
try:
|
|
return str(int(text, 0))
|
|
except ValueError:
|
|
return text.casefold()
|
|
|
|
|
|
def validate(psp: Path, topology_path: Path) -> dict[str, object]:
|
|
errors: list[str] = []
|
|
psp = psp.expanduser().resolve(strict=True)
|
|
topology_path = topology_path.expanduser().resolve(strict=True)
|
|
topology = json.loads(topology_path.read_text(encoding="utf-8"))
|
|
if not isinstance(topology, dict) or topology.get("schema_version") != 1:
|
|
raise ValueError("hardware-topology.json 必须是 schema_version=1 的对象")
|
|
|
|
for field in ("project", "chip", "system_mode", "storage"):
|
|
if not topology.get(field):
|
|
errors.append(f"硬件拓扑缺少字段: {field}")
|
|
storage = topology.get("storage")
|
|
if not isinstance(storage, dict) or not storage.get("type") or not isinstance(storage.get("capacity_bytes"), int):
|
|
errors.append("storage 必须包含 type 和整数 capacity_bytes;型号未知时可省略 model")
|
|
|
|
sources = topology.get("sources")
|
|
source_results: list[dict[str, object]] = []
|
|
if not isinstance(sources, list) or not sources:
|
|
errors.append("硬件拓扑至少需要一个可校验证据源")
|
|
sources = []
|
|
for index, item in enumerate(sources):
|
|
if not isinstance(item, dict):
|
|
errors.append(f"sources[{index}] 必须是对象")
|
|
continue
|
|
raw_path, expected = item.get("path"), item.get("sha256")
|
|
if not isinstance(raw_path, str) or not raw_path:
|
|
errors.append(f"sources[{index}].path 不能为空")
|
|
continue
|
|
source_path = Path(raw_path).expanduser()
|
|
if not source_path.is_absolute():
|
|
source_path = (topology_path.parent / source_path).resolve(strict=False)
|
|
exists = source_path.is_file() and not source_path.is_symlink()
|
|
actual = sha256_file(source_path) if exists else None
|
|
if not exists:
|
|
errors.append(f"硬件证据源不存在或不是普通文件: {source_path}")
|
|
if not isinstance(expected, str) or len(expected) != 64 or expected != actual:
|
|
errors.append(f"硬件证据源 hash 不匹配: {source_path}")
|
|
source_results.append({"path": str(source_path), "expected_sha256": expected, "actual_sha256": actual})
|
|
|
|
conflicts = topology.get("conflicts")
|
|
if not isinstance(conflicts, list):
|
|
errors.append("conflicts 必须是数组")
|
|
elif conflicts:
|
|
errors.append("硬件资料仍有未解决冲突,禁止进入 PSP 适配")
|
|
|
|
sensor_files = sorted(
|
|
path for path in psp.rglob("sensor_cfg.ini")
|
|
if path.is_file() and not path.is_symlink() and ".git" not in path.parts
|
|
)
|
|
if len(sensor_files) != 1:
|
|
errors.append(f"必须唯一定位 sensor_cfg.ini,实际 {len(sensor_files)} 个")
|
|
parser = None
|
|
else:
|
|
parser = configparser.ConfigParser(interpolation=None, strict=False)
|
|
try:
|
|
parser.read(sensor_files[0], encoding="utf-8")
|
|
except configparser.Error as exc:
|
|
errors.append(f"sensor_cfg.ini 解析失败: {exc}")
|
|
parser = None
|
|
|
|
sensors = topology.get("sensors")
|
|
if not isinstance(sensors, list) or not sensors:
|
|
errors.append("sensors 必须是非空数组")
|
|
sensors = []
|
|
required_sensor_fields = (
|
|
"section", "model", "enum_name", "i2c_bus", "i2c_address",
|
|
"mipi_device", "lane_count", "evidence_sources", "config_expectations",
|
|
)
|
|
seen_sections: set[str] = set()
|
|
checks: list[dict[str, object]] = []
|
|
for index, sensor in enumerate(sensors):
|
|
if not isinstance(sensor, dict):
|
|
errors.append(f"sensors[{index}] 必须是对象")
|
|
continue
|
|
missing = [field for field in required_sensor_fields if field not in sensor]
|
|
if missing:
|
|
errors.append(f"sensors[{index}] 缺少字段: {missing}")
|
|
continue
|
|
section = str(sensor["section"])
|
|
if section.casefold() in seen_sections:
|
|
errors.append(f"Sensor section 重复: {section}")
|
|
seen_sections.add(section.casefold())
|
|
evidence_indexes = sensor.get("evidence_sources")
|
|
if not isinstance(evidence_indexes, list) or not evidence_indexes or any(
|
|
not isinstance(value, int) or value < 0 or value >= len(sources)
|
|
for value in evidence_indexes
|
|
):
|
|
errors.append(f"{section}.evidence_sources 必须引用有效 sources 索引")
|
|
expectations = sensor.get("config_expectations")
|
|
row_checks: list[dict[str, object]] = []
|
|
if not isinstance(expectations, dict) or not expectations:
|
|
errors.append(f"{section}.config_expectations 必须明确列出配置字段")
|
|
elif parser is None or not parser.has_section(section):
|
|
errors.append(f"sensor_cfg.ini 缺少硬件拓扑声明的 section: {section}")
|
|
else:
|
|
for key, expected in sorted(expectations.items()):
|
|
actual = parser.get(section, key, fallback=None)
|
|
matched = actual is not None and normalized(actual) == normalized(expected)
|
|
row_checks.append({"key": key, "expected": expected, "actual": actual, "matched": matched})
|
|
if not matched:
|
|
errors.append(f"{section}.{key} 与硬件拓扑不一致:期望 {expected!r},实际 {actual!r}")
|
|
checks.append({"section": section, "model": sensor.get("model"), "checks": row_checks})
|
|
|
|
configured_sections = [] if parser is None else [
|
|
name for name in parser.sections() if name.casefold().startswith("sensor")
|
|
]
|
|
if len(configured_sections) != len(sensors):
|
|
errors.append(f"Sensor 数量不一致:拓扑 {len(sensors)},配置 {len(configured_sections)}")
|
|
|
|
return {
|
|
"schema_version": 1,
|
|
"passed": not errors,
|
|
"topology_path": str(topology_path),
|
|
"topology_sha256": sha256_file(topology_path),
|
|
"psp": str(psp),
|
|
"sensor_cfg": str(sensor_files[0]) if len(sensor_files) == 1 else None,
|
|
"sources": source_results,
|
|
"sensor_checks": checks,
|
|
"errors": errors,
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--psp", type=Path, required=True)
|
|
parser.add_argument("--topology", type=Path, required=True)
|
|
parser.add_argument("--output", type=Path)
|
|
args = parser.parse_args()
|
|
try:
|
|
result = validate(args.psp, args.topology)
|
|
if args.output:
|
|
write_json(args.output, result)
|
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
return 0 if result["passed"] else 2
|
|
except (OSError, UnicodeError, ValueError, json.JSONDecodeError) as exc:
|
|
print(json.dumps({"passed": False, "error": str(exc)}, ensure_ascii=False, indent=2))
|
|
return 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|