feat: strengthen BSP and PSP validation and handoff
This commit is contained in:
1 parent
bcea35a91e
commit
1641fac631
39 files changed
+2695
-90
No files matched your search
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Classify a target PSP against target HEAD, reference HEAD, and reference worktree."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class DeltaError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def run_git(repo: Path, *args: str, binary: bool = False):
|
||||
return subprocess.run(
|
||||
["git", "-C", str(repo), *args],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=not binary,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def repository(path: Path, field: str) -> Path:
|
||||
path = path.expanduser().resolve(strict=True)
|
||||
if not path.is_dir() or path.is_symlink():
|
||||
raise DeltaError(f"{field} 必须是普通目录: {path}")
|
||||
top = run_git(path, "rev-parse", "--show-toplevel")
|
||||
if top.returncode != 0 or Path(top.stdout.strip()).resolve() != path:
|
||||
raise DeltaError(f"{field} 必须是独立 Git 仓库根目录: {path}")
|
||||
head = run_git(path, "rev-parse", "--verify", "HEAD")
|
||||
if head.returncode != 0:
|
||||
raise DeltaError(f"{field} 缺少可追踪 HEAD")
|
||||
return path
|
||||
|
||||
|
||||
def digest(kind: str, mode: str, data: bytes) -> str:
|
||||
value = hashlib.sha256()
|
||||
value.update(kind.encode("ascii") + b"\0" + mode.encode("ascii") + b"\0" + data)
|
||||
return value.hexdigest()
|
||||
|
||||
|
||||
def head_snapshot(repo: Path) -> dict[str, str]:
|
||||
listing = run_git(repo, "ls-tree", "-r", "-z", "--full-tree", "HEAD", binary=True)
|
||||
if listing.returncode != 0:
|
||||
raise DeltaError(listing.stderr.decode(errors="replace"))
|
||||
result: dict[str, str] = {}
|
||||
for raw in listing.stdout.split(b"\0"):
|
||||
if not raw:
|
||||
continue
|
||||
metadata, raw_path = raw.split(b"\t", 1)
|
||||
mode, kind, _object_id = metadata.decode("ascii").split(" ", 2)
|
||||
path = raw_path.decode("utf-8")
|
||||
if kind != "blob" or mode == "160000":
|
||||
raise DeltaError(f"仓库包含不支持的特殊条目: {path}")
|
||||
content = run_git(repo, "show", f"HEAD:{path}", binary=True)
|
||||
if content.returncode != 0:
|
||||
raise DeltaError(f"无法读取 HEAD 文件: {path}")
|
||||
result[path] = digest("symlink" if mode == "120000" else "file", mode, content.stdout)
|
||||
return result
|
||||
|
||||
|
||||
def worktree_snapshot(repo: Path) -> dict[str, str]:
|
||||
listing = run_git(
|
||||
repo, "ls-files", "-z", "--cached", "--others", "--exclude-standard", binary=True
|
||||
)
|
||||
if listing.returncode != 0:
|
||||
raise DeltaError(listing.stderr.decode(errors="replace"))
|
||||
result: dict[str, str] = {}
|
||||
for raw in listing.stdout.split(b"\0"):
|
||||
if not raw:
|
||||
continue
|
||||
relative = raw.decode("utf-8")
|
||||
path = repo / relative
|
||||
if path.is_symlink():
|
||||
result[relative] = digest("symlink", "120000", os.readlink(path).encode())
|
||||
elif path.is_file():
|
||||
mode = "100755" if os.access(path, os.X_OK) else "100644"
|
||||
result[relative] = digest("file", mode, path.read_bytes())
|
||||
return result
|
||||
|
||||
|
||||
def classify(
|
||||
target_head: dict[str, str],
|
||||
target_worktree: dict[str, str],
|
||||
reference_head: dict[str, str],
|
||||
reference_worktree: dict[str, str],
|
||||
) -> dict[str, object]:
|
||||
categories: dict[str, list[str]] = {
|
||||
"matches_reference_head": [],
|
||||
"matches_reference_dirty_worktree": [],
|
||||
"target_unchanged": [],
|
||||
"target_specific": [],
|
||||
"unclassified_conflict": [],
|
||||
}
|
||||
rows: list[dict[str, object]] = []
|
||||
all_paths = sorted(
|
||||
set(target_head) | set(target_worktree) | set(reference_head) | set(reference_worktree)
|
||||
)
|
||||
for path in all_paths:
|
||||
th = target_head.get(path)
|
||||
tw = target_worktree.get(path)
|
||||
rh = reference_head.get(path)
|
||||
rw = reference_worktree.get(path)
|
||||
if tw == rh and (path in target_worktree or path in reference_head):
|
||||
category = "matches_reference_head"
|
||||
elif tw == rw and rw != rh and (
|
||||
path in target_head or path in reference_head or path in target_worktree or path in reference_worktree
|
||||
):
|
||||
category = "matches_reference_dirty_worktree"
|
||||
elif tw == th:
|
||||
category = "target_unchanged"
|
||||
elif tw not in {rh, rw}:
|
||||
category = "target_specific"
|
||||
else:
|
||||
category = "unclassified_conflict"
|
||||
categories[category].append(path)
|
||||
rows.append(
|
||||
{
|
||||
"path": path,
|
||||
"category": category,
|
||||
"present": {
|
||||
"target_head": th is not None,
|
||||
"target_worktree": tw is not None,
|
||||
"reference_head": rh is not None,
|
||||
"reference_worktree": rw is not None,
|
||||
},
|
||||
}
|
||||
)
|
||||
return {"categories": categories, "paths": rows}
|
||||
|
||||
|
||||
def analyze(target: Path, reference: Path) -> dict[str, object]:
|
||||
target = repository(target, "target")
|
||||
reference = repository(reference, "reference")
|
||||
target_head = head_snapshot(target)
|
||||
target_worktree = worktree_snapshot(target)
|
||||
reference_head = head_snapshot(reference)
|
||||
reference_worktree = worktree_snapshot(reference)
|
||||
analysis = classify(target_head, target_worktree, reference_head, reference_worktree)
|
||||
target_commit = run_git(target, "rev-parse", "HEAD").stdout.strip()
|
||||
reference_commit = run_git(reference, "rev-parse", "HEAD").stdout.strip()
|
||||
reference_dirty = reference_head != reference_worktree
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"passed": True,
|
||||
"target": {"path": str(target), "head": target_commit, "dirty": target_head != target_worktree},
|
||||
"reference": {
|
||||
"path": str(reference),
|
||||
"head": reference_commit,
|
||||
"dirty": reference_dirty,
|
||||
"dirty_paths": sorted(
|
||||
path
|
||||
for path in set(reference_head) | set(reference_worktree)
|
||||
if reference_head.get(path) != reference_worktree.get(path)
|
||||
),
|
||||
},
|
||||
**analysis,
|
||||
}
|
||||
|
||||
|
||||
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 main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--target", type=Path, required=True)
|
||||
parser.add_argument("--reference", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
result = analyze(args.target, args.reference)
|
||||
if args.output:
|
||||
write_json(args.output, result)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
except (DeltaError, OSError, UnicodeError) 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,263 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only CV181X Release/default/PSP configuration inventory and graph audit."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def digest(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def git_provenance(root: Path | None):
|
||||
if root is None:
|
||||
return None
|
||||
def git(*args):
|
||||
return subprocess.run(["git", "-C", str(root), *args], capture_output=True,
|
||||
text=True, check=True).stdout.strip()
|
||||
return {"path": str(root.resolve()), "commit": git("rev-parse", "HEAD"),
|
||||
"dirty_paths": git("status", "--short", "--untracked-files=all").splitlines()}
|
||||
|
||||
|
||||
def load(path: Path):
|
||||
if not path.is_file() or path.is_symlink():
|
||||
raise ValueError(f"需要普通 JSON 文件: {path}")
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def leaves(value, prefix=""):
|
||||
if isinstance(value, dict):
|
||||
for key, item in value.items():
|
||||
yield from leaves(item, f"{prefix}.{key}" if prefix else key)
|
||||
elif isinstance(value, list):
|
||||
for index, item in enumerate(value):
|
||||
yield from leaves(item, f"{prefix}[{index}]")
|
||||
else:
|
||||
yield prefix, value
|
||||
|
||||
|
||||
def config_files(profile: Path, root_name: str):
|
||||
paths = [profile / root_name, *sorted((profile / "configs").glob("*.json"))]
|
||||
if root_name == "profile.json":
|
||||
paths.append(profile / "AiPlugin.json")
|
||||
if not paths[0].is_file() or not (profile / "configs").is_dir():
|
||||
raise ValueError(f"Release profile 不完整: {profile}")
|
||||
return {path.relative_to(profile).as_posix(): path for path in paths if path.is_file()}
|
||||
|
||||
|
||||
@lru_cache(maxsize=2)
|
||||
def source_index(source_root: Path, profile_name: str) -> dict[str, list[str]]:
|
||||
index: dict[str, list[str]] = {}
|
||||
pattern = re.compile(r'\[\s*"([^"]+)"\s*\]')
|
||||
chip_root = source_root / "chip/cvitek/cv181x"
|
||||
build = source_root / "profile" / profile_name / "build.cmake"
|
||||
selected = set()
|
||||
if build.is_file():
|
||||
build_text = build.read_text(encoding="utf-8", errors="replace")
|
||||
for variant in re.findall(r'aux_source_directory\([^\n]*/chip/\$\{ChipManufactory\}/\$\{ChipSerial\}/([^\s)]+)', build_text):
|
||||
selected.add(profile_name if variant == "${Profile}" else variant)
|
||||
paths = set(chip_root.glob("*.cpp"))
|
||||
for variant in selected:
|
||||
paths.update((chip_root / variant).glob("*.cpp"))
|
||||
for path in sorted(paths):
|
||||
for number, line in enumerate(path.read_text(encoding="utf-8", errors="replace").splitlines(), 1):
|
||||
for key in pattern.findall(line):
|
||||
index.setdefault(key, []).append(f"{path.relative_to(source_root)}:{number}")
|
||||
return index
|
||||
|
||||
|
||||
def source_hits(source_root: Path | None, profile_name: str, key: str) -> list[str]:
|
||||
return source_index(source_root, profile_name).get(key, [])[:12] if source_root is not None else []
|
||||
|
||||
|
||||
def profile_inventory(profile: Path, root_name: str, source_profile: Path | None, source_root: Path | None):
|
||||
result = {}
|
||||
drift = []
|
||||
for relative, path in config_files(profile, root_name).items():
|
||||
source_path = source_profile / relative if source_profile and relative != "AiPlugin.json" else None
|
||||
if source_path is not None and (not source_path.is_file() or digest(path) != digest(source_path)):
|
||||
drift.append(relative)
|
||||
rows = []
|
||||
for field, default in leaves(load(path)):
|
||||
key = re.split(r"\.|\[", field)[-1]
|
||||
rows.append({
|
||||
"field": field,
|
||||
"release_default": default,
|
||||
"type": type(default).__name__,
|
||||
"source_hits_hint_only": source_hits(source_root, profile.name, key),
|
||||
})
|
||||
result[relative] = {"sha256": digest(path), "fields": rows}
|
||||
return result, drift
|
||||
|
||||
|
||||
def check_graph(media_system, media_files, ai_files):
|
||||
errors = []
|
||||
if not isinstance(media_system, list) or not media_system:
|
||||
return ["system.json 必须包含至少一个 System 通道"]
|
||||
for system_index, system in enumerate(media_system):
|
||||
groups = {g["vpssGrp"]: g for g in system.get("vpss", {}).get("group", [])}
|
||||
pools = len(system.get("vb", []))
|
||||
for group_id, group in groups.items():
|
||||
for attach in group.get("attachPool", []):
|
||||
if attach.get("chn", -1) not in range(len(group.get("chn", []))):
|
||||
errors.append(f"System[{system_index}] VPSS {group_id} attachPool.chn 不存在")
|
||||
if attach.get("pool", -1) not in range(pools):
|
||||
errors.append(f"System[{system_index}] VPSS {group_id} attachPool.pool 不存在")
|
||||
for bind in group.get("bind", []):
|
||||
parent = groups.get(bind.get("group"))
|
||||
if parent is None or bind.get("chn", -1) not in range(len(parent.get("chn", []))):
|
||||
errors.append(f"System[{system_index}] VPSS {group_id} bind 来源不存在")
|
||||
for relative in ("configs/venc.json", "configs/snap.json", "configs/osd.json", "configs/draw.json"):
|
||||
for index, item in enumerate(media_files.get(relative, [])):
|
||||
attr = item.get("attr", item)
|
||||
group_id = attr.get("VpssGrp")
|
||||
channel = attr.get("VpssChn")
|
||||
if group_id is not None and (group_id not in groups or channel not in range(len(groups[group_id].get("chn", [])))):
|
||||
errors.append(f"{relative}[{index}] VPSS {group_id}/{channel} 不存在")
|
||||
enabled = ai_files.get("configs/config.json", {})
|
||||
for relative, items in ai_files.items():
|
||||
if relative == "configs/config.json" or not isinstance(items, list):
|
||||
continue
|
||||
module = Path(relative).stem
|
||||
if enabled.get(module) is not True:
|
||||
continue
|
||||
for index, item in enumerate(items):
|
||||
if not isinstance(item, dict) or "vpssGrp" not in item:
|
||||
continue
|
||||
group_id, channel = item["vpssGrp"], item.get("vpssChn")
|
||||
if group_id not in groups or channel not in range(len(groups[group_id].get("chn", []))):
|
||||
errors.append(f"{relative}[{index}] VPSS {group_id}/{channel} 不存在")
|
||||
return errors
|
||||
|
||||
|
||||
def model_references(ai_files, ai_meta, psp=None):
|
||||
enabled = ai_files.get("configs/config.json", {})
|
||||
declared = set(ai_meta.get("runtime", {}).get("file", []))
|
||||
result = []
|
||||
for relative, items in ai_files.items():
|
||||
if not relative.startswith("configs/") or not isinstance(items, list):
|
||||
continue
|
||||
module = Path(relative).stem
|
||||
if enabled.get(module) is not True:
|
||||
continue
|
||||
for index, item in enumerate(items):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
for key, value in item.items():
|
||||
if not key.endswith("ModelPath") and key != "modelPath":
|
||||
continue
|
||||
if not isinstance(value, str) or not value:
|
||||
continue
|
||||
local = (psp / "AiPlugin" / value).is_file() if psp is not None else False
|
||||
result.append({"module": module, "channel": index, "field": key,
|
||||
"model": value, "release_runtime_declared": value in declared,
|
||||
"psp_file_present": local,
|
||||
"requires_source_or_packaging_evidence": value not in declared and not local})
|
||||
return result
|
||||
|
||||
|
||||
def audit(media_profile: Path, ai_profile: Path, media_source: Path | None = None,
|
||||
ai_source: Path | None = None, psp: Path | None = None):
|
||||
media_profile, ai_profile = media_profile.resolve(), ai_profile.resolve()
|
||||
if not media_profile.parent.name.startswith("EmxMedia-") or not ai_profile.parent.name.startswith("EmxAiPlugin-"):
|
||||
raise ValueError("必须指定精确 Release 版本下的 profile,不能指定源码 profile 或 Debug")
|
||||
media_root = media_profile.parent.name
|
||||
ai_root = ai_profile.parent.name
|
||||
media_meta = load(media_profile / "configs.json")
|
||||
ai_meta = load(ai_profile / "profile.json")
|
||||
ai_package = load(ai_profile / "AiPlugin.json")
|
||||
if any(meta.get("ChipSerial", "").lower() != "cv181x" for meta in (media_meta, ai_meta)):
|
||||
raise ValueError("当前只支持 CV181X Release")
|
||||
media_source_profile = media_source / "profile" / media_profile.name if media_source else None
|
||||
ai_source_profile = ai_source / "profile" / ai_profile.name if ai_source else None
|
||||
media_catalog, media_drift = profile_inventory(media_profile, "configs.json", media_source_profile, media_source)
|
||||
ai_catalog, ai_drift = profile_inventory(ai_profile, "profile.json", ai_source_profile, ai_source)
|
||||
errors = []
|
||||
for key in ("EmxSdkVersion", "ChipManufactory", "ChipSerial", "ChipSdkVersion", "Cross"):
|
||||
if media_meta.get(key) != ai_meta.get(key):
|
||||
errors.append(f"Media/AI {key} 不一致")
|
||||
if ai_package.get("profile") != ai_profile.name or ai_package.get("version") != ai_root.removeprefix("EmxAiPlugin-"):
|
||||
errors.append("AiPlugin.json 与所选 Release/profile 不一致")
|
||||
media_files = {rel: load(path) for rel, path in config_files(media_profile, "configs.json").items()}
|
||||
ai_files = {rel: load(path) for rel, path in config_files(ai_profile, "profile.json").items()}
|
||||
errors += check_graph(media_files["configs/system.json"], media_files, ai_files)
|
||||
candidate = []
|
||||
model_refs = model_references(ai_files, ai_meta, psp)
|
||||
if psp is not None:
|
||||
psp = psp.resolve()
|
||||
psp_json = load(psp / "psp.json")
|
||||
for key, expected in (("EmxMediaVersion", media_root), ("EmxMediaProfile", media_profile.name),
|
||||
("EmxAiPluginVersion", ai_root), ("EmxAiPluginProfile", ai_profile.name)):
|
||||
if psp_json.get(key) != expected:
|
||||
errors.append(f"psp.json {key} 与所选 Release 不一致")
|
||||
mapping = (("media", media_files, media_catalog, psp / "configs/media/scene/default"),
|
||||
("ai", ai_files, ai_catalog, psp / "AiPlugin/configs"))
|
||||
effective = {"media": dict(media_files), "ai": dict(ai_files)}
|
||||
for kind, defaults, catalog, target in mapping:
|
||||
for relative, baseline in defaults.items():
|
||||
if not relative.startswith("configs/"):
|
||||
continue
|
||||
path = target / Path(relative).name
|
||||
if not path.is_file():
|
||||
if kind == "ai":
|
||||
path = psp / "AiPlugin/configs_ro" / Path(relative).name
|
||||
if not path.is_file():
|
||||
candidate.append({"kind": kind, "file": relative, "state": "not_in_psp_check_effective_source"})
|
||||
continue
|
||||
current = load(path)
|
||||
effective[kind][relative] = current
|
||||
baseline_fields, current_fields = dict(leaves(baseline)), dict(leaves(current))
|
||||
for field in sorted(baseline_fields.keys() | current_fields.keys()):
|
||||
if field not in baseline_fields or field not in current_fields or baseline_fields[field] != current_fields[field]:
|
||||
candidate.append({"kind": kind, "file": str(path), "field": field,
|
||||
"release_default": baseline_fields.get(field),
|
||||
"candidate": current_fields.get(field),
|
||||
"state": "changed_or_missing_requires_evidence"})
|
||||
errors += check_graph(effective["media"]["configs/system.json"], effective["media"], effective["ai"])
|
||||
model_refs = model_references(effective["ai"], ai_meta, psp)
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"scope": "read_only_static_audit_not_hardware_or_runtime_validation",
|
||||
"media_release": str(media_profile), "ai_release": str(ai_profile),
|
||||
"media_sha256": digest(media_profile / "configs.json"),
|
||||
"ai_sha256": digest(ai_profile / "profile.json"),
|
||||
"source_release_drift": {"media": media_drift, "ai": ai_drift},
|
||||
"source_git": {"media": git_provenance(media_source), "ai": git_provenance(ai_source)},
|
||||
"field_counts": {"media": sum(len(x["fields"]) for x in media_catalog.values()),
|
||||
"ai": sum(len(x["fields"]) for x in ai_catalog.values())},
|
||||
"catalog": {"media": media_catalog, "ai": ai_catalog},
|
||||
"candidate_differences": candidate,
|
||||
"enabled_ai_model_references": model_refs,
|
||||
"errors": errors,
|
||||
"passed_static_cross_checks": not errors,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--media-release-profile", type=Path, required=True)
|
||||
parser.add_argument("--ai-release-profile", type=Path, required=True)
|
||||
parser.add_argument("--media-source", type=Path)
|
||||
parser.add_argument("--ai-source", type=Path)
|
||||
parser.add_argument("--psp", type=Path)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
result = audit(args.media_release_profile, args.ai_release_profile,
|
||||
args.media_source, args.ai_source, args.psp)
|
||||
except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError,
|
||||
subprocess.CalledProcessError) as exc:
|
||||
parser.exit(2, f"audit failed: {exc}\n")
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
if result["errors"]:
|
||||
parser.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -456,7 +456,7 @@ def inspect_project(target: Path, psp_root: Path = DEFAULT_PSP_ROOT) -> dict[str
|
||||
|
||||
|
||||
def prepare(
|
||||
reference: Path,
|
||||
reference: Path | None,
|
||||
target: Path,
|
||||
manifest_path: Path,
|
||||
old_tokens: list[str],
|
||||
@@ -464,9 +464,10 @@ def prepare(
|
||||
psp_root: Path = DEFAULT_PSP_ROOT,
|
||||
) -> dict[str, object]:
|
||||
psp_root = psp_root_directory(psp_root)
|
||||
reference = ordinary_directory(reference, "reference")
|
||||
if reference is not None:
|
||||
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:
|
||||
if reference is not None and (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)
|
||||
@@ -482,17 +483,22 @@ def prepare(
|
||||
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)
|
||||
if reference is not None:
|
||||
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) if reference is not None else None
|
||||
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:
|
||||
if reference is None:
|
||||
staging.mkdir()
|
||||
baseline_mode = "empty"
|
||||
warnings = ["无参考 PSP:必须按 BSP handoff、硬件资料、应用 schema 和精确 Release 建立每个配置;不可将 Release 当完整 PSP"]
|
||||
elif 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,不包含这些修改"]
|
||||
@@ -512,7 +518,7 @@ def prepare(
|
||||
"schema_version": 2,
|
||||
"created_at": now_utc(),
|
||||
"psp_root": str(psp_root),
|
||||
"reference": str(reference),
|
||||
"reference": str(reference) if reference is not None else None,
|
||||
"target": str(target),
|
||||
"staging": str(staging),
|
||||
"target_mode": "precreated-empty-repository",
|
||||
@@ -521,7 +527,7 @@ def prepare(
|
||||
"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),
|
||||
"excluded_paths": excluded_paths(reference) if reference is not None else [],
|
||||
"source_inventory": source_before,
|
||||
"staging_inventory_before_edits": staging_inventory,
|
||||
"warnings": warnings,
|
||||
@@ -529,7 +535,7 @@ def prepare(
|
||||
write_json_atomic(manifest_path, payload)
|
||||
return {
|
||||
"passed": True,
|
||||
"reference": str(reference),
|
||||
"reference": str(reference) if reference is not None else None,
|
||||
"target": str(target),
|
||||
"staging": str(staging),
|
||||
"psp_root": str(psp_root),
|
||||
@@ -550,12 +556,13 @@ def verify(
|
||||
) -> 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")
|
||||
reference = (ordinary_directory(Path(str(manifest["reference"])), "reference")
|
||||
if manifest.get("reference") is not None else None)
|
||||
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):
|
||||
if reference is not None and not reference_matches_manifest(reference, manifest):
|
||||
raise GuardError("prepare 后参考 PSP 发生变化;必须重新 prepare")
|
||||
links = scan_symlinks(staging)
|
||||
credentials = scan_credentials(staging)
|
||||
@@ -586,7 +593,7 @@ def verify(
|
||||
"schema_version": 1,
|
||||
"checked_at": now_utc(),
|
||||
"manifest": str(manifest_path),
|
||||
"reference": str(reference),
|
||||
"reference": str(reference) if reference is not None else None,
|
||||
"staging": str(staging),
|
||||
"target": manifest.get("target"),
|
||||
"allowed_paths": sorted(allowed),
|
||||
@@ -693,7 +700,8 @@ def audit_final(
|
||||
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")
|
||||
reference = (ordinary_directory(Path(str(manifest["reference"])), "reference")
|
||||
if manifest.get("reference") is not None else None)
|
||||
source = manifest.get("source_inventory")
|
||||
if not isinstance(source, dict):
|
||||
raise GuardError("manifest 缺少 source_inventory")
|
||||
@@ -732,8 +740,9 @@ def audit_final(
|
||||
"schema_version": 1,
|
||||
"checked_at": now_utc(),
|
||||
"manifest": str(manifest_path),
|
||||
"reference": str(reference),
|
||||
"reference_drifted_since_prepare": not reference_matches_manifest(reference, manifest),
|
||||
"reference": str(reference) if reference is not None else None,
|
||||
"reference_drifted_since_prepare": (not reference_matches_manifest(reference, manifest)
|
||||
if reference is not None else False),
|
||||
"target": str(target),
|
||||
"allowed_paths": sorted(allowed),
|
||||
"allowed_residue_paths": sorted(residue_allow),
|
||||
@@ -782,6 +791,11 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
prepare_parser.add_argument("--old-token", action="append", default=[])
|
||||
prepare_parser.add_argument("--include-dirty", action="store_true")
|
||||
|
||||
empty_parser = subparsers.add_parser("prepare-empty", help="无参考 PSP:为预创建空仓库准备空 staging")
|
||||
empty_parser.add_argument("--psp-root", type=Path, default=DEFAULT_PSP_ROOT)
|
||||
empty_parser.add_argument("--target", type=Path, required=True)
|
||||
empty_parser.add_argument("--manifest", type=Path, required=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=[])
|
||||
@@ -817,6 +831,8 @@ def main() -> int:
|
||||
args.include_dirty,
|
||||
args.psp_root,
|
||||
)
|
||||
elif args.command == "prepare-empty":
|
||||
result = prepare(None, args.target, args.manifest, [], False, args.psp_root)
|
||||
elif args.command == "verify":
|
||||
result = verify(args.manifest, args.allow_path, args.allow_residue, args.report)
|
||||
elif args.command == "finalize":
|
||||
|
||||
@@ -251,6 +251,8 @@ def create_contract(project_root: Path, chip: str, destination: Path) -> dict[st
|
||||
SIZE_RE = re.compile(r"^(\d+)([KMG])$", re.IGNORECASE)
|
||||
SIZE_MULTIPLIERS = {"K": 1024, "M": 1024 * 1024, "G": 1024 * 1024 * 1024}
|
||||
UBI_LEB_SIZE = 126976
|
||||
UBI_PEB_SIZE = 131072
|
||||
UBI_REQUIRED_RESERVE_PEBS = 8
|
||||
|
||||
|
||||
def parse_size(value: object, field: str) -> int | None:
|
||||
@@ -415,7 +417,13 @@ def packaging_preflight(
|
||||
errors.append(
|
||||
f"DATA 组包资源没有明确来源映射: EmxResToProduct 缺少 {required_dst}"
|
||||
)
|
||||
counts = {int(value) for value in re.findall(r"mkAiData\.py\s+(\d+)\b", release_text)}
|
||||
counts: set[int] = set()
|
||||
for line in release_text.splitlines():
|
||||
if line.lstrip().startswith("#"):
|
||||
continue
|
||||
counts.update(
|
||||
int(value) for value in re.findall(r"mkAiData\.py\s+(\d+)\b", line)
|
||||
)
|
||||
if len(counts) != 1:
|
||||
errors.append("无法从 release.py 唯一确定 mkAiData.py 的容量参数")
|
||||
elif isinstance(data_row.get("size_bytes"), int):
|
||||
@@ -423,9 +431,11 @@ def packaging_preflight(
|
||||
if count <= 0:
|
||||
errors.append("mkAiData.py 的容量参数必须大于 0")
|
||||
requested = count * UBI_LEB_SIZE
|
||||
if requested > int(data_row["size_bytes"]):
|
||||
reserved = UBI_REQUIRED_RESERVE_PEBS * UBI_PEB_SIZE
|
||||
if requested + reserved > int(data_row["size_bytes"]):
|
||||
errors.append(
|
||||
f"mkAiData.py 参数需要约 {requested} 字节,超过 data 分区 {data_row['size_bytes']} 字节"
|
||||
f"mkAiData.py 参数需要 {requested} 字节并保留 {reserved} 字节,"
|
||||
f"超过 data 分区 {data_row['size_bytes']} 字节"
|
||||
)
|
||||
|
||||
bsp_lock_path = source / "metadata" / "bsp.lock.json"
|
||||
|
||||
@@ -287,7 +287,7 @@ def render(
|
||||
status: str,
|
||||
psp_repo: Path,
|
||||
repository_url: str | None,
|
||||
reference_psp: str,
|
||||
reference_psp: str | None,
|
||||
reference_commit: str | None,
|
||||
static_path: Path,
|
||||
bsp_lock_path: Path,
|
||||
@@ -426,7 +426,9 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
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)
|
||||
reference_group = parser.add_mutually_exclusive_group(required=True)
|
||||
reference_group.add_argument("--reference-psp")
|
||||
reference_group.add_argument("--no-reference", action="store_true")
|
||||
parser.add_argument("--reference-commit")
|
||||
parser.add_argument("--static-validation", type=Path, required=True)
|
||||
parser.add_argument("--bsp-lock", type=Path, required=True)
|
||||
@@ -453,6 +455,8 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
def main() -> int:
|
||||
args = build_parser().parse_args()
|
||||
try:
|
||||
if args.no_reference and args.reference_commit:
|
||||
raise HandoffError("无参考 PSP 时不能填写 --reference-commit")
|
||||
remote_verification = None
|
||||
if args.verify_remote:
|
||||
if not args.repository_url:
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import analyze_psp_delta
|
||||
|
||||
|
||||
def git(repo: Path, *args: str) -> str:
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(repo), *args], text=True, capture_output=True, check=True
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def init(repo: Path, files: dict[str, str]) -> None:
|
||||
repo.mkdir()
|
||||
git(repo, "init", "-q")
|
||||
for name, content in files.items():
|
||||
path = repo / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
git(repo, "add", ".")
|
||||
git(repo, "-c", "user.name=PSP Test", "-c", "user.email=x@example.invalid", "commit", "-qm", "base")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
reference = root / "reference"
|
||||
target = root / "target"
|
||||
init(reference, {"stable.ini": "new\n", "dirty.ini": "head\n", "gone.ini": "old\n"})
|
||||
init(target, {"stable.ini": "old\n", "dirty.ini": "old\n", "gone.ini": "old\n", "local.ini": "base\n"})
|
||||
(reference / "dirty.ini").write_text("dirty-reference\n", encoding="utf-8")
|
||||
(reference / "gone.ini").unlink()
|
||||
(target / "stable.ini").write_text("new\n", encoding="utf-8")
|
||||
(target / "dirty.ini").write_text("dirty-reference\n", encoding="utf-8")
|
||||
(target / "gone.ini").unlink()
|
||||
(target / "local.ini").write_text("target-only\n", encoding="utf-8")
|
||||
|
||||
result = analyze_psp_delta.analyze(target, reference)
|
||||
assert result["passed"] is True
|
||||
assert result["reference"]["dirty"] is True
|
||||
categories = result["categories"]
|
||||
assert "stable.ini" in categories["matches_reference_head"]
|
||||
assert "dirty.ini" in categories["matches_reference_dirty_worktree"]
|
||||
assert "gone.ini" in categories["matches_reference_dirty_worktree"]
|
||||
assert "local.ini" in categories["target_specific"]
|
||||
|
||||
print("PSP three-way delta tests passed")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Self-contained checks for Release selection and effective VPSS graph."""
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from audit_cv181x_media_ai import audit
|
||||
|
||||
|
||||
def put(path: Path, value):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(value), encoding="utf-8")
|
||||
|
||||
|
||||
def main():
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
media = root / "EmxMedia-20260917-1" / "cv181xArmMuslDefault"
|
||||
ai = root / "EmxAiPlugin-20260921-1" / "cv181xArmMusl"
|
||||
psp = root / "PSP"
|
||||
identity = {"ChipSerial": "cv181x", "ChipSdkVersion": "4.1.6_arm_musl",
|
||||
"ChipManufactory": "cvitek", "Cross": "arm-none-linux-musleabihf",
|
||||
"EmxSdkVersion": "EmxSdk-20260917-1"}
|
||||
put(media / "configs.json", identity)
|
||||
put(ai / "profile.json", identity)
|
||||
put(ai / "AiPlugin.json", {"profile": ai.name, "version": "20260921-1"})
|
||||
system = [{"vb": [{"cnt": 2}], "vpss": {"group": [
|
||||
{"vpssGrp": 0, "chn": [{"u32Width": 640}], "attachPool": [{"chn": 0, "pool": 0}], "bind": []}
|
||||
]}}]
|
||||
put(media / "configs/system.json", system)
|
||||
put(media / "configs/venc.json", [{"attr": {"VpssGrp": 0, "VpssChn": 0}}])
|
||||
put(ai / "configs/config.json", {"PersonDetection": True})
|
||||
put(ai / "configs/PersonDetection.json", [{"vpssGrp": 0, "vpssChn": 0}])
|
||||
result = audit(media, ai)
|
||||
assert result["passed_static_cross_checks"] is True, result["errors"]
|
||||
assert result["field_counts"]["media"] > 0
|
||||
put(psp / "psp.json", {"EmxMediaVersion": media.parent.name, "EmxMediaProfile": media.name,
|
||||
"EmxAiPluginVersion": ai.parent.name, "EmxAiPluginProfile": ai.name})
|
||||
put(psp / "configs/media/scene/default/system.json", system)
|
||||
put(psp / "configs/media/scene/default/venc.json", [{"attr": {"VpssGrp": 4, "VpssChn": 0}}])
|
||||
changed = audit(media, ai, psp=psp)
|
||||
assert changed["passed_static_cross_checks"] is False
|
||||
assert any("VPSS 4/0" in error for error in changed["errors"])
|
||||
assert any(row.get("field") == "[0].attr.VpssGrp" for row in changed["candidate_differences"])
|
||||
put(ai / "profile.json", {**identity, "EmxSdkVersion": "EmxSdk-20260110-1"})
|
||||
mismatch = audit(media, ai)
|
||||
assert any("EmxSdkVersion" in error for error in mismatch["errors"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -111,6 +111,22 @@ def main() -> int:
|
||||
assert audited["passed"] is True, audited
|
||||
assert audited["repository_unchanged"] is True
|
||||
|
||||
no_reference = create_empty_project(psp_root, "NO-REFERENCE")
|
||||
empty_manifest = root / "evidence" / "empty-manifest.json"
|
||||
empty_prepared = psp_project_guard.prepare(
|
||||
None, no_reference, empty_manifest, [], False, psp_root
|
||||
)
|
||||
empty_staging = Path(empty_prepared["staging"])
|
||||
assert list(empty_staging.iterdir()) == []
|
||||
assert json.loads(empty_manifest.read_text(encoding="utf-8"))["reference"] is None
|
||||
(empty_staging / "psp.json").write_text('{"project":"NO-REFERENCE"}', encoding="utf-8")
|
||||
empty_report = root / "evidence" / "empty-verify.json"
|
||||
assert psp_project_guard.verify(empty_manifest, ["psp.json"], [], empty_report)["passed"]
|
||||
psp_project_guard.finalize(empty_manifest, empty_report)
|
||||
assert psp_project_guard.audit_final(empty_manifest, ["psp.json"], [], None)["passed"]
|
||||
assert (no_reference / ".git").is_dir()
|
||||
assert (no_reference / "psp.json").is_file()
|
||||
|
||||
missing = psp_root / "MISSING"
|
||||
expect_guard_error(
|
||||
lambda: psp_project_guard.prepare(
|
||||
|
||||
@@ -155,6 +155,14 @@ def main() -> int:
|
||||
assert candidate["passed"] is True, candidate
|
||||
assert candidate["etunel"]["sent"] is False
|
||||
|
||||
without_reference = render_psp_handoff.render(
|
||||
"NEW", "CV1841CP", "candidate", psp, None, None, None,
|
||||
static, bsp, None, None, None, None, None, None, None,
|
||||
[], [], [], None, False,
|
||||
)
|
||||
assert without_reference["passed"] is True, without_reference
|
||||
assert without_reference["reference_psp"] == {"source": None, "commit": None}
|
||||
|
||||
packaged = render_psp_handoff.render(
|
||||
"NEW",
|
||||
"CV1841CP",
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import validate_bsp_handoff
|
||||
|
||||
@@ -12,7 +14,8 @@ import validate_bsp_handoff
|
||||
def main() -> int:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
imgs = root / "imgs" / "20260902_1"
|
||||
output_root = root / "outputs"
|
||||
imgs = output_root / "CV1841CP-BOARD_dualos" / "imgs" / "20260902_1"
|
||||
imgs.mkdir(parents=True)
|
||||
artifact = imgs / "boot.bin"
|
||||
artifact.write_bytes(b"boot")
|
||||
@@ -66,6 +69,15 @@ def main() -> int:
|
||||
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")
|
||||
permissions = {
|
||||
"schema_version": 1,
|
||||
"passed": True,
|
||||
"policy": "a+rX",
|
||||
"output_root": str(output_root),
|
||||
"imgs_version_path": str(imgs),
|
||||
}
|
||||
permissions_path = margin_path.parent / "output_permissions_20260902_1.json"
|
||||
permissions_path.write_text(json.dumps(permissions), encoding="utf-8")
|
||||
manifest = root / "psp_handoff.json"
|
||||
payload = {
|
||||
"schema_version": 1,
|
||||
@@ -86,6 +98,7 @@ def main() -> int:
|
||||
"completion_marker_seen": True,
|
||||
"imgs_version_path": str(imgs),
|
||||
"image_partition_margin_path": str(margin_path),
|
||||
"output_permissions_path": str(permissions_path),
|
||||
},
|
||||
"artifacts": [
|
||||
{
|
||||
@@ -98,12 +111,17 @@ def main() -> int:
|
||||
],
|
||||
"validation": {
|
||||
"image_partition_margin": margin,
|
||||
"output_permissions": permissions,
|
||||
"hardware": "pending",
|
||||
"owner_acceptance": "pending",
|
||||
"release": "not-authorized",
|
||||
},
|
||||
}
|
||||
manifest.write_text(json.dumps(payload), encoding="utf-8")
|
||||
for directory in (output_root, output_root / "CV1841CP-BOARD_dualos", imgs.parent, imgs):
|
||||
os.chmod(directory, 0o755)
|
||||
for image in (artifact, kernel, rootfs):
|
||||
os.chmod(image, 0o644)
|
||||
result = validate_bsp_handoff.validate_handoff(
|
||||
manifest,
|
||||
"CV1841CP-BOARD_dualos",
|
||||
@@ -116,9 +134,74 @@ def main() -> int:
|
||||
assert result["dependency"]["build_id"] == "20260902_1"
|
||||
assert result["dependency"]["chip"] == "CV1841CP"
|
||||
assert result["dependency"]["image_partition_margin"]["passed"] is True
|
||||
assert result["dependency"]["output_permissions"]["current_check"]["passed"] is True
|
||||
assert result["dependency"]["artifacts"][0]["sha256"]
|
||||
assert result["warnings"]
|
||||
|
||||
os.chmod(output_root, 0o700)
|
||||
os.chmod(imgs, 0o700)
|
||||
os.chmod(kernel, 0o600)
|
||||
other_build = root / "another-build"
|
||||
other_build.mkdir(mode=0o700)
|
||||
(imgs / "external-link").symlink_to(other_build, target_is_directory=True)
|
||||
readonly = validate_bsp_handoff.validate_handoff(manifest)
|
||||
assert readonly["passed"] is False
|
||||
assert any("共享读取/进入权限" in error for error in readonly["errors"])
|
||||
assert output_root.stat().st_mode & 0o777 == 0o700
|
||||
repaired = validate_bsp_handoff.validate_handoff(
|
||||
manifest,
|
||||
expected_project="CV1841CP-BOARD_dualos",
|
||||
repair_output_permissions=True,
|
||||
)
|
||||
assert repaired["passed"] is True, repaired
|
||||
assert repaired["dependency"]["output_permissions"]["current_check"]["repaired"] is True
|
||||
assert repaired["dependency"]["output_permissions"]["current_check"]["symlinks_skipped"] == 1
|
||||
assert output_root.stat().st_mode & 0o555 == 0o555
|
||||
assert other_build.stat().st_mode & 0o777 == 0o700
|
||||
assert kernel.stat().st_mode & 0o444 == 0o444
|
||||
assert kernel.stat().st_mode & 0o111 == 0
|
||||
|
||||
no_evidence_payload = json.loads(json.dumps(payload))
|
||||
no_evidence_payload["validation"].pop("output_permissions")
|
||||
no_evidence_manifest = root / "no_permissions_handoff.json"
|
||||
no_evidence_manifest.write_text(json.dumps(no_evidence_payload), encoding="utf-8")
|
||||
no_evidence = validate_bsp_handoff.validate_handoff(
|
||||
no_evidence_manifest,
|
||||
expected_project="CV1841CP-BOARD_dualos",
|
||||
repair_output_permissions=True,
|
||||
)
|
||||
assert no_evidence["passed"] is False
|
||||
assert any("权限证据" in error for error in no_evidence["errors"])
|
||||
|
||||
os.chmod(kernel, 0o600)
|
||||
bad_project = validate_bsp_handoff.validate_handoff(
|
||||
manifest,
|
||||
expected_project="NOT-THE-PROJECT",
|
||||
repair_output_permissions=True,
|
||||
)
|
||||
assert bad_project["passed"] is False
|
||||
assert kernel.stat().st_mode & 0o444 != 0o444
|
||||
bad_evidence_payload = json.loads(json.dumps(payload))
|
||||
bad_evidence_payload["validation"]["output_permissions"]["output_root"] = str(root / "wrong-root")
|
||||
bad_evidence_manifest = root / "bad_permissions_handoff.json"
|
||||
bad_evidence_manifest.write_text(json.dumps(bad_evidence_payload), encoding="utf-8")
|
||||
bad_evidence = validate_bsp_handoff.validate_handoff(
|
||||
bad_evidence_manifest,
|
||||
expected_project="CV1841CP-BOARD_dualos",
|
||||
repair_output_permissions=True,
|
||||
)
|
||||
assert bad_evidence["passed"] is False
|
||||
assert kernel.stat().st_mode & 0o444 != 0o444
|
||||
with patch.object(validate_bsp_handoff.os, "chmod", side_effect=PermissionError("denied")):
|
||||
unrepairable = validate_bsp_handoff.validate_handoff(
|
||||
manifest,
|
||||
expected_project="CV1841CP-BOARD_dualos",
|
||||
repair_output_permissions=True,
|
||||
)
|
||||
assert unrepairable["passed"] is False
|
||||
assert any("无法检查或修复" in error for error in unrepairable["errors"])
|
||||
os.chmod(kernel, 0o644)
|
||||
|
||||
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")
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import validate_hardware_topology
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
psp = root / "psp"
|
||||
sensor_dir = psp / "configs" / "media"
|
||||
sensor_dir.mkdir(parents=True)
|
||||
(sensor_dir / "sensor_cfg.ini").write_text(
|
||||
"[sensor]\nname=0X00003A00\nbus_id=2\ni2c_addr=0x30\ndevno=1\nlane_num=1\n"
|
||||
"[sensor2]\nname=0X00003A00\nbus_id=2\ni2c_addr=0x31\ndevno=0\nlane_num=1\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
evidence = root / "spec.txt"
|
||||
evidence.write_text("dual MIS40C1", encoding="utf-8")
|
||||
digest = hashlib.sha256(evidence.read_bytes()).hexdigest()
|
||||
topology = {
|
||||
"schema_version": 1,
|
||||
"project": "LY-SM-YT42-AI",
|
||||
"chip": "CV1841CP",
|
||||
"system_mode": "dualos",
|
||||
"storage": {"type": "spinand", "capacity_bytes": 134217728},
|
||||
"sources": [{"kind": "spec", "path": str(evidence), "sha256": digest}],
|
||||
"conflicts": [],
|
||||
"sensors": [
|
||||
{"section": "sensor", "model": "MIS40C1", "enum_name": "MIS40C1_MIPI_4M_30FPS_12BIT", "i2c_bus": 2, "i2c_address": "0x30", "mipi_device": 1, "lane_count": 1, "evidence_sources": [0], "config_expectations": {"name": "0X00003A00", "bus_id": 2, "i2c_addr": "0x30", "devno": 1, "lane_num": 1}},
|
||||
{"section": "sensor2", "model": "MIS40C1", "enum_name": "MIS40C1_MIPI_4M_30FPS_12BIT", "i2c_bus": 2, "i2c_address": "0x31", "mipi_device": 0, "lane_count": 1, "evidence_sources": [0], "config_expectations": {"name": "0X00003A00", "bus_id": 2, "i2c_addr": "0x31", "devno": 0, "lane_num": 1}},
|
||||
],
|
||||
}
|
||||
topology_path = root / "hardware-topology.json"
|
||||
topology_path.write_text(json.dumps(topology), encoding="utf-8")
|
||||
assert validate_hardware_topology.validate(psp, topology_path)["passed"] is True
|
||||
|
||||
topology["sensors"] = topology["sensors"][:1]
|
||||
topology_path.write_text(json.dumps(topology), encoding="utf-8")
|
||||
failed = validate_hardware_topology.validate(psp, topology_path)
|
||||
assert failed["passed"] is False
|
||||
assert any("Sensor 数量不一致" in error for error in failed["errors"])
|
||||
|
||||
print("hardware topology tests passed")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import validate_partition_closure
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
psp_json = root / "psp.json"
|
||||
table = [
|
||||
("boot", "2560K"), ("env", "512K"), ("yoc", "1024K"),
|
||||
("kernel.A", "3584K"), ("rootfs.A", "3072K"), ("firmware.A", "19456K"),
|
||||
("kernel.B", "3584K"), ("rootfs.B", "3072K"), ("firmware.B", "19456K"),
|
||||
("data", "14336K"), ("configs", "-"),
|
||||
]
|
||||
psp_json.write_text(json.dumps({"partition": {"flashImgSize": "128M", "partitionTable": [{"name": name, "size": size} for name, size in table]}}), encoding="utf-8")
|
||||
_flash, rows = validate_partition_closure.partition_rows(psp_json)
|
||||
default = root / "Default"
|
||||
assignments = "\n".join(f"{row['boot_name']}_PART_OFFSET={row['offset_bytes']:#x}\n{row['boot_name']}_PART_SIZE={row['size_bytes']:#x}" for row in rows)
|
||||
mtdparts = ",".join(("-" if row["name"] == "configs" else hex(row["size_bytes"])) + f"({row['boot_name']})" for row in rows)
|
||||
default.write_text(assignments + f"\nMTD=mtdparts=spi0.0:{mtdparts}\n", encoding="utf-8")
|
||||
release = root / "release.py"
|
||||
release.write_text('run("./mkAiData.py 104 none")\n', encoding="utf-8")
|
||||
result = validate_partition_closure.validate(psp_json, default, release)
|
||||
assert result["passed"] is True, result
|
||||
|
||||
default.write_text(default.read_text(encoding="utf-8").replace("DATA_PART_SIZE=0xe00000", "DATA_PART_SIZE=0x100000"), encoding="utf-8")
|
||||
stale = validate_partition_closure.validate(psp_json, default, release)
|
||||
assert stale["passed"] is False
|
||||
assert any("data_part_size" in error for error in stale["errors"])
|
||||
|
||||
default.write_text(assignments + f"\nMTD=mtdparts=spi0.0:{mtdparts}\n", encoding="utf-8")
|
||||
release.write_text('run("./mkAiData.py 280 none")\n', encoding="utf-8")
|
||||
too_large = validate_partition_closure.validate(psp_json, default, release)
|
||||
assert too_large["passed"] is False
|
||||
assert any("超过 DATA" in error for error in too_large["errors"])
|
||||
|
||||
print("partition closure tests passed")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -5,6 +5,7 @@ import hashlib
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import validate_psp
|
||||
|
||||
@@ -16,7 +17,8 @@ def main() -> int:
|
||||
(psp / "configs").mkdir(parents=True)
|
||||
(psp / "scripts").mkdir()
|
||||
(psp / "AiPlugin").mkdir()
|
||||
artifact_root = root / "imgs" / "20260902_1"
|
||||
output_root = root / "outputs"
|
||||
artifact_root = output_root / "CV1841CP-NEW_dualos" / "imgs" / "20260902_1"
|
||||
artifact_root.mkdir(parents=True)
|
||||
artifact = artifact_root / "boot.bin"
|
||||
artifact.write_bytes(b"boot")
|
||||
@@ -38,6 +40,12 @@ def main() -> int:
|
||||
(psp / "AiPlugin" / "profile.json").write_text(
|
||||
json.dumps({"EmxSdkVersion": "EmxSdk-1"}), encoding="utf-8"
|
||||
)
|
||||
runtime_configs = root / "runtime-configs"
|
||||
runtime_configs.mkdir()
|
||||
(runtime_configs / "invalid.json").write_text("{", encoding="utf-8")
|
||||
(runtime_configs / "invalid.ini").write_text("[", encoding="utf-8")
|
||||
(runtime_configs / "invalid.sh").write_text("if\n", encoding="utf-8")
|
||||
(psp / "AiPlugin" / "configs").symlink_to(runtime_configs, target_is_directory=True)
|
||||
(psp / "configs" / "same.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
@@ -126,6 +134,15 @@ def main() -> int:
|
||||
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")
|
||||
permissions = {
|
||||
"schema_version": 1,
|
||||
"passed": True,
|
||||
"policy": "a+rX",
|
||||
"output_root": str(output_root),
|
||||
"imgs_version_path": str(artifact_root),
|
||||
}
|
||||
permissions_path = margin_path.parent / "output_permissions_20260902_1.json"
|
||||
permissions_path.write_text(json.dumps(permissions), encoding="utf-8")
|
||||
handoff.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
@@ -148,6 +165,7 @@ def main() -> int:
|
||||
"completion_marker_seen": True,
|
||||
"imgs_version_path": str(artifact_root),
|
||||
"image_partition_margin_path": str(margin_path),
|
||||
"output_permissions_path": str(permissions_path),
|
||||
},
|
||||
"artifacts": [
|
||||
{
|
||||
@@ -160,6 +178,7 @@ def main() -> int:
|
||||
],
|
||||
"validation": {
|
||||
"image_partition_margin": margin,
|
||||
"output_permissions": permissions,
|
||||
"hardware": "pending",
|
||||
"owner_acceptance": "pending",
|
||||
"release": "not-authorized",
|
||||
@@ -188,9 +207,24 @@ def main() -> int:
|
||||
expected_sensors=expected_sensors,
|
||||
)
|
||||
assert result["passed"] is True, result
|
||||
scanned_files = {
|
||||
path.relative_to(psp).as_posix()
|
||||
for path in validate_psp.iter_regular_files(psp)
|
||||
}
|
||||
assert "AiPlugin/configs/invalid.json" not in scanned_files
|
||||
assert "AiPlugin/configs/invalid.ini" not in scanned_files
|
||||
assert "AiPlugin/configs/invalid.sh" not in scanned_files
|
||||
with patch.object(Path, "exists", side_effect=PermissionError(13, "denied")):
|
||||
exists, access_error = validate_psp.probe_path_exists(Path("/target/runtime"))
|
||||
assert exists is False
|
||||
assert access_error is not None and "denied" in access_error
|
||||
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 lock_payload["output_permissions"]["passed"] is True
|
||||
assert lock_payload["output_permissions"]["evidence_path"] == str(permissions_path)
|
||||
assert output_root.stat().st_mode & 0o555 == 0o555
|
||||
assert artifact.stat().st_mode & 0o444 == 0o444
|
||||
assert result["checks"]["configuration_overrides"][0]["allowed"] is True
|
||||
assert any("个人或临时目录" in warning for warning in result["warnings"])
|
||||
assert any(
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate a BSP-to-PSP handoff and its immutable artifact hashes."""
|
||||
"""Validate a BSP-to-PSP handoff, output permissions, and artifact hashes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REQUIRED_IMAGE_MARGIN_BYTES = 512 * 1024
|
||||
REQUIRED_PARTITION_IMAGES = {"kernel.bin", "rootfs.bin"}
|
||||
DIRECTORY_ACCESS_BITS = 0o555
|
||||
FILE_READ_BITS = 0o444
|
||||
FILE_EXECUTE_BITS = 0o111
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
@@ -30,6 +35,159 @@ def within(path: Path, root: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def validate_output_permissions(
|
||||
payload: object,
|
||||
build: dict[str, object],
|
||||
producer: dict[str, object],
|
||||
imgs_path: Path | None,
|
||||
errors: list[str],
|
||||
repair: bool,
|
||||
) -> dict[str, object] | None:
|
||||
"""Check BSP evidence and live modes; optionally repair only its exact output."""
|
||||
if not isinstance(payload, dict) or payload.get("passed") is not True:
|
||||
errors.append("BSP handoff 缺少通过的输出路径权限证据")
|
||||
return None
|
||||
if payload.get("schema_version") != 1 or payload.get("policy") != "a+rX":
|
||||
errors.append("输出路径权限证据的 schema_version/policy 不匹配")
|
||||
return None
|
||||
|
||||
root_value = payload.get("output_root")
|
||||
version_value = payload.get("imgs_version_path")
|
||||
project_name = producer.get("project")
|
||||
build_version = build.get("version")
|
||||
if (
|
||||
not isinstance(root_value, str)
|
||||
or not Path(root_value).is_absolute()
|
||||
or not isinstance(version_value, str)
|
||||
or not Path(version_value).is_absolute()
|
||||
or not isinstance(project_name, str)
|
||||
or project_name in ("", ".", "..")
|
||||
or Path(project_name).name != project_name
|
||||
or not isinstance(build_version, str)
|
||||
or build_version in ("", ".", "..")
|
||||
or Path(build_version).name != build_version
|
||||
or imgs_path is None
|
||||
):
|
||||
errors.append("输出路径权限证据缺少精确的绝对输出目录、项目或构建版本")
|
||||
return None
|
||||
output_root = Path(root_value)
|
||||
if (
|
||||
len(output_root.parts) < 3
|
||||
or output_root == Path.home()
|
||||
or output_root.is_symlink()
|
||||
):
|
||||
errors.append("输出路径权限证据的根路径过宽或是软链接")
|
||||
return None
|
||||
expected_imgs_path = output_root / project_name / "imgs" / build_version
|
||||
if Path(version_value) != expected_imgs_path or imgs_path != expected_imgs_path:
|
||||
errors.append("输出路径权限证据与 BSP 项目、构建版本或 imgs_version_path 不一致")
|
||||
return None
|
||||
|
||||
evidence_value = build.get("output_permissions_path")
|
||||
project_value = producer.get("project_path")
|
||||
if (
|
||||
not isinstance(project_value, str)
|
||||
or not Path(project_value).is_absolute()
|
||||
or not isinstance(evidence_value, str)
|
||||
or not Path(evidence_value).is_absolute()
|
||||
):
|
||||
errors.append("build.output_permissions_path 必须是 BSP 项目内的绝对路径")
|
||||
return None
|
||||
evidence_path = Path(evidence_value)
|
||||
expected_evidence = (
|
||||
Path(project_value)
|
||||
/ "doc" / "bsp_adaptation" / "validation"
|
||||
/ f"output_permissions_{build_version}.json"
|
||||
)
|
||||
if evidence_path != expected_evidence or evidence_path.is_symlink():
|
||||
errors.append("输出路径权限证据文件路径不是预期 BSP 项目内的普通文件")
|
||||
return None
|
||||
try:
|
||||
external_payload = json.loads(evidence_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
errors.append(f"输出路径权限证据文件不可读或解析失败: {exc}")
|
||||
return None
|
||||
if external_payload != payload:
|
||||
errors.append("handoff 内嵌权限证据与外部证据文件不一致")
|
||||
return None
|
||||
|
||||
paths: list[tuple[Path, int, str]] = []
|
||||
for path in (
|
||||
output_root,
|
||||
output_root / project_name,
|
||||
output_root / project_name / "imgs",
|
||||
expected_imgs_path,
|
||||
):
|
||||
if path.is_symlink() or not path.is_dir():
|
||||
errors.append(f"构建输出路径节点不是普通目录: {path}")
|
||||
return None
|
||||
paths.append((path, DIRECTORY_ACCESS_BITS, "目录"))
|
||||
|
||||
def walk_error(exc: OSError) -> None:
|
||||
raise exc
|
||||
|
||||
skipped_symlinks = 0
|
||||
try:
|
||||
for parent, directory_names, file_names in os.walk(
|
||||
expected_imgs_path, followlinks=False, onerror=walk_error
|
||||
):
|
||||
parent_path = Path(parent)
|
||||
for name in list(directory_names):
|
||||
path = parent_path / name
|
||||
if path.is_symlink():
|
||||
directory_names.remove(name)
|
||||
skipped_symlinks += 1
|
||||
elif path.is_dir():
|
||||
paths.append((path, DIRECTORY_ACCESS_BITS, "目录"))
|
||||
else:
|
||||
errors.append(f"构建输出包含非普通目录: {path}")
|
||||
return None
|
||||
for name in file_names:
|
||||
path = parent_path / name
|
||||
if path.is_symlink():
|
||||
skipped_symlinks += 1
|
||||
elif path.is_file():
|
||||
bits = FILE_READ_BITS
|
||||
if stat.S_IMODE(path.stat().st_mode) & FILE_EXECUTE_BITS:
|
||||
bits |= FILE_EXECUTE_BITS
|
||||
paths.append((path, bits, "文件"))
|
||||
else:
|
||||
errors.append(f"构建输出包含非普通文件: {path}")
|
||||
return None
|
||||
except OSError as exc:
|
||||
errors.append(f"无法遍历构建输出目录: {exc}")
|
||||
return None
|
||||
|
||||
changed = 0
|
||||
for path, bits, kind in paths:
|
||||
try:
|
||||
if path.is_symlink():
|
||||
errors.append(f"权限处理拒绝软链接: {path}")
|
||||
return None
|
||||
before = stat.S_IMODE(path.stat().st_mode)
|
||||
if before & bits != bits:
|
||||
if not repair:
|
||||
errors.append(f"构建输出{kind}缺少共享读取/进入权限: {path}")
|
||||
continue
|
||||
os.chmod(path, before | bits, follow_symlinks=False)
|
||||
changed += 1
|
||||
after = stat.S_IMODE(path.stat().st_mode)
|
||||
if after & bits != bits:
|
||||
errors.append(f"构建输出{kind}权限修复后仍不满足要求: {path}")
|
||||
except OSError as exc:
|
||||
errors.append(f"无法检查或修复构建输出权限 {path}: {exc}")
|
||||
return {
|
||||
**payload,
|
||||
"current_check": {
|
||||
"passed": not errors,
|
||||
"repaired": changed > 0,
|
||||
"changed_count": changed,
|
||||
"checked_paths": len(paths),
|
||||
"symlinks_skipped": skipped_symlinks,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def validate_image_partition_margin(
|
||||
payload: object,
|
||||
build: dict[str, object],
|
||||
@@ -155,6 +313,7 @@ def validate_handoff(
|
||||
expected_sdk: str | None = None,
|
||||
expected_build_type: str | None = None,
|
||||
expected_system_form: str | None = None,
|
||||
repair_output_permissions: bool = False,
|
||||
) -> dict[str, object]:
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
@@ -196,6 +355,8 @@ def validate_handoff(
|
||||
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 repair_output_permissions and expected_project is None:
|
||||
errors.append("自动修复输出权限时必须提供预期的 BSP 项目名 --project")
|
||||
|
||||
if build.get("status") != "build_passed" or build.get("exit_code") != 0:
|
||||
errors.append("BSP 构建状态不是 build_passed/exit_code=0")
|
||||
@@ -210,6 +371,16 @@ def validate_handoff(
|
||||
if not imgs_path.is_dir() or imgs_path.is_symlink():
|
||||
errors.append(f"imgs_version_path 不存在或不是普通目录: {imgs_path}")
|
||||
|
||||
# Never modify BSP output if the handoff identity is already inconsistent.
|
||||
output_permissions = validate_output_permissions(
|
||||
validation.get("output_permissions"),
|
||||
build,
|
||||
producer,
|
||||
imgs_path,
|
||||
errors,
|
||||
repair=repair_output_permissions and not errors and expected_project == producer.get("project"),
|
||||
)
|
||||
|
||||
verified = 0
|
||||
verified_artifacts: list[dict[str, object]] = []
|
||||
seen_relative_paths: set[str] = set()
|
||||
@@ -297,6 +468,8 @@ def validate_handoff(
|
||||
"verified_artifact_count": verified,
|
||||
"artifacts": verified_artifacts,
|
||||
"image_partition_margin": image_partition_margin,
|
||||
"output_permissions_path": build.get("output_permissions_path"),
|
||||
"output_permissions": output_permissions,
|
||||
"hardware_validation": validation.get("hardware"),
|
||||
"owner_acceptance": validation.get("owner_acceptance"),
|
||||
"release": validation.get("release"),
|
||||
@@ -312,6 +485,11 @@ def main() -> int:
|
||||
parser.add_argument("--sdk")
|
||||
parser.add_argument("--build-type")
|
||||
parser.add_argument("--system-form", choices=("single-os", "dual-os"))
|
||||
parser.add_argument(
|
||||
"--repair-output-permissions",
|
||||
action="store_true",
|
||||
help="仅对已核对项目身份及证据的本次 imgs 路径补齐 a+rX 权限",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
result = validate_handoff(
|
||||
@@ -321,6 +499,7 @@ def main() -> int:
|
||||
args.sdk,
|
||||
args.build_type,
|
||||
args.system_form,
|
||||
repair_output_permissions=args.repair_output_permissions,
|
||||
)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
result = {"passed": False, "errors": [str(exc)], "warnings": []}
|
||||
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
#!/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())
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate PSP partition geometry, Boot/Default mirrors, and DATA UBI capacity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SIZE_RE = re.compile(r"^(\d+)([KMG])$", re.IGNORECASE)
|
||||
MULTIPLIERS = {"K": 1024, "M": 1024**2, "G": 1024**3}
|
||||
ALIASES = {
|
||||
"boot": "fip", "env": "ENV", "yoc": "2nd", "kernel.A": "BOOT",
|
||||
"rootfs.A": "ROOTFS", "firmware.A": "APP", "kernel.B": "BOOT2",
|
||||
"rootfs.B": "ROOTFS2", "firmware.B": "APP2", "data": "DATA", "configs": "CONFIGS",
|
||||
}
|
||||
|
||||
|
||||
def size_bytes(value: object) -> int | None:
|
||||
if value == "-":
|
||||
return None
|
||||
if not isinstance(value, str) or (match := SIZE_RE.fullmatch(value.strip())) is None:
|
||||
raise ValueError(f"非法分区大小: {value!r}")
|
||||
return int(match.group(1)) * MULTIPLIERS[match.group(2).upper()]
|
||||
|
||||
|
||||
def parse_number(value: str) -> int:
|
||||
value = value.strip()
|
||||
match = SIZE_RE.fullmatch(value)
|
||||
if match:
|
||||
return int(match.group(1)) * MULTIPLIERS[match.group(2).upper()]
|
||||
return int(value, 0)
|
||||
|
||||
|
||||
def partition_rows(psp_json: Path) -> tuple[int, list[dict[str, object]]]:
|
||||
payload = json.loads(psp_json.read_text(encoding="utf-8"))
|
||||
partition = payload.get("partition")
|
||||
if not isinstance(partition, dict):
|
||||
raise ValueError("psp.json.partition 必须是对象")
|
||||
flash = size_bytes(partition.get("flashImgSize"))
|
||||
if flash is None:
|
||||
raise ValueError("flashImgSize 不能是 -")
|
||||
table = partition.get("partitionTable")
|
||||
if not isinstance(table, list) or not table:
|
||||
raise ValueError("partitionTable 必须是非空数组")
|
||||
offset = 0
|
||||
rows: list[dict[str, object]] = []
|
||||
remainder_seen = False
|
||||
for index, item in enumerate(table):
|
||||
if not isinstance(item, dict) or not isinstance(item.get("name"), str):
|
||||
raise ValueError(f"partitionTable[{index}] 非法")
|
||||
raw_size = item.get("size")
|
||||
value = size_bytes(raw_size)
|
||||
if value is None:
|
||||
if remainder_seen or index != len(table) - 1:
|
||||
raise ValueError("size=- 只能出现一次且必须是最后一个分区")
|
||||
remainder_seen = True
|
||||
value = flash - offset
|
||||
if value < 0 or offset + value > flash:
|
||||
raise ValueError(f"分区 {item['name']} 超出 flashImgSize")
|
||||
rows.append({"name": item["name"], "boot_name": ALIASES.get(item["name"], item["name"]), "offset_bytes": offset, "size_bytes": value})
|
||||
offset += value
|
||||
if offset != flash:
|
||||
raise ValueError(f"分区总量 {offset} 未闭合到 flashImgSize {flash}")
|
||||
return flash, rows
|
||||
|
||||
|
||||
def assignments(text: str) -> dict[str, str]:
|
||||
result: dict[str, str] = {}
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
match = re.match(r"(?:export\s+)?([A-Za-z0-9_]+)\s*=\s*['\"]?([^'\"\s;]+)", stripped)
|
||||
if match:
|
||||
result[match.group(1).casefold()] = match.group(2)
|
||||
return result
|
||||
|
||||
|
||||
def check_mtdparts(
|
||||
text: str, rows: list[dict[str, object]], errors: list[str]
|
||||
) -> dict[str, object]:
|
||||
candidates = sorted(
|
||||
set(re.findall(r"mtdparts=[^:\s\"']+:([^\"'\s;]+)", text))
|
||||
)
|
||||
if not candidates:
|
||||
return {"present": False, "checks": []}
|
||||
if len(candidates) != 1:
|
||||
errors.append("Boot/Default 中存在多个不同 mtdparts,无法唯一核对")
|
||||
return {"present": True, "values": candidates, "checks": []}
|
||||
tokens = candidates[0].split(",")
|
||||
checks: list[dict[str, object]] = []
|
||||
if len(tokens) != len(rows):
|
||||
errors.append(f"mtdparts 分区数量不一致:期望 {len(rows)},实际 {len(tokens)}")
|
||||
for index, row in enumerate(rows):
|
||||
token = tokens[index] if index < len(tokens) else ""
|
||||
match = re.fullmatch(r"([^()]+)\(([^)]+)\)", token)
|
||||
raw_size, actual_name = (match.group(1), match.group(2)) if match else (None, None)
|
||||
try:
|
||||
actual_size = (
|
||||
int(row["size_bytes"])
|
||||
if raw_size == "-"
|
||||
else parse_number(raw_size) if raw_size is not None else None
|
||||
)
|
||||
except ValueError:
|
||||
actual_size = None
|
||||
expected_name = str(row["boot_name"])
|
||||
matched = (
|
||||
actual_name is not None
|
||||
and actual_name.casefold() == expected_name.casefold()
|
||||
and actual_size == row["size_bytes"]
|
||||
)
|
||||
checks.append({"index": index, "token": token, "expected_name": expected_name, "actual_name": actual_name, "expected_size": row["size_bytes"], "actual_size": actual_size, "matched": matched})
|
||||
if not matched:
|
||||
errors.append(f"mtdparts[{index}] 未同步:期望 {expected_name}/{row['size_bytes']},实际 {token!r}")
|
||||
return {"present": True, "value": candidates[0], "checks": checks}
|
||||
|
||||
|
||||
def check_embedded_partition_json(
|
||||
text: str, rows: list[dict[str, object]], errors: list[str]
|
||||
) -> dict[str, object]:
|
||||
payloads: list[dict[str, object]] = []
|
||||
for line in text.splitlines():
|
||||
if "partitionTable" not in line or "{" not in line:
|
||||
continue
|
||||
normalized_line = line.replace('\\"', '"')
|
||||
start, end = normalized_line.find("{"), normalized_line.rfind("}")
|
||||
if start < 0 or end <= start:
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(normalized_line[start : end + 1])
|
||||
except json.JSONDecodeError:
|
||||
errors.append("Boot/Default 内嵌 partition JSON 无法解析")
|
||||
continue
|
||||
if isinstance(payload, dict):
|
||||
payloads.append(payload)
|
||||
if not payloads:
|
||||
return {"present": False, "checks": []}
|
||||
if len(payloads) != 1:
|
||||
errors.append("Boot/Default 中存在多个内嵌 partition JSON,无法唯一核对")
|
||||
return {"present": True, "count": len(payloads), "checks": []}
|
||||
table = payloads[0].get("partitionTable")
|
||||
checks: list[dict[str, object]] = []
|
||||
if not isinstance(table, list) or len(table) != len(rows):
|
||||
errors.append("Boot/Default 内嵌 partition JSON 分区数量不一致")
|
||||
return {"present": True, "checks": checks}
|
||||
for index, row in enumerate(rows):
|
||||
item = table[index]
|
||||
actual_name = item.get("name") if isinstance(item, dict) else None
|
||||
try:
|
||||
actual_size = size_bytes(item.get("size")) if isinstance(item, dict) else None
|
||||
if actual_size is None:
|
||||
actual_size = row["size_bytes"]
|
||||
except ValueError:
|
||||
actual_size = None
|
||||
matched = actual_name == row["name"] and actual_size == row["size_bytes"]
|
||||
checks.append({"index": index, "expected_name": row["name"], "actual_name": actual_name, "expected_size": row["size_bytes"], "actual_size": actual_size, "matched": matched})
|
||||
if not matched:
|
||||
errors.append(f"内嵌 partition JSON[{index}] 未同步")
|
||||
return {"present": True, "checks": checks}
|
||||
|
||||
|
||||
def validate(
|
||||
psp_json: Path,
|
||||
boot_default: Path,
|
||||
release_py: Path | None,
|
||||
leb_size: int = 126976,
|
||||
peb_size: int = 131072,
|
||||
reserve_pebs: int = 8,
|
||||
) -> dict[str, object]:
|
||||
errors: list[str] = []
|
||||
psp_json = psp_json.expanduser().resolve(strict=True)
|
||||
boot_default = boot_default.expanduser().resolve(strict=True)
|
||||
if psp_json.is_symlink() or boot_default.is_symlink():
|
||||
raise ValueError("分区输入必须是普通文件,不能是软链接")
|
||||
flash, rows = partition_rows(psp_json)
|
||||
boot_text = boot_default.read_text(encoding="utf-8", errors="replace")
|
||||
boot_values = assignments(boot_text)
|
||||
mirror_checks: list[dict[str, object]] = []
|
||||
for row in rows:
|
||||
alias = str(row["boot_name"])
|
||||
keys = {
|
||||
"offset": f"{alias}_PART_OFFSET".casefold(),
|
||||
"size": f"{alias}_PART_SIZE".casefold(),
|
||||
}
|
||||
for kind, key in keys.items():
|
||||
expected = int(row[f"{kind}_bytes"])
|
||||
raw = boot_values.get(key)
|
||||
try:
|
||||
actual = parse_number(raw) if raw is not None else None
|
||||
except ValueError:
|
||||
actual = None
|
||||
matched = actual == expected
|
||||
mirror_checks.append({"partition": row["name"], "field": kind, "key": key, "expected": expected, "actual": actual, "matched": matched})
|
||||
if not matched:
|
||||
errors.append(f"Boot/Default {key} 未同步:期望 {expected:#x},实际 {raw!r}")
|
||||
|
||||
data = next((row for row in rows if row["name"] == "data"), None)
|
||||
mtdparts = check_mtdparts(boot_text, rows, errors)
|
||||
embedded_partition = check_embedded_partition_json(boot_text, rows, errors)
|
||||
data_check = None
|
||||
if release_py is not None:
|
||||
release_py = release_py.expanduser().resolve(strict=True)
|
||||
calls: set[int] = set()
|
||||
for line in release_py.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
if line.lstrip().startswith("#"):
|
||||
continue
|
||||
calls.update(int(value) for value in re.findall(r"mkAiData\.py\s+(\d+)\b", line))
|
||||
if len(calls) != 1:
|
||||
errors.append("release.py 必须唯一确定一个有效 mkAiData.py 容量参数")
|
||||
elif data is None:
|
||||
errors.append("PSP 分区表缺少 data")
|
||||
else:
|
||||
count = next(iter(calls))
|
||||
requested = count * leb_size
|
||||
reserved = reserve_pebs * peb_size
|
||||
capacity = int(data["size_bytes"])
|
||||
passed = requested + reserved <= capacity
|
||||
data_check = {"parameter": count, "leb_size": leb_size, "requested_bytes": requested, "reserve_pebs": reserve_pebs, "reserved_bytes": reserved, "partition_bytes": capacity, "passed": passed}
|
||||
if not passed:
|
||||
errors.append(f"mkAiData.py {count} 需要 {requested} 字节并保留 {reserved} 字节,超过 DATA {capacity} 字节")
|
||||
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"passed": not errors,
|
||||
"psp_json": str(psp_json),
|
||||
"boot_default": str(boot_default),
|
||||
"release_py": str(release_py) if release_py else None,
|
||||
"flash_size_bytes": flash,
|
||||
"partitions": rows,
|
||||
"boot_mirror_checks": mirror_checks,
|
||||
"mtdparts": mtdparts,
|
||||
"embedded_partition_json": embedded_partition,
|
||||
"data_capacity": data_check,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
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 main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--psp-json", type=Path, required=True)
|
||||
parser.add_argument("--boot-default", type=Path, required=True)
|
||||
parser.add_argument("--release-py", type=Path)
|
||||
parser.add_argument("--leb-size", type=int, default=126976)
|
||||
parser.add_argument("--peb-size", type=int, default=131072)
|
||||
parser.add_argument("--reserve-pebs", type=int, default=8)
|
||||
parser.add_argument("--output", type=Path)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
result = validate(args.psp_json, args.boot_default, args.release_py, args.leb_size, args.peb_size, args.reserve_pebs)
|
||||
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())
|
||||
@@ -87,10 +87,33 @@ def under(path: Path, root: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def iter_regular_files(root: Path) -> Iterator[Path]:
|
||||
"""Yield repository files without following runtime or Git directory links."""
|
||||
for current, dirnames, filenames in os.walk(root, followlinks=False):
|
||||
current_path = Path(current)
|
||||
dirnames[:] = sorted(
|
||||
name
|
||||
for name in dirnames
|
||||
if name != ".git" and not (current_path / name).is_symlink()
|
||||
)
|
||||
for name in sorted(filenames):
|
||||
path = current_path / name
|
||||
if path.is_symlink() or not path.is_file():
|
||||
continue
|
||||
yield path
|
||||
|
||||
|
||||
def probe_path_exists(path: Path) -> tuple[bool, str | None]:
|
||||
try:
|
||||
return path.exists(), None
|
||||
except OSError as exc:
|
||||
return False, str(exc)
|
||||
|
||||
|
||||
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:
|
||||
for path in iter_regular_files(root):
|
||||
if path.suffix.lower() != ".json":
|
||||
continue
|
||||
relative = path.relative_to(root).as_posix()
|
||||
try:
|
||||
@@ -102,8 +125,8 @@ def collect_json(root: Path, errors: list[str]) -> dict[str, Any]:
|
||||
|
||||
def validate_ini(root: Path, errors: list[str]) -> int:
|
||||
count = 0
|
||||
for path in sorted(root.rglob("*.ini")):
|
||||
if ".git" in path.parts:
|
||||
for path in iter_regular_files(root):
|
||||
if path.suffix.lower() != ".ini":
|
||||
continue
|
||||
parser = configparser.ConfigParser(interpolation=None, strict=False)
|
||||
try:
|
||||
@@ -200,11 +223,9 @@ def validate_sensor_baseline(
|
||||
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()
|
||||
)
|
||||
candidates = [
|
||||
path for path in iter_regular_files(psp_root) if path.name == "sensor_cfg.ini"
|
||||
]
|
||||
if len(candidates) != 1:
|
||||
errors.append(
|
||||
f"CV18 PSP 必须唯一定位一个 sensor_cfg.ini,实际 {len(candidates)} 个"
|
||||
@@ -320,8 +341,8 @@ def validate_sensor_baseline(
|
||||
|
||||
def validate_shell(root: Path, errors: list[str]) -> int:
|
||||
count = 0
|
||||
for path in sorted(root.rglob("*.sh")):
|
||||
if ".git" in path.parts:
|
||||
for path in iter_regular_files(root):
|
||||
if path.suffix.lower() != ".sh":
|
||||
continue
|
||||
result = subprocess.run(
|
||||
["bash", "-n", str(path)],
|
||||
@@ -346,12 +367,9 @@ def path_risk(path: Path) -> str | None:
|
||||
|
||||
|
||||
def text_absolute_paths(root: Path) -> Iterator[tuple[str, str, str]]:
|
||||
for path in sorted(root.rglob("*")):
|
||||
for path in iter_regular_files(root):
|
||||
if (
|
||||
not path.is_file()
|
||||
or path.is_symlink()
|
||||
or ".git" in path.parts
|
||||
or path.suffix.lower() not in TEXT_PATH_SUFFIXES
|
||||
path.suffix.lower() not in TEXT_PATH_SUFFIXES
|
||||
or path.stat().st_size > 4 * 1024 * 1024
|
||||
):
|
||||
continue
|
||||
@@ -409,7 +427,7 @@ def validate_absolute_paths(
|
||||
continue
|
||||
seen.add(key)
|
||||
path = Path(value).resolve(strict=False)
|
||||
exists = path.exists()
|
||||
exists, access_error = probe_path_exists(path)
|
||||
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
|
||||
@@ -419,12 +437,19 @@ def validate_absolute_paths(
|
||||
"path": value,
|
||||
"resolved_path": str(path),
|
||||
"exists": exists,
|
||||
"access_error": access_error,
|
||||
"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:
|
||||
if access_error:
|
||||
message = (
|
||||
f"外部绝对路径无法在当前主机检查 {source}:{location}: "
|
||||
f"{value}: {access_error}"
|
||||
)
|
||||
(errors if build_input else warnings).append(message)
|
||||
elif not exists:
|
||||
message = f"外部绝对路径不存在 {source}:{location}: {value}"
|
||||
if build_input:
|
||||
errors.append(f"外部绝对路径不存在 {source}:{location}: {value}")
|
||||
@@ -503,7 +528,7 @@ def scan_overrides(
|
||||
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()):
|
||||
for app_path in iter_regular_files(app_dir):
|
||||
relative = app_path.relative_to(app_dir)
|
||||
psp_path = psp_dir / relative
|
||||
if not psp_path.is_file():
|
||||
@@ -533,6 +558,10 @@ def scan_overrides(
|
||||
|
||||
|
||||
def build_bsp_lock(dependency: dict[str, object], warnings: list[str]) -> dict[str, object]:
|
||||
permissions = dependency.get("output_permissions")
|
||||
permissions = permissions if isinstance(permissions, dict) else {}
|
||||
live_check = permissions.get("current_check")
|
||||
live_check = live_check if isinstance(live_check, dict) else {}
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"handoff_path": dependency.get("manifest_path"),
|
||||
@@ -548,6 +577,13 @@ def build_bsp_lock(dependency: dict[str, object], warnings: list[str]) -> dict[s
|
||||
"imgs_version_path": dependency.get("imgs_version_path"),
|
||||
"artifacts": dependency.get("artifacts", []),
|
||||
"image_partition_margin": dependency.get("image_partition_margin"),
|
||||
"output_permissions": {
|
||||
"policy": permissions.get("policy"),
|
||||
"evidence_path": dependency.get("output_permissions_path"),
|
||||
"output_root": permissions.get("output_root"),
|
||||
"imgs_version_path": permissions.get("imgs_version_path"),
|
||||
"passed": live_check.get("passed") is True,
|
||||
},
|
||||
"mixed_build": None,
|
||||
"warnings": warnings,
|
||||
}
|
||||
@@ -736,6 +772,7 @@ def validate_psp(
|
||||
expected_sdk=expected_bsp_sdk,
|
||||
expected_build_type=expected_build_type,
|
||||
expected_system_form=expected_system_form,
|
||||
repair_output_permissions=True,
|
||||
)
|
||||
if bsp_result.get("passed") is not True:
|
||||
errors.extend(f"BSP handoff: {item}" for item in bsp_result.get("errors", []))
|
||||
|
||||
Reference in new issue
Block a user