Files

855 lines
34 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())