602 lines
23 KiB
Python
Executable File
602 lines
23 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Safely stage, verify, finalize, and audit an EmxRes BSP project clone."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import stat
|
|
import sys
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path, PurePosixPath
|
|
|
|
|
|
ROOT_SUFFIX = Path("EapilBsp") / "EMX2.0" / "EmxRes"
|
|
PROJECT_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
|
EXCLUDED_DIR_NAMES = {
|
|
".git",
|
|
".cache",
|
|
".mypy_cache",
|
|
".pytest_cache",
|
|
".ruff_cache",
|
|
"__pycache__",
|
|
"build",
|
|
"logs",
|
|
"out",
|
|
"output",
|
|
}
|
|
EXCLUDED_FILE_SUFFIXES = {".log", ".pyc", ".tmp"}
|
|
CREDENTIAL_FILE_NAMES = {
|
|
".env",
|
|
"credentials.json",
|
|
"credentials.yaml",
|
|
"credentials.yml",
|
|
"id_ed25519",
|
|
"id_rsa",
|
|
}
|
|
CREDENTIAL_FILE_SUFFIXES = {".key", ".p12", ".pem", ".pfx"}
|
|
DOC_PREFIX = "doc/bsp_adaptation/"
|
|
DELTA_REPORT_PATH = "doc/bsp_adaptation/validation/project_delta.json"
|
|
FINAL_AUDIT_REPORT_PATH = "doc/bsp_adaptation/validation/final_audit.json"
|
|
|
|
|
|
class GuardError(RuntimeError):
|
|
"""A safety or validation gate failed."""
|
|
|
|
|
|
def now_utc() -> str:
|
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
|
|
|
|
def production_root() -> Path:
|
|
return Path.home() / ROOT_SUFFIX
|
|
|
|
|
|
def normalize_root(root: Path) -> Path:
|
|
root = root.expanduser()
|
|
if not root.is_dir():
|
|
raise GuardError(f"EmxRes 根目录不存在: {root}")
|
|
if root.is_symlink():
|
|
raise GuardError(f"EmxRes 根目录不能是软链接: {root}")
|
|
return root.resolve()
|
|
|
|
|
|
def validate_project_name(name: str, field: str) -> str:
|
|
if not PROJECT_NAME_RE.fullmatch(name or ""):
|
|
raise GuardError(
|
|
f"{field} 必须是 EmxRes 直接子目录名,只能包含字母、数字、点、下划线和连字符: {name!r}"
|
|
)
|
|
return name
|
|
|
|
|
|
def direct_child(
|
|
root: Path, name: str, field: str, must_exist: bool, allow_hidden: bool = False
|
|
) -> Path:
|
|
if allow_hidden and name.startswith("."):
|
|
validate_project_name(name[1:], field)
|
|
else:
|
|
name = validate_project_name(name, field)
|
|
path = root / name
|
|
if path.parent != root:
|
|
raise GuardError(f"{field} 发生路径逃逸: {name}")
|
|
if must_exist:
|
|
if not path.is_dir() or path.is_symlink():
|
|
raise GuardError(f"{field} 不是普通项目目录: {path}")
|
|
if path.resolve().parent != root:
|
|
raise GuardError(f"{field} 解析后不在 EmxRes 内: {path}")
|
|
return path
|
|
|
|
|
|
def is_excluded_name(path: Path, is_dir: bool) -> bool:
|
|
if is_dir and path.name in EXCLUDED_DIR_NAMES:
|
|
return True
|
|
return not is_dir and path.suffix.lower() in EXCLUDED_FILE_SUFFIXES
|
|
|
|
|
|
def copy_ignore(_directory: str, names: list[str]) -> set[str]:
|
|
ignored = set()
|
|
base = Path(_directory)
|
|
for name in names:
|
|
path = base / name
|
|
is_dir = path.is_dir()
|
|
if is_excluded_name(path, is_dir):
|
|
ignored.add(name)
|
|
return ignored
|
|
|
|
|
|
def scan_credentials(reference: Path) -> list[str]:
|
|
hits = []
|
|
for current, dirs, files in os.walk(reference, followlinks=False):
|
|
base = Path(current)
|
|
dirs[:] = [
|
|
name
|
|
for name in dirs
|
|
if not is_excluded_name(base / name, True) and not (base / name).is_symlink()
|
|
]
|
|
for name in files:
|
|
path = base / name
|
|
if path.is_symlink() or is_excluded_name(path, False):
|
|
continue
|
|
if name in CREDENTIAL_FILE_NAMES or path.suffix.lower() in CREDENTIAL_FILE_SUFFIXES:
|
|
hits.append(path.relative_to(reference).as_posix())
|
|
return sorted(hits)
|
|
|
|
|
|
def excluded_paths(reference: Path) -> list[str]:
|
|
hits = []
|
|
for current, dirs, files in os.walk(reference, followlinks=False):
|
|
base = Path(current)
|
|
kept_dirs = []
|
|
for name in dirs:
|
|
path = base / name
|
|
if is_excluded_name(path, True):
|
|
hits.append(path.relative_to(reference).as_posix() + "/")
|
|
elif path.is_symlink():
|
|
continue
|
|
else:
|
|
kept_dirs.append(name)
|
|
dirs[:] = kept_dirs
|
|
for name in files:
|
|
path = base / name
|
|
if not path.is_symlink() and is_excluded_name(path, False):
|
|
hits.append(path.relative_to(reference).as_posix())
|
|
return sorted(hits)
|
|
|
|
|
|
def within(path: Path, root: Path) -> bool:
|
|
try:
|
|
path.relative_to(root)
|
|
return True
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def expected_imgs_target(project_name: str) -> str:
|
|
return f"/epbuild/EMX2.0/EmxRes/{project_name}/imgs"
|
|
|
|
|
|
def scan_symlinks(reference: Path, root: Path, reference_name: str) -> list[dict[str, str]]:
|
|
links = []
|
|
link_paths = []
|
|
for current, dirs, files in os.walk(reference, followlinks=False):
|
|
base = Path(current)
|
|
kept_dirs = []
|
|
for name in dirs:
|
|
path = base / name
|
|
if is_excluded_name(path, True):
|
|
continue
|
|
if path.is_symlink():
|
|
link_paths.append(path)
|
|
else:
|
|
kept_dirs.append(name)
|
|
dirs[:] = kept_dirs
|
|
for name in files:
|
|
path = base / name
|
|
if path.is_symlink() and not is_excluded_name(path, False):
|
|
link_paths.append(path)
|
|
|
|
for path in sorted(link_paths):
|
|
relative = path.relative_to(reference).as_posix()
|
|
raw_target = os.readlink(path)
|
|
if relative == "imgs" and raw_target == expected_imgs_target(reference_name):
|
|
kind = "imgs"
|
|
elif os.path.isabs(raw_target):
|
|
resolved = Path(raw_target).resolve(strict=False)
|
|
if not within(resolved, root):
|
|
raise GuardError(f"存在未经确认的外部绝对软链接: {relative} -> {raw_target}")
|
|
kind = "absolute-in-root"
|
|
else:
|
|
resolved = (path.parent / raw_target).resolve(strict=False)
|
|
if not within(resolved, root):
|
|
raise GuardError(f"存在未经确认的外部相对软链接: {relative} -> {raw_target}")
|
|
kind = "relative-in-root"
|
|
links.append({"path": relative, "target": raw_target, "kind": kind})
|
|
return links
|
|
|
|
|
|
def sha256_file(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as handle:
|
|
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
|
digest.update(block)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def inventory(
|
|
project: Path, omit_paths: set[str] | None = None
|
|
) -> dict[str, dict[str, object]]:
|
|
omit_paths = omit_paths or set()
|
|
entries: dict[str, dict[str, object]] = {}
|
|
for current, dirs, files in os.walk(project, followlinks=False):
|
|
base = Path(current)
|
|
kept_dirs = []
|
|
for name in sorted(dirs):
|
|
path = base / name
|
|
if is_excluded_name(path, True):
|
|
continue
|
|
if path.is_symlink():
|
|
rel = path.relative_to(project).as_posix()
|
|
entries[rel] = {
|
|
"kind": "symlink",
|
|
"mode": stat.S_IMODE(path.lstat().st_mode),
|
|
"target": os.readlink(path),
|
|
}
|
|
else:
|
|
kept_dirs.append(name)
|
|
dirs[:] = kept_dirs
|
|
for name in sorted(files):
|
|
path = base / name
|
|
if is_excluded_name(path, False):
|
|
continue
|
|
rel = path.relative_to(project).as_posix()
|
|
if rel in omit_paths:
|
|
continue
|
|
if path.is_symlink():
|
|
entries[rel] = {
|
|
"kind": "symlink",
|
|
"mode": stat.S_IMODE(path.lstat().st_mode),
|
|
"target": os.readlink(path),
|
|
}
|
|
elif path.is_file():
|
|
file_stat = path.stat()
|
|
entries[rel] = {
|
|
"kind": "file",
|
|
"mode": stat.S_IMODE(file_stat.st_mode),
|
|
"size": file_stat.st_size,
|
|
"sha256": sha256_file(path),
|
|
}
|
|
return dict(sorted(entries.items()))
|
|
|
|
|
|
def write_json(path: Path, payload: dict[str, object]) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
|
|
|
|
def prepare_project(reference_name: str, target_name: str, root: Path | None = None) -> dict[str, object]:
|
|
root = normalize_root(root or production_root())
|
|
reference = direct_child(root, reference_name, "reference-project", True)
|
|
target = direct_child(root, target_name, "target-project", False)
|
|
if target.exists() or target.is_symlink():
|
|
raise GuardError(f"目标项目已存在,禁止覆盖: {target}")
|
|
|
|
credentials = scan_credentials(reference)
|
|
if credentials:
|
|
raise GuardError("参考项目包含疑似凭据文件,未复制: " + ", ".join(credentials))
|
|
links = scan_symlinks(reference, root, reference_name)
|
|
source_before = inventory(reference)
|
|
staging_name = f".{target_name}.bsp-staging-{uuid.uuid4().hex[:12]}"
|
|
staging = root / staging_name
|
|
|
|
shutil.copytree(
|
|
reference,
|
|
staging,
|
|
symlinks=True,
|
|
copy_function=shutil.copy2,
|
|
ignore=copy_ignore,
|
|
)
|
|
|
|
imgs = staging / "imgs"
|
|
imgs_retargeted = False
|
|
if imgs.is_symlink() and os.readlink(imgs) == expected_imgs_target(reference_name):
|
|
imgs.unlink()
|
|
imgs.symlink_to(expected_imgs_target(target_name))
|
|
imgs_retargeted = True
|
|
|
|
doc_root = staging / "doc" / "bsp_adaptation"
|
|
(doc_root / "inputs").mkdir(parents=True, exist_ok=True)
|
|
validation = doc_root / "validation"
|
|
validation.mkdir(parents=True, exist_ok=True)
|
|
|
|
source_after = inventory(reference)
|
|
if source_before != source_after:
|
|
raise GuardError(f"复制期间参考项目发生变化,临时目录保留供检查: {staging}")
|
|
|
|
manifest: dict[str, object] = {
|
|
"schema_version": 1,
|
|
"created_at": now_utc(),
|
|
"emxres_root": str(root),
|
|
"reference_project": reference_name,
|
|
"target_project": target_name,
|
|
"staging_directory": staging_name,
|
|
"excluded_paths": excluded_paths(reference),
|
|
"symlinks": links,
|
|
"imgs_retargeted": imgs_retargeted,
|
|
"imgs_target": os.readlink(imgs) if imgs.is_symlink() else None,
|
|
"source_inventory": source_before,
|
|
"staging_inventory_before_edits": inventory(staging),
|
|
}
|
|
write_json(validation / "clone_manifest.json", manifest)
|
|
return {
|
|
"staging_directory": staging_name,
|
|
"staging_path": str(staging),
|
|
"manifest": str(validation / "clone_manifest.json"),
|
|
"imgs_retargeted": imgs_retargeted,
|
|
}
|
|
|
|
|
|
def normalize_allow_paths(values: list[str]) -> set[str]:
|
|
allowed = set()
|
|
for value in values:
|
|
pure = PurePosixPath(value)
|
|
if pure.is_absolute() or ".." in pure.parts or value in {"", "."}:
|
|
raise GuardError(f"allow-path 必须是项目内精确相对路径: {value!r}")
|
|
if any(char in value for char in "*?[]"):
|
|
raise GuardError(f"allow-path 不允许通配符,必须逐文件列出: {value!r}")
|
|
allowed.add(pure.as_posix())
|
|
return allowed
|
|
|
|
|
|
def compare_inventories(
|
|
source: dict[str, dict[str, object]], target: dict[str, dict[str, object]]
|
|
) -> dict[str, list[str]]:
|
|
source_paths = set(source)
|
|
target_paths = set(target)
|
|
return {
|
|
"added": sorted(target_paths - source_paths),
|
|
"removed": sorted(source_paths - target_paths),
|
|
"modified": sorted(path for path in source_paths & target_paths if source[path] != target[path]),
|
|
}
|
|
|
|
|
|
def load_clone_manifest(
|
|
staging: Path, root: Path, reference_name: str, target_name: str, staging_name: str
|
|
) -> dict[str, object]:
|
|
path = staging / "doc" / "bsp_adaptation" / "validation" / "clone_manifest.json"
|
|
if not path.is_file():
|
|
raise GuardError("缺少 clone_manifest.json;临时目录不是受控 prepare 结果")
|
|
manifest = json.loads(path.read_text(encoding="utf-8"))
|
|
expected = {
|
|
"emxres_root": str(root),
|
|
"reference_project": reference_name,
|
|
"target_project": target_name,
|
|
"staging_directory": staging_name,
|
|
}
|
|
mismatched = [key for key, value in expected.items() if manifest.get(key) != value]
|
|
if mismatched:
|
|
raise GuardError("clone_manifest 与当前操作不匹配: " + ", ".join(mismatched))
|
|
return manifest
|
|
|
|
|
|
def verify_project(
|
|
reference_name: str,
|
|
staging_name: str,
|
|
target_name: str,
|
|
allow_paths: list[str],
|
|
root: Path | None = None,
|
|
) -> dict[str, object]:
|
|
root = normalize_root(root or production_root())
|
|
reference = direct_child(root, reference_name, "reference-project", True)
|
|
validate_project_name(target_name, "target-project")
|
|
staging = direct_child(root, staging_name, "staging-dir", True, allow_hidden=True)
|
|
expected_prefix = f".{target_name}.bsp-staging-"
|
|
if not staging_name.startswith(expected_prefix):
|
|
raise GuardError(f"临时目录与目标项目不匹配: {staging_name}")
|
|
clone_manifest = load_clone_manifest(staging, root, reference_name, target_name, staging_name)
|
|
scan_symlinks(staging, root, target_name)
|
|
credentials = scan_credentials(staging)
|
|
if credentials:
|
|
raise GuardError("临时目录包含疑似凭据文件,禁止最终化: " + ", ".join(credentials))
|
|
unexpected_excluded = excluded_paths(staging)
|
|
if unexpected_excluded:
|
|
raise GuardError(
|
|
"临时目录新增了被排除的缓存、构建输出或日志,必须先处理: "
|
|
+ ", ".join(unexpected_excluded)
|
|
)
|
|
allowed = normalize_allow_paths(allow_paths)
|
|
source_inventory = clone_manifest.get("source_inventory")
|
|
if not isinstance(source_inventory, dict):
|
|
raise GuardError("clone_manifest 缺少 prepare 时的参考项目清单")
|
|
if inventory(reference) != source_inventory:
|
|
raise GuardError("prepare 后参考项目发生变化;必须重新确认并重新 prepare")
|
|
staging_inventory = inventory(staging, {DELTA_REPORT_PATH})
|
|
changes = compare_inventories(source_inventory, staging_inventory)
|
|
|
|
def permitted(path: str) -> bool:
|
|
return path == "imgs" or path.startswith(DOC_PREFIX) or path in allowed
|
|
|
|
violations = sorted(
|
|
{path for group in changes.values() for path in group if not permitted(path)}
|
|
)
|
|
report: dict[str, object] = {
|
|
"schema_version": 1,
|
|
"checked_at": now_utc(),
|
|
"reference_project": reference_name,
|
|
"target_project": target_name,
|
|
"staging_directory": staging_name,
|
|
"allowed_paths": sorted(allowed),
|
|
"changes": changes,
|
|
"violations": violations,
|
|
"passed": not violations,
|
|
"verified_staging_inventory": staging_inventory,
|
|
}
|
|
write_json(staging / "doc" / "bsp_adaptation" / "validation" / "project_delta.json", report)
|
|
return report
|
|
|
|
|
|
def finalize_project(
|
|
reference_name: str,
|
|
staging_name: str,
|
|
target_name: str,
|
|
allow_paths: list[str],
|
|
root: Path | None = None,
|
|
) -> dict[str, object]:
|
|
root = normalize_root(root or production_root())
|
|
target = direct_child(root, target_name, "target-project", False)
|
|
if target.exists() or target.is_symlink():
|
|
raise GuardError(f"目标项目已存在,禁止覆盖: {target}")
|
|
staging = direct_child(root, staging_name, "staging-dir", True, allow_hidden=True)
|
|
if not staging_name.startswith(f".{target_name}.bsp-staging-"):
|
|
raise GuardError(f"临时目录与目标项目不匹配: {staging_name}")
|
|
|
|
report_path = staging / "doc" / "bsp_adaptation" / "adaptation_report.md"
|
|
delta = verify_project(reference_name, staging_name, target_name, allow_paths, root=root)
|
|
if delta.get("passed") is not True:
|
|
raise GuardError("差异白名单未通过,禁止最终化")
|
|
verified_inventory = delta.get("verified_staging_inventory")
|
|
if not isinstance(verified_inventory, dict):
|
|
raise GuardError("差异报告缺少已验证目录清单;必须重新运行 verify")
|
|
current_inventory = inventory(staging, {DELTA_REPORT_PATH})
|
|
if current_inventory != verified_inventory:
|
|
raise GuardError("verify 后临时目录又发生变化;必须重新运行 verify")
|
|
if not report_path.is_file() or not report_path.read_text(encoding="utf-8").strip():
|
|
raise GuardError("缺少非空的 doc/bsp_adaptation/adaptation_report.md")
|
|
|
|
os.rename(staging, target)
|
|
finalized_at = now_utc()
|
|
clone_path = target / "doc" / "bsp_adaptation" / "validation" / "clone_manifest.json"
|
|
if clone_path.is_file():
|
|
clone = json.loads(clone_path.read_text(encoding="utf-8"))
|
|
clone["finalized_at"] = finalized_at
|
|
clone["final_path"] = str(target)
|
|
write_json(clone_path, clone)
|
|
delta_path = target / "doc" / "bsp_adaptation" / "validation" / "project_delta.json"
|
|
delta["finalized_at"] = finalized_at
|
|
delta["final_path"] = str(target)
|
|
write_json(delta_path, delta)
|
|
return {"target_project": target_name, "target_path": str(target), "finalized_at": finalized_at}
|
|
|
|
|
|
def audit_final_project(
|
|
target_name: str,
|
|
allow_paths: list[str] | None = None,
|
|
root: Path | None = None,
|
|
) -> dict[str, object]:
|
|
"""Audit a finalized project against its immutable prepare-time baseline."""
|
|
root = normalize_root(root or production_root())
|
|
target = direct_child(root, target_name, "target-project", True)
|
|
clone_path = target / "doc" / "bsp_adaptation" / "validation" / "clone_manifest.json"
|
|
if not clone_path.is_file():
|
|
raise GuardError("正式项目缺少 clone_manifest.json,无法恢复 prepare 时基线")
|
|
clone = json.loads(clone_path.read_text(encoding="utf-8"))
|
|
expected = {"emxres_root": str(root), "target_project": target_name}
|
|
mismatched = [key for key, value in expected.items() if clone.get(key) != value]
|
|
if mismatched:
|
|
raise GuardError("clone_manifest 与正式项目不匹配: " + ", ".join(mismatched))
|
|
source_inventory = clone.get("source_inventory")
|
|
if not isinstance(source_inventory, dict):
|
|
raise GuardError("clone_manifest 缺少 prepare 时的参考项目清单")
|
|
|
|
delta_path = target / DELTA_REPORT_PATH
|
|
delta: dict[str, object] = {}
|
|
if delta_path.is_file():
|
|
delta = json.loads(delta_path.read_text(encoding="utf-8"))
|
|
effective_allow_paths = allow_paths
|
|
if effective_allow_paths is None or not effective_allow_paths:
|
|
stored = delta.get("allowed_paths")
|
|
if not isinstance(stored, list) or not all(isinstance(item, str) for item in stored):
|
|
raise GuardError("未提供 allow-path,且 project_delta.json 中没有可复用白名单")
|
|
effective_allow_paths = stored
|
|
allowed = normalize_allow_paths(effective_allow_paths)
|
|
|
|
scan_symlinks(target, root, target_name)
|
|
credentials = scan_credentials(target)
|
|
if credentials:
|
|
raise GuardError("正式项目包含疑似凭据文件: " + ", ".join(credentials))
|
|
unexpected_excluded = excluded_paths(target)
|
|
if unexpected_excluded:
|
|
raise GuardError(
|
|
"正式项目包含被排除的缓存、构建输出或日志: " + ", ".join(unexpected_excluded)
|
|
)
|
|
|
|
target_inventory = inventory(target, {DELTA_REPORT_PATH, FINAL_AUDIT_REPORT_PATH})
|
|
changes = compare_inventories(source_inventory, target_inventory)
|
|
|
|
def permitted(path: str) -> bool:
|
|
return path == "imgs" or path.startswith(DOC_PREFIX) or path in allowed
|
|
|
|
violations = sorted(
|
|
{path for group in changes.values() for path in group if not permitted(path)}
|
|
)
|
|
reference_name = clone.get("reference_project")
|
|
reference_drifted: bool | None = None
|
|
if isinstance(reference_name, str):
|
|
reference = root / reference_name
|
|
if reference.is_dir() and not reference.is_symlink():
|
|
reference_drifted = inventory(reference) != source_inventory
|
|
report: dict[str, object] = {
|
|
"schema_version": 1,
|
|
"checked_at": now_utc(),
|
|
"reference_project": reference_name,
|
|
"reference_drifted_since_prepare": reference_drifted,
|
|
"target_project": target_name,
|
|
"target_path": str(target),
|
|
"baseline": "clone_manifest.source_inventory",
|
|
"allowed_paths": sorted(allowed),
|
|
"changes": changes,
|
|
"violations": violations,
|
|
"passed": not violations,
|
|
"audited_target_inventory": target_inventory,
|
|
}
|
|
write_json(target / FINAL_AUDIT_REPORT_PATH, report)
|
|
return report
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
|
|
prepare = subparsers.add_parser("prepare", help="复制参考项目到唯一临时目录")
|
|
prepare.add_argument("--reference-project", required=True)
|
|
prepare.add_argument("--target-project", required=True)
|
|
|
|
verify = subparsers.add_parser("verify", help="按精确文件白名单检查差异")
|
|
verify.add_argument("--reference-project", required=True)
|
|
verify.add_argument("--staging-dir", required=True)
|
|
verify.add_argument("--target-project", required=True)
|
|
verify.add_argument("--allow-path", action="append", default=[])
|
|
|
|
finalize = subparsers.add_parser("finalize", help="将已验证临时目录原子落为正式项目")
|
|
finalize.add_argument("--reference-project", required=True)
|
|
finalize.add_argument("--staging-dir", required=True)
|
|
finalize.add_argument("--target-project", required=True)
|
|
finalize.add_argument("--allow-path", action="append", default=[])
|
|
|
|
audit = subparsers.add_parser("audit-final", help="复核已落盘正式项目的差异白名单")
|
|
audit.add_argument("--target-project", required=True)
|
|
audit.add_argument("--allow-path", action="append", default=[])
|
|
return parser
|
|
|
|
|
|
def main() -> int:
|
|
args = build_parser().parse_args()
|
|
try:
|
|
if args.command == "prepare":
|
|
result = prepare_project(args.reference_project, args.target_project)
|
|
elif args.command == "verify":
|
|
result = verify_project(
|
|
args.reference_project,
|
|
args.staging_dir,
|
|
args.target_project,
|
|
args.allow_path,
|
|
)
|
|
if not result["passed"]:
|
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
return 2
|
|
elif args.command == "finalize":
|
|
result = finalize_project(
|
|
args.reference_project,
|
|
args.staging_dir,
|
|
args.target_project,
|
|
args.allow_path,
|
|
)
|
|
else:
|
|
result = audit_final_project(args.target_project, args.allow_path)
|
|
if not result["passed"]:
|
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
return 2
|
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
return 0
|
|
except (GuardError, OSError, json.JSONDecodeError) as exc:
|
|
print(f"ERROR: {exc}", file=sys.stderr)
|
|
return 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|