855 lines
34 KiB
Python
Executable File
855 lines
34 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""Safely adapt a reference PSP into a pre-created project repository."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import re
|
||
import shutil
|
||
import stat
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import uuid
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path, PurePosixPath
|
||
|
||
|
||
EXCLUDED_DIR_NAMES = {
|
||
".git",
|
||
".cache",
|
||
".mypy_cache",
|
||
".pytest_cache",
|
||
".ruff_cache",
|
||
"__pycache__",
|
||
"build",
|
||
"logs",
|
||
"out",
|
||
"output",
|
||
"temp",
|
||
"tmp",
|
||
}
|
||
EXCLUDED_FILE_NAMES = {".git", ".DS_Store", "psp.lock.json"}
|
||
EXCLUDED_FILE_SUFFIXES = {".log", ".pyc", ".tmp"}
|
||
CREDENTIAL_FILE_NAMES = {
|
||
".env",
|
||
"credentials.json",
|
||
"credentials.yaml",
|
||
"credentials.yml",
|
||
"id_ed25519",
|
||
"id_rsa",
|
||
}
|
||
CREDENTIAL_FILE_SUFFIXES = {".key", ".p12", ".pem", ".pfx"}
|
||
NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||
DEFAULT_PSP_ROOT = Path.home() / "work/project/EMX2.1/AppTeam/Psp"
|
||
|
||
|
||
class GuardError(RuntimeError):
|
||
"""A safety or validation gate failed."""
|
||
|
||
|
||
def now_utc() -> str:
|
||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||
|
||
|
||
def sha256_file(path: Path) -> str:
|
||
digest = hashlib.sha256()
|
||
with path.open("rb") as handle:
|
||
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
||
digest.update(block)
|
||
return digest.hexdigest()
|
||
|
||
|
||
def write_json_atomic(path: Path, payload: dict[str, object]) -> None:
|
||
path = path.expanduser().resolve(strict=False)
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
with tempfile.NamedTemporaryFile(
|
||
"w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", delete=False
|
||
) as handle:
|
||
json.dump(payload, handle, ensure_ascii=False, indent=2)
|
||
handle.write("\n")
|
||
temporary = Path(handle.name)
|
||
os.replace(temporary, path)
|
||
|
||
|
||
def load_json(path: Path) -> dict[str, object]:
|
||
payload = json.loads(path.expanduser().read_text(encoding="utf-8"))
|
||
if not isinstance(payload, dict):
|
||
raise GuardError(f"JSON 根节点必须是对象: {path}")
|
||
return payload
|
||
|
||
|
||
def ordinary_directory(path: Path, field: str, must_exist: bool = True) -> Path:
|
||
path = path.expanduser().absolute()
|
||
if must_exist:
|
||
if not path.is_dir() or path.is_symlink():
|
||
raise GuardError(f"{field} 必须是存在的普通目录: {path}")
|
||
return path.resolve()
|
||
if path.exists() or path.is_symlink():
|
||
raise GuardError(f"{field} 已存在,禁止覆盖: {path}")
|
||
if not path.parent.is_dir() or path.parent.is_symlink():
|
||
raise GuardError(f"{field} 的父目录不存在或不是普通目录: {path.parent}")
|
||
return path
|
||
|
||
|
||
def psp_root_directory(path: Path) -> Path:
|
||
return ordinary_directory(path, "psp-root")
|
||
|
||
|
||
def project_directory(path: Path, root: Path, field: str) -> Path:
|
||
project = ordinary_directory(path, field)
|
||
if not NAME_RE.fullmatch(project.name):
|
||
raise GuardError(f"{field} 目录名非法: {project.name!r}")
|
||
if project.parent != root:
|
||
raise GuardError(f"{field} 必须是 PSP 固定根目录的直接子目录: {root}")
|
||
return project
|
||
|
||
|
||
def target_payload_entries(path: Path) -> list[str]:
|
||
"""Return every top-level entry other than the repository metadata."""
|
||
return sorted(entry.name for entry in path.iterdir() if entry.name != ".git")
|
||
|
||
|
||
def is_excluded(path: Path, is_dir: bool) -> bool:
|
||
if is_dir:
|
||
return path.name in EXCLUDED_DIR_NAMES
|
||
return path.name in EXCLUDED_FILE_NAMES or path.suffix.lower() in EXCLUDED_FILE_SUFFIXES
|
||
|
||
|
||
def copy_ignore(directory: str, names: list[str]) -> set[str]:
|
||
base = Path(directory)
|
||
return {name for name in names if is_excluded(base / name, (base / name).is_dir())}
|
||
|
||
|
||
def excluded_paths(root: Path) -> list[str]:
|
||
hits: list[str] = []
|
||
for current, dirs, files in os.walk(root, followlinks=False):
|
||
base = Path(current)
|
||
kept: list[str] = []
|
||
for name in sorted(dirs):
|
||
path = base / name
|
||
if is_excluded(path, True):
|
||
hits.append(path.relative_to(root).as_posix() + "/")
|
||
elif path.is_symlink():
|
||
kept.append(name)
|
||
else:
|
||
kept.append(name)
|
||
dirs[:] = kept
|
||
for name in sorted(files):
|
||
path = base / name
|
||
if is_excluded(path, False):
|
||
hits.append(path.relative_to(root).as_posix())
|
||
return sorted(hits)
|
||
|
||
|
||
def scan_symlinks(root: Path) -> list[str]:
|
||
links: list[str] = []
|
||
for current, dirs, files in os.walk(root, followlinks=False):
|
||
base = Path(current)
|
||
kept: list[str] = []
|
||
for name in sorted(dirs):
|
||
path = base / name
|
||
if is_excluded(path, True):
|
||
continue
|
||
if path.is_symlink():
|
||
links.append(path.relative_to(root).as_posix())
|
||
else:
|
||
kept.append(name)
|
||
dirs[:] = kept
|
||
for name in sorted(files):
|
||
path = base / name
|
||
if path.is_symlink() and not is_excluded(path, False):
|
||
links.append(path.relative_to(root).as_posix())
|
||
return sorted(links)
|
||
|
||
|
||
def scan_credentials(root: Path) -> list[str]:
|
||
hits: list[str] = []
|
||
for current, dirs, files in os.walk(root, followlinks=False):
|
||
base = Path(current)
|
||
dirs[:] = [
|
||
name
|
||
for name in dirs
|
||
if not is_excluded(base / name, True) and not (base / name).is_symlink()
|
||
]
|
||
for name in files:
|
||
path = base / name
|
||
if path.is_symlink() or is_excluded(path, False):
|
||
continue
|
||
if name in CREDENTIAL_FILE_NAMES or path.suffix.lower() in CREDENTIAL_FILE_SUFFIXES:
|
||
hits.append(path.relative_to(root).as_posix())
|
||
return sorted(hits)
|
||
|
||
|
||
def inventory(root: Path) -> dict[str, dict[str, object]]:
|
||
entries: dict[str, dict[str, object]] = {}
|
||
for current, dirs, files in os.walk(root, followlinks=False):
|
||
base = Path(current)
|
||
kept: list[str] = []
|
||
for name in sorted(dirs):
|
||
path = base / name
|
||
if is_excluded(path, True):
|
||
continue
|
||
if path.is_symlink():
|
||
rel = path.relative_to(root).as_posix()
|
||
entries[rel] = {"kind": "symlink", "target": os.readlink(path)}
|
||
else:
|
||
kept.append(name)
|
||
dirs[:] = kept
|
||
for name in sorted(files):
|
||
path = base / name
|
||
if is_excluded(path, False):
|
||
continue
|
||
rel = path.relative_to(root).as_posix()
|
||
if path.is_symlink():
|
||
entries[rel] = {"kind": "symlink", "target": os.readlink(path)}
|
||
elif path.is_file():
|
||
item = path.stat()
|
||
entries[rel] = {
|
||
"kind": "file",
|
||
"mode": stat.S_IMODE(item.st_mode),
|
||
"size": item.st_size,
|
||
"sha256": sha256_file(path),
|
||
}
|
||
return dict(sorted(entries.items()))
|
||
|
||
|
||
def run_git(path: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
||
return subprocess.run(
|
||
["git", "-C", str(path), *args],
|
||
text=True,
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.PIPE,
|
||
check=False,
|
||
)
|
||
|
||
|
||
def excluded_git_status_line(line: str) -> bool:
|
||
value = line[3:] if len(line) > 3 else line
|
||
if " -> " in value:
|
||
value = value.split(" -> ", 1)[1]
|
||
value = value.strip().strip('"')
|
||
pure = PurePosixPath(value)
|
||
return any(part in EXCLUDED_DIR_NAMES for part in pure.parts) or (
|
||
pure.name in EXCLUDED_FILE_NAMES
|
||
or PurePosixPath(pure.name).suffix.lower() in EXCLUDED_FILE_SUFFIXES
|
||
)
|
||
|
||
|
||
def git_state(path: Path) -> dict[str, object]:
|
||
top = run_git(path, "rev-parse", "--show-toplevel")
|
||
if top.returncode != 0:
|
||
return {"tracked": False, "repository_root": None, "commit": None, "dirty": None}
|
||
status = run_git(path, "status", "--porcelain", "--untracked-files=all", "--", ".")
|
||
if status.returncode != 0:
|
||
raise GuardError(f"无法读取 PSP Git 状态: {status.stderr}")
|
||
commit = run_git(path, "rev-parse", "--verify", "HEAD")
|
||
branch = run_git(path, "symbolic-ref", "--quiet", "--short", "HEAD")
|
||
git_dir = run_git(path, "rev-parse", "--absolute-git-dir")
|
||
if git_dir.returncode != 0:
|
||
raise GuardError(f"无法读取 PSP Git 元数据目录: {git_dir.stderr}")
|
||
all_lines = [line for line in status.stdout.splitlines() if line.strip()]
|
||
lines = [line for line in all_lines if not excluded_git_status_line(line)]
|
||
return {
|
||
"tracked": True,
|
||
"repository_root": str(Path(top.stdout.strip()).resolve()),
|
||
"git_dir": str(Path(git_dir.stdout.strip()).resolve()),
|
||
"commit": commit.stdout.strip() if commit.returncode == 0 else None,
|
||
"unborn": commit.returncode != 0,
|
||
"branch": branch.stdout.strip() if branch.returncode == 0 else None,
|
||
"dirty": bool(lines),
|
||
"status": lines,
|
||
"ignored_generated_status": [
|
||
line for line in all_lines if excluded_git_status_line(line)
|
||
],
|
||
}
|
||
|
||
|
||
def repository_identity(path: Path, state: dict[str, object] | None = None) -> dict[str, object]:
|
||
state = git_state(path) if state is None else state
|
||
if state.get("tracked") is not True:
|
||
raise GuardError(f"目标 PSP 必须是预先创建的独立 Git 仓库: {path}")
|
||
expected_git_dir = path / ".git"
|
||
git_dir = Path(str(state.get("git_dir"))).resolve()
|
||
if state.get("repository_root") != str(path) or git_dir != expected_git_dir:
|
||
raise GuardError(f"目标 PSP 必须以自身目录和 .git 作为独立 Git 仓库: {path}")
|
||
if not expected_git_dir.is_dir() or expected_git_dir.is_symlink():
|
||
raise GuardError(f"目标 PSP 的 .git 必须是普通目录: {expected_git_dir}")
|
||
config = expected_git_dir / "config"
|
||
head = expected_git_dir / "HEAD"
|
||
if not config.is_file() or config.is_symlink() or not head.is_file() or head.is_symlink():
|
||
raise GuardError(f"目标 PSP Git config/HEAD 缺失或不是普通文件: {path}")
|
||
remotes: dict[str, list[dict[str, str]]] = {}
|
||
remote_names = run_git(path, "remote")
|
||
if remote_names.returncode != 0:
|
||
raise GuardError(f"无法读取目标 PSP Git 远端: {remote_names.stderr}")
|
||
for name in sorted(line for line in remote_names.stdout.splitlines() if line):
|
||
urls = run_git(path, "remote", "get-url", "--all", name)
|
||
if urls.returncode != 0:
|
||
raise GuardError(f"无法读取目标 PSP Git 远端 {name}: {urls.stderr}")
|
||
remotes[name] = [
|
||
{"sha256": hashlib.sha256(url.encode("utf-8")).hexdigest()}
|
||
for url in urls.stdout.splitlines()
|
||
]
|
||
return {
|
||
"repository_root": str(path),
|
||
"git_dir": str(expected_git_dir),
|
||
"commit": state.get("commit"),
|
||
"unborn": state.get("unborn"),
|
||
"branch": state.get("branch"),
|
||
"config_sha256": sha256_file(config),
|
||
"head_sha256": sha256_file(head),
|
||
"remotes": remotes,
|
||
}
|
||
|
||
|
||
def copy_committed_head(reference: Path, staging: Path, state: dict[str, object]) -> None:
|
||
repository_value = state.get("repository_root")
|
||
commit = state.get("commit")
|
||
if not isinstance(repository_value, str) or not isinstance(commit, str):
|
||
raise GuardError("参考 PSP 缺少可追踪的 Git HEAD")
|
||
repository = Path(repository_value)
|
||
try:
|
||
prefix = reference.relative_to(repository)
|
||
except ValueError as exc:
|
||
raise GuardError("参考 PSP 不在其 Git repository_root 内") from exc
|
||
command = ["git", "-C", str(repository), "ls-tree", "-r", "-z", commit]
|
||
if prefix.parts:
|
||
command.extend(["--", prefix.as_posix()])
|
||
listing = subprocess.run(
|
||
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False
|
||
)
|
||
if listing.returncode != 0:
|
||
raise GuardError(f"无法读取参考 PSP HEAD 清单: {listing.stderr.decode(errors='replace')}")
|
||
staging.mkdir()
|
||
entries = [item for item in listing.stdout.split(b"\0") if item]
|
||
for raw in entries:
|
||
try:
|
||
metadata, raw_path = raw.split(b"\t", 1)
|
||
mode, kind, _object_id = metadata.decode("ascii").split(" ", 2)
|
||
repository_path = PurePosixPath(raw_path.decode("utf-8"))
|
||
except (ValueError, UnicodeDecodeError) as exc:
|
||
raise GuardError("参考 PSP Git tree 含无法解析的条目") from exc
|
||
if kind != "blob" or mode not in {"100644", "100755"}:
|
||
raise GuardError(
|
||
f"参考 PSP HEAD 含不支持的 symlink/submodule/特殊条目: {repository_path}"
|
||
)
|
||
if prefix.parts:
|
||
try:
|
||
relative = repository_path.relative_to(PurePosixPath(prefix.as_posix()))
|
||
except ValueError as exc:
|
||
raise GuardError(f"Git tree 条目逃逸参考 PSP: {repository_path}") from exc
|
||
else:
|
||
relative = repository_path
|
||
relative_path = Path(relative.as_posix())
|
||
if any(part in EXCLUDED_DIR_NAMES for part in relative.parts) or is_excluded(
|
||
relative_path, False
|
||
):
|
||
continue
|
||
destination = staging / relative_path
|
||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||
content = subprocess.run(
|
||
["git", "-C", str(repository), "show", f"{commit}:{repository_path.as_posix()}"],
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.PIPE,
|
||
check=False,
|
||
)
|
||
if content.returncode != 0:
|
||
raise GuardError(
|
||
f"无法读取参考 PSP HEAD 文件 {repository_path}: "
|
||
f"{content.stderr.decode(errors='replace')}"
|
||
)
|
||
destination.write_bytes(content.stdout)
|
||
destination.chmod(0o755 if mode == "100755" else 0o644)
|
||
|
||
|
||
def reference_matches_manifest(reference: Path, manifest: dict[str, object]) -> bool:
|
||
mode = manifest.get("reference_baseline")
|
||
if mode == "committed-head":
|
||
expected = manifest.get("reference_git")
|
||
if not isinstance(expected, dict):
|
||
return False
|
||
current = git_state(reference)
|
||
return current.get("commit") == expected.get("commit")
|
||
source = manifest.get("source_inventory")
|
||
return isinstance(source, dict) and inventory(reference) == source
|
||
|
||
|
||
def normalize_paths(values: list[str], field: str) -> set[str]:
|
||
normalized: set[str] = set()
|
||
for value in values:
|
||
pure = PurePosixPath(value)
|
||
if pure.is_absolute() or ".." in pure.parts or value in {"", "."}:
|
||
raise GuardError(f"{field} 必须是目录内精确相对路径: {value!r}")
|
||
if any(character in value for character in "*?[]"):
|
||
raise GuardError(f"{field} 不允许通配符: {value!r}")
|
||
normalized.add(pure.as_posix())
|
||
return normalized
|
||
|
||
|
||
def compare(
|
||
source: dict[str, dict[str, object]], candidate: dict[str, dict[str, object]]
|
||
) -> dict[str, list[str]]:
|
||
source_paths = set(source)
|
||
candidate_paths = set(candidate)
|
||
return {
|
||
"added": sorted(candidate_paths - source_paths),
|
||
"removed": sorted(source_paths - candidate_paths),
|
||
"modified": sorted(
|
||
path for path in source_paths & candidate_paths if source[path] != candidate[path]
|
||
),
|
||
}
|
||
|
||
|
||
def read_text_for_scan(path: Path) -> str | None:
|
||
if path.stat().st_size > 4 * 1024 * 1024:
|
||
return None
|
||
data = path.read_bytes()
|
||
if b"\x00" in data:
|
||
return None
|
||
try:
|
||
return data.decode("utf-8")
|
||
except UnicodeDecodeError:
|
||
return None
|
||
|
||
|
||
def scan_residues(root: Path, tokens: list[str], allowed_paths: set[str]) -> list[dict[str, object]]:
|
||
hits: list[dict[str, object]] = []
|
||
lowered = [(token, token.casefold()) for token in tokens if token]
|
||
if not lowered:
|
||
return hits
|
||
for relative, item in inventory(root).items():
|
||
if item.get("kind") != "file" or relative in allowed_paths:
|
||
continue
|
||
path = root / relative
|
||
text = read_text_for_scan(path)
|
||
if text is None:
|
||
continue
|
||
folded = text.casefold()
|
||
for token, token_folded in lowered:
|
||
count = folded.count(token_folded)
|
||
if count:
|
||
hits.append({"path": relative, "token": token, "count": count})
|
||
return hits
|
||
|
||
|
||
def inspect_project(target: Path, psp_root: Path = DEFAULT_PSP_ROOT) -> dict[str, object]:
|
||
psp_root = psp_root_directory(psp_root)
|
||
target = project_directory(target, psp_root, "target")
|
||
state = git_state(target)
|
||
identity = repository_identity(target, state)
|
||
entries = target_payload_entries(target)
|
||
return {
|
||
"passed": True,
|
||
"checked_at": now_utc(),
|
||
"psp_root": str(psp_root),
|
||
"target": str(target),
|
||
"project": target.name,
|
||
"mode": "existing-project" if entries else "precreated-empty-repository",
|
||
"payload_entries": entries,
|
||
"git": state,
|
||
"repository_identity": identity,
|
||
}
|
||
|
||
|
||
def prepare(
|
||
reference: Path | None,
|
||
target: Path,
|
||
manifest_path: Path,
|
||
old_tokens: list[str],
|
||
include_dirty: bool,
|
||
psp_root: Path = DEFAULT_PSP_ROOT,
|
||
) -> dict[str, object]:
|
||
psp_root = psp_root_directory(psp_root)
|
||
if reference is not None:
|
||
reference = ordinary_directory(reference, "reference")
|
||
target = project_directory(target, psp_root, "target")
|
||
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)
|
||
payload_entries = target_payload_entries(target)
|
||
if payload_entries:
|
||
raise GuardError(
|
||
"目标 PSP 已有业务内容,禁止按新项目参考复制流程覆盖;"
|
||
f"请转入现有项目更新流程: {', '.join(payload_entries)}"
|
||
)
|
||
if target_git.get("dirty") is True:
|
||
raise GuardError("目标 PSP 工作区不干净,禁止导入参考内容")
|
||
manifest_path = manifest_path.expanduser().absolute()
|
||
if manifest_path.exists() or manifest_path.is_symlink():
|
||
raise GuardError(f"manifest 已存在,禁止覆盖: {manifest_path}")
|
||
|
||
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 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,不包含这些修改"]
|
||
else:
|
||
source_before_copy = inventory(reference)
|
||
shutil.copytree(reference, staging, copy_function=shutil.copy2, ignore=copy_ignore)
|
||
if source_before_copy != inventory(reference):
|
||
raise GuardError(f"复制期间参考 PSP 发生变化;staging 保留: {staging}")
|
||
baseline_mode = "worktree"
|
||
warnings = []
|
||
staging_inventory = inventory(staging)
|
||
if baseline_mode == "worktree" and staging_inventory != source_before_copy:
|
||
raise GuardError(f"staging 与过滤后的参考清单不一致;staging 保留: {staging}")
|
||
source_before = staging_inventory
|
||
|
||
payload: dict[str, object] = {
|
||
"schema_version": 2,
|
||
"created_at": now_utc(),
|
||
"psp_root": str(psp_root),
|
||
"reference": str(reference) if reference is not None else None,
|
||
"target": str(target),
|
||
"staging": str(staging),
|
||
"target_mode": "precreated-empty-repository",
|
||
"target_repository_before_import": target_repository,
|
||
"reference_git": source_git,
|
||
"reference_baseline": baseline_mode,
|
||
"include_dirty_reference": include_dirty,
|
||
"old_tokens": sorted(set(token for token in old_tokens if token)),
|
||
"excluded_paths": excluded_paths(reference) if reference is not None else [],
|
||
"source_inventory": source_before,
|
||
"staging_inventory_before_edits": staging_inventory,
|
||
"warnings": warnings,
|
||
}
|
||
write_json_atomic(manifest_path, payload)
|
||
return {
|
||
"passed": True,
|
||
"reference": str(reference) if reference is not None else None,
|
||
"target": str(target),
|
||
"staging": str(staging),
|
||
"psp_root": str(psp_root),
|
||
"target_mode": payload["target_mode"],
|
||
"target_repository": target_repository,
|
||
"manifest": str(manifest_path),
|
||
"reference_git": source_git,
|
||
"excluded_paths": payload["excluded_paths"],
|
||
"warnings": warnings,
|
||
}
|
||
|
||
|
||
def verify(
|
||
manifest_path: Path,
|
||
allow_paths: list[str],
|
||
allowed_residues: list[str],
|
||
report_path: Path | None,
|
||
) -> dict[str, object]:
|
||
manifest_path = manifest_path.expanduser().resolve(strict=True)
|
||
manifest = load_json(manifest_path)
|
||
reference = (ordinary_directory(Path(str(manifest["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 reference is not None and not reference_matches_manifest(reference, manifest):
|
||
raise GuardError("prepare 后参考 PSP 发生变化;必须重新 prepare")
|
||
links = scan_symlinks(staging)
|
||
credentials = scan_credentials(staging)
|
||
unexpected_excluded = excluded_paths(staging)
|
||
allowed = normalize_paths(allow_paths, "allow-path")
|
||
residue_allow = normalize_paths(allowed_residues, "allow-residue")
|
||
candidate_inventory = inventory(staging)
|
||
changes = compare(source, candidate_inventory)
|
||
violations = sorted(
|
||
path for group in changes.values() for path in group if path not in allowed
|
||
)
|
||
old_tokens = manifest.get("old_tokens")
|
||
if not isinstance(old_tokens, list) or not all(isinstance(item, str) for item in old_tokens):
|
||
raise GuardError("manifest.old_tokens 非法")
|
||
residues = scan_residues(staging, old_tokens, residue_allow)
|
||
errors: list[str] = []
|
||
if links:
|
||
errors.append("staging 包含软链接")
|
||
if credentials:
|
||
errors.append("staging 包含疑似凭据")
|
||
if unexpected_excluded:
|
||
errors.append("staging 包含缓存、构建输出、日志或临时文件")
|
||
if violations:
|
||
errors.append("存在未列入精确差异白名单的修改")
|
||
if residues:
|
||
errors.append("存在未确认的参考项目残留")
|
||
report: dict[str, object] = {
|
||
"schema_version": 1,
|
||
"checked_at": now_utc(),
|
||
"manifest": str(manifest_path),
|
||
"reference": str(reference) if reference is not None else None,
|
||
"staging": str(staging),
|
||
"target": manifest.get("target"),
|
||
"allowed_paths": sorted(allowed),
|
||
"allowed_residue_paths": sorted(residue_allow),
|
||
"changes": changes,
|
||
"violations": violations,
|
||
"residues": residues,
|
||
"symlinks": links,
|
||
"credential_files": credentials,
|
||
"unexpected_excluded_paths": unexpected_excluded,
|
||
"verified_inventory": candidate_inventory,
|
||
"errors": errors,
|
||
"passed": not errors,
|
||
}
|
||
if report_path is not None:
|
||
write_json_atomic(report_path, report)
|
||
return report
|
||
|
||
|
||
def populate_precreated_repository(staging: Path, target: Path) -> None:
|
||
children = sorted(staging.iterdir(), key=lambda path: path.name)
|
||
collisions = [child.name for child in children if (target / child.name).exists()]
|
||
collisions.extend(
|
||
child.name for child in children if (target / child.name).is_symlink()
|
||
)
|
||
if collisions:
|
||
raise GuardError(
|
||
"目标 PSP 与 staging 存在路径冲突: "
|
||
+ ", ".join(sorted(set(collisions)))
|
||
)
|
||
created: list[Path] = []
|
||
try:
|
||
for source in children:
|
||
destination = target / source.name
|
||
if source.is_dir():
|
||
shutil.copytree(source, destination, copy_function=shutil.copy2)
|
||
else:
|
||
shutil.copy2(source, destination)
|
||
created.append(destination)
|
||
except OSError:
|
||
for path in reversed(created):
|
||
if path.is_dir() and not path.is_symlink():
|
||
shutil.rmtree(path)
|
||
elif path.exists() or path.is_symlink():
|
||
path.unlink()
|
||
raise
|
||
|
||
|
||
def finalize(manifest_path: Path, verify_report_path: Path) -> dict[str, object]:
|
||
manifest_path = manifest_path.expanduser().resolve(strict=True)
|
||
verify_report_path = verify_report_path.expanduser().resolve(strict=True)
|
||
manifest = load_json(manifest_path)
|
||
report = load_json(verify_report_path)
|
||
if report.get("passed") is not True:
|
||
raise GuardError("verify 报告未通过,禁止 finalize")
|
||
if report.get("manifest") != str(manifest_path):
|
||
raise GuardError("verify 报告不属于当前 manifest")
|
||
staging = ordinary_directory(Path(str(manifest.get("staging"))), "staging")
|
||
root_value = manifest.get("psp_root")
|
||
if not isinstance(root_value, str):
|
||
raise GuardError("manifest 缺少 psp_root")
|
||
psp_root = psp_root_directory(Path(root_value))
|
||
target = project_directory(Path(str(manifest.get("target"))), psp_root, "target")
|
||
expected_repository = manifest.get("target_repository_before_import")
|
||
if not isinstance(expected_repository, dict):
|
||
raise GuardError("manifest 缺少 target_repository_before_import")
|
||
current_repository = repository_identity(target)
|
||
if current_repository != expected_repository:
|
||
raise GuardError("prepare 后目标 PSP 的 Git 分支、HEAD、远端或配置已变化")
|
||
payload_entries = target_payload_entries(target)
|
||
if payload_entries:
|
||
raise GuardError(
|
||
"prepare 后目标 PSP 出现业务内容,禁止覆盖: " + ", ".join(payload_entries)
|
||
)
|
||
verified_inventory = report.get("verified_inventory")
|
||
if not isinstance(verified_inventory, dict) or inventory(staging) != verified_inventory:
|
||
raise GuardError("verify 后 staging 已变化;必须重新 verify")
|
||
populate_precreated_repository(staging, target)
|
||
if repository_identity(target) != expected_repository:
|
||
raise GuardError("导入内容时目标 PSP Git 元数据发生变化")
|
||
shutil.rmtree(staging)
|
||
return {
|
||
"passed": True,
|
||
"finalized_at": now_utc(),
|
||
"target": str(target),
|
||
"target_mode": manifest.get("target_mode"),
|
||
"repository_preserved": True,
|
||
"target_repository": expected_repository,
|
||
"manifest": str(manifest_path),
|
||
"verify_report": str(verify_report_path),
|
||
}
|
||
|
||
|
||
def audit_final(
|
||
manifest_path: Path,
|
||
allow_paths: list[str],
|
||
allowed_residues: list[str],
|
||
report_path: Path | None,
|
||
) -> dict[str, object]:
|
||
manifest_path = manifest_path.expanduser().resolve(strict=True)
|
||
manifest = load_json(manifest_path)
|
||
root_value = manifest.get("psp_root")
|
||
if not isinstance(root_value, str):
|
||
raise GuardError("manifest 缺少 psp_root")
|
||
psp_root = psp_root_directory(Path(root_value))
|
||
target = project_directory(Path(str(manifest.get("target"))), psp_root, "target")
|
||
reference = (ordinary_directory(Path(str(manifest["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")
|
||
allowed = normalize_paths(allow_paths, "allow-path")
|
||
residue_allow = normalize_paths(allowed_residues, "allow-residue")
|
||
target_inventory = inventory(target)
|
||
changes = compare(source, target_inventory)
|
||
violations = sorted(
|
||
path for group in changes.values() for path in group if path not in allowed
|
||
)
|
||
old_tokens = manifest.get("old_tokens")
|
||
if not isinstance(old_tokens, list) or not all(isinstance(item, str) for item in old_tokens):
|
||
raise GuardError("manifest.old_tokens 非法")
|
||
residues = scan_residues(target, old_tokens, residue_allow)
|
||
links = scan_symlinks(target)
|
||
credentials = scan_credentials(target)
|
||
unexpected_excluded = [path for path in excluded_paths(target) if path != ".git/"]
|
||
expected_repository = manifest.get("target_repository_before_import")
|
||
repository_unchanged = isinstance(expected_repository, dict) and (
|
||
repository_identity(target) == expected_repository
|
||
)
|
||
errors: list[str] = []
|
||
if violations:
|
||
errors.append("正式 PSP 存在未列入精确差异白名单的修改")
|
||
if residues:
|
||
errors.append("正式 PSP 存在未确认的参考项目残留")
|
||
if links:
|
||
errors.append("正式 PSP 包含软链接")
|
||
if credentials:
|
||
errors.append("正式 PSP 包含疑似凭据")
|
||
if unexpected_excluded:
|
||
errors.append("正式 PSP 包含缓存、构建输出、日志或临时文件")
|
||
if not repository_unchanged:
|
||
errors.append("正式 PSP 的 Git 分支、HEAD、远端或配置偏离 prepare 基线")
|
||
report: dict[str, object] = {
|
||
"schema_version": 1,
|
||
"checked_at": now_utc(),
|
||
"manifest": str(manifest_path),
|
||
"reference": str(reference) 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),
|
||
"changes": changes,
|
||
"violations": violations,
|
||
"residues": residues,
|
||
"symlinks": links,
|
||
"credential_files": credentials,
|
||
"unexpected_excluded_paths": unexpected_excluded,
|
||
"repository_unchanged": repository_unchanged,
|
||
"target_repository": expected_repository,
|
||
"errors": errors,
|
||
"passed": not errors,
|
||
"audited_inventory": target_inventory,
|
||
}
|
||
if report_path is not None:
|
||
write_json_atomic(report_path, report)
|
||
return report
|
||
|
||
|
||
def build_parser() -> argparse.ArgumentParser:
|
||
parser = argparse.ArgumentParser(description=__doc__)
|
||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||
|
||
inspect_parser = subparsers.add_parser(
|
||
"inspect", help="只读校验固定根目录下的预创建项目仓库"
|
||
)
|
||
inspect_parser.add_argument(
|
||
"--psp-root",
|
||
type=Path,
|
||
default=DEFAULT_PSP_ROOT,
|
||
help=f"PSP 固定根目录(默认: {DEFAULT_PSP_ROOT})",
|
||
)
|
||
inspect_parser.add_argument("--target", type=Path, required=True)
|
||
|
||
prepare_parser = subparsers.add_parser("prepare", help="复制参考 PSP 到唯一 staging")
|
||
prepare_parser.add_argument(
|
||
"--psp-root",
|
||
type=Path,
|
||
default=DEFAULT_PSP_ROOT,
|
||
help=f"PSP 固定根目录(默认: {DEFAULT_PSP_ROOT})",
|
||
)
|
||
prepare_parser.add_argument("--reference", type=Path, required=True)
|
||
prepare_parser.add_argument("--target", type=Path, required=True)
|
||
prepare_parser.add_argument("--manifest", type=Path, required=True)
|
||
prepare_parser.add_argument("--old-token", action="append", default=[])
|
||
prepare_parser.add_argument("--include-dirty", action="store_true")
|
||
|
||
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=[])
|
||
verify_parser.add_argument("--allow-residue", action="append", default=[])
|
||
verify_parser.add_argument("--report", type=Path)
|
||
|
||
finalize_parser = subparsers.add_parser(
|
||
"finalize", help="把已验证 staging 导入预创建的空项目仓库"
|
||
)
|
||
finalize_parser.add_argument("--manifest", type=Path, required=True)
|
||
finalize_parser.add_argument("--verify-report", type=Path, required=True)
|
||
|
||
audit_parser = subparsers.add_parser("audit-final", help="按 prepare 基线复核正式 PSP")
|
||
audit_parser.add_argument("--manifest", type=Path, required=True)
|
||
audit_parser.add_argument("--allow-path", action="append", default=[])
|
||
audit_parser.add_argument("--allow-residue", action="append", default=[])
|
||
audit_parser.add_argument("--report", type=Path)
|
||
|
||
return parser
|
||
|
||
|
||
def main() -> int:
|
||
args = build_parser().parse_args()
|
||
try:
|
||
if args.command == "inspect":
|
||
result = inspect_project(args.target, args.psp_root)
|
||
elif args.command == "prepare":
|
||
result = prepare(
|
||
args.reference,
|
||
args.target,
|
||
args.manifest,
|
||
args.old_token,
|
||
args.include_dirty,
|
||
args.psp_root,
|
||
)
|
||
elif args.command == "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":
|
||
result = finalize(args.manifest, args.verify_report)
|
||
elif args.command == "audit-final":
|
||
result = audit_final(
|
||
args.manifest, args.allow_path, args.allow_residue, args.report
|
||
)
|
||
else:
|
||
raise GuardError(f"未知命令: {args.command}")
|
||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||
return 0 if result.get("passed") is True else 2
|
||
except (GuardError, OSError, json.JSONDecodeError) as exc:
|
||
print(json.dumps({"passed": False, "error": str(exc)}, ensure_ascii=False, indent=2))
|
||
return 2
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|