games-development-ai/contracts/play-loop/test_reference_asset_trusted_consumption_v2.py
lili 1981a6be01
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled
feat(storage): 闭合 Agent 到 Runtime 的 OSS 游戏链路
增加不可变源码检出、两阶段 finalize/activate、Runtime 同源对象读取与 iframe 安全边界,并用 fail-closed CI 门固定契约。Git 仅提交平台实现、契约、迁移和 SoT,不包含具体游戏源码、素材、bundle 或验收证据。
2026-07-30 05:21:44 -07:00

743 lines
36 KiB
Python
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
# -*- coding: utf-8 -*-
"""ReferenceAsset/2 trusted-consumption contract tests.
本测试只验证 Task 1 新增的七条版本线、严格样本和旧契约隔离。测试通过
validate.py 的真实 CLI 与同一 stdlib 校验器执行,不把 schema 源文本当作行为。
"""
from __future__ import annotations
import json
import hashlib
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
import validate as V # noqa: E402 —— 与 CLI 使用同一校验器
CONTRACTS = (
("reference-asset-record-v2", "reference-asset-record-v2.schema.json"),
("reference-asset-registry-v2", "reference-asset-registry-v2.schema.json"),
("reference-asset-consumption-manifest", "reference-asset-consumption-manifest.schema.json"),
("reference-asset-consumption-policy", "reference-asset-consumption-policy.schema.json"),
("reference-asset-release", "reference-asset-release.schema.json"),
("reference-asset-verification-receipt", "reference-asset-verification-receipt.schema.json"),
("acceptance-provenance-v4", "acceptance-provenance-v4.schema.json"),
)
_PASS: list[tuple[str, str]] = []
_FAIL: list[tuple[str, str]] = []
REPO_ROOT = HERE.parent.parent
REGISTRY_V1 = HERE / "reference-asset-registry.initial.json"
REGISTRY_V2 = HERE / "reference-asset-registry.v2.initial.json"
POLICY_INITIAL = HERE / "reference-asset-consumption-policy.initial.json"
RELEASE_INITIAL = HERE / "reference-asset-release.initial.json"
MANIFEST = REPO_ROOT / "game-runtime/games/shanhai-xingji/reference/consumption-manifest.json"
GENERATOR = HERE / "build_reference_asset_consumption_manifest.py"
GAME_ROOT = REPO_ROOT / "game-runtime/games/shanhai-xingji"
EXPECTED_BUNDLE_HASH = "17b9073c767faf7990e0bf4563a86d55ffa81121f11e7c8ad37c8b8ce72e8cbd"
EXPECTED_MANIFEST_REF = "game-runtime/games/shanhai-xingji/reference/consumption-manifest.json"
EXPECTED_V1_REGISTRY_HASH = "cefa7c136700c7badaa5870ddbac230e4a93a4342a8254a0d130a471a2ad7b9d"
V1_BYTES_AT_TEST_START = REGISTRY_V1.read_bytes() if REGISTRY_V1.is_file() else None
def check(name: str, ok: bool, detail: str = "") -> None:
"""打印单条断言并保留失败证据,便于 RED/GREEN 原始输出复核。"""
(_PASS if ok else _FAIL).append((name, detail))
line = ("PASS " if ok else "FAIL ") + name
if not ok and detail:
line += f"\n 证据:{detail}"
print(line)
def sample(path: Path):
"""按 validate.py 的声明式样本口径展开正负样本。"""
return V._load_sample_instance(path.resolve())
def errors_of(schema_file: str, instance) -> list:
"""执行结构层和语义层完整校验,返回空列表表示通过。"""
schema = V._load((HERE / schema_file).resolve())
errors = V.validate(schema, instance, schema)
if not errors:
errors += V._semantic_validate(schema, instance)
return errors
def errors_of_path(schema_file: str, path: Path) -> list:
"""按真实样本文件校验,保留原始字节约束和声明式负样本展开。"""
schema = V._load((HERE / schema_file).resolve())
return V._validate_file(schema, path.resolve())
def repo_ref(path: Path) -> str:
"""把临时测试文件转换为仓根相对引用,模拟契约中的 ref。"""
return path.absolute().relative_to(HERE.parent.parent).as_posix()
def write_json(path: Path, value: dict) -> None:
"""写入测试专用跨文件 fixture生产文件仍由仓内样本提供。"""
path.write_bytes((json.dumps(value, ensure_ascii=False, indent=2) + "\n").encode("utf-8"))
def expected_manifest_paths(registry: dict, game_root: Path = GAME_ROOT) -> list[str]:
"""按 Task 2 的固定白名单独立推导清单路径,不复用生成器实现。"""
paths = {
(game_root / name).relative_to(REPO_ROOT).as_posix()
for name in ("README.md", "index.html", "entry.js")
}
for pattern in ("src/*.js", "assets/atlas/*.json"):
paths.update(path.relative_to(REPO_ROOT).as_posix() for path in game_root.glob(pattern))
paths.add((game_root / "assets/manifest.json").relative_to(REPO_ROOT).as_posix())
active = next(
record for record in registry.get("records", [])
if record.get("recordId") == "gac-shanhai-xingji"
)
paths.update(active.get("designRef") or [])
return sorted(paths, key=lambda value: value.encode("utf-8"))
def run_manifest_generator(
repo_root: Path,
output: Path,
source_mode: str | None = None,
) -> subprocess.CompletedProcess:
"""运行生产清单生成命令,保留 stdout/stderr 供 RED/GREEN 证据使用。"""
command = [
sys.executable,
str(GENERATOR),
"--repo-root",
str(repo_root),
"--output",
str(output),
]
if source_mode is not None:
command.extend(("--mode", source_mode))
return subprocess.run(
command,
cwd=REPO_ROOT,
capture_output=True,
text=True,
check=False,
)
def copy_manifest_inputs(root: Path, source_paths: list[str]) -> None:
"""复制固定消费范围,构造只含测试输入的临时归档。"""
# 测试归档只复制批准清单和 /1 registry不把真实工作区的其它文件带入候选范围。
for relative in source_paths:
target = root / relative
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(REPO_ROOT / relative, target)
registry_target = root / "contracts/play-loop/reference-asset-registry.initial.json"
registry_target.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(REGISTRY_V1, registry_target)
def matches_v1_trust_anchor(path: Path) -> bool:
"""只接受与批准的 /1 原始字节完全相同的 registry。"""
return path.is_file() and hashlib.sha256(path.read_bytes()).hexdigest() == EXPECTED_V1_REGISTRY_HASH
def test_task2_artifact_files_and_manifest_scope() -> None:
"""Task 2 数据文件必须存在,且清单只能覆盖批准的确定性文件集。"""
required = (REGISTRY_V2, POLICY_INITIAL, RELEASE_INITIAL, MANIFEST, GENERATOR)
missing = [str(path.relative_to(REPO_ROOT)) for path in required if not path.is_file()]
check("Task 2 生成器与四份数据文件已创建", not missing, ", ".join(missing))
if missing:
return
registry = sample(REGISTRY_V2)
policy = sample(POLICY_INITIAL)
release = sample(RELEASE_INITIAL)
manifest = sample(MANIFEST)
check("Registry/2 数据通过 strict schema", not errors_of("reference-asset-registry-v2.schema.json", registry))
check("消费 policy 数据通过 strict schema", not errors_of("reference-asset-consumption-policy.schema.json", policy))
check("可信 release 数据通过 strict schema", not errors_of("reference-asset-release.schema.json", release))
check(
"消费 manifest 数据通过 strict schema",
not errors_of("reference-asset-consumption-manifest.schema.json", manifest),
)
actual = MANIFEST.read_bytes()
check(
"仓内 manifest 原始字节符合 canonical JSON",
actual == canonical_manifest_bytes(manifest),
f"actual_sha={hashlib.sha256(actual).hexdigest()}",
)
expected = expected_manifest_paths(sample(REGISTRY_V1))
actual_paths = [entry.get("path") for entry in manifest.get("entries", [])]
check("manifest 精确覆盖批准文件集", actual_paths == expected, f"actual={actual_paths}")
check(
"manifest entries 按 UTF-8 字节序排列",
actual_paths == sorted(actual_paths, key=lambda value: value.encode("utf-8")),
str(actual_paths),
)
forbidden = ("/evidence/", "/dist/", "/test/", "/scripts/")
check(
"manifest 不含 evidence/dist/test/scripts 文件",
not any(any(f"/{part}/" in f"/{path}" for part in ("evidence", "dist", "test", "scripts"))
for path in actual_paths),
str(actual_paths),
)
entry_by_path = {entry["path"]: entry for entry in manifest.get("entries", [])}
hash_errors = []
for path in expected:
source = REPO_ROOT / path
if not source.is_file():
hash_errors.append(f"缺文件:{path}")
continue
raw = source.read_bytes()
entry = entry_by_path.get(path)
if entry is None:
hash_errors.append(f"缺 entry:{path}")
continue
if entry.get("size") != len(raw) or entry.get("sha256") != hashlib.sha256(raw).hexdigest():
hash_errors.append(path)
check("manifest entries 保存真实 size/hash", not hash_errors, ", ".join(hash_errors))
def test_task2_manifest_is_deterministic_and_detects_drift() -> None:
"""同一输入必须同字节,任一纳入源文件变化必须改变 entry 与 manifest hash。"""
source_registry = sample(REGISTRY_V1) if REGISTRY_V1.is_file() else {}
source_paths = expected_manifest_paths(source_registry) if source_registry else []
with tempfile.TemporaryDirectory(dir=REPO_ROOT) as directory:
root = Path(directory)
copy_manifest_inputs(root, source_paths)
# clean-archive 没有 Git 元数据,仍要按明确的临时输出命名排除候选文件。
for relative in (
"game-runtime/games/shanhai-xingji/src/generated-output.js",
"game-runtime/games/shanhai-xingji/assets/atlas/cache.generated.json",
):
temporary = root / relative
temporary.parent.mkdir(parents=True, exist_ok=True)
temporary.write_bytes(b"temporary output")
default_path = root / "default-worktree-manifest.json"
default = run_manifest_generator(root, default_path)
check(
"默认 worktree 模式拒绝无 Git 元数据归档",
default.returncode != 0 and "code=reference_git_metadata_unavailable" in default.stderr,
(default.stdout + default.stderr)[-2000:],
)
first_path = root / "first-manifest.json"
second_path = root / "second-manifest.json"
first = run_manifest_generator(root, first_path, "clean-archive")
second = run_manifest_generator(root, second_path, "clean-archive")
detail = (first.stdout + first.stderr + second.stdout + second.stderr)[-4000:]
check("显式 clean-archive manifest 生成器可执行", first.returncode == 0 and second.returncode == 0, detail)
if first.returncode != 0 or second.returncode != 0:
return
first_bytes = first_path.read_bytes()
second_bytes = second_path.read_bytes()
check("同一输入连续生成 manifest 字节一致", first_bytes == second_bytes)
check("clean-archive 重建结果与仓内 manifest 逐字一致", first_bytes == MANIFEST.read_bytes())
before = sample(first_path)
before_paths = {entry["path"] for entry in before["entries"]}
check(
"clean-archive 排除明确的临时输出命名",
not {
"game-runtime/games/shanhai-xingji/src/generated-output.js",
"game-runtime/games/shanhai-xingji/assets/atlas/cache.generated.json",
} & before_paths,
str(sorted(before_paths)),
)
before_hash = hashlib.sha256(first_bytes).hexdigest()
before_entry_hash = next(
entry["sha256"] for entry in before["entries"] if entry["path"].endswith("/entry.js")
)
entry_source = root / "game-runtime/games/shanhai-xingji/entry.js"
entry_source.write_bytes(entry_source.read_bytes() + b"\n// task-2 drift fixture")
drift_path = root / "drift-manifest.json"
drift = run_manifest_generator(root, drift_path, "clean-archive")
check("修改纳入源文件后生成器仍可执行", drift.returncode == 0, (drift.stdout + drift.stderr)[-2000:])
if drift.returncode != 0:
return
after = sample(drift_path)
after_hash = hashlib.sha256(drift_path.read_bytes()).hexdigest()
after_entry_hash = next(
entry["sha256"] for entry in after["entries"] if entry["path"].endswith("/entry.js")
)
check("纳入源文件漂移改变 entry hash", before_entry_hash != after_entry_hash)
check("纳入源文件漂移改变 manifest hash", before_hash != after_hash)
def test_task2_manifest_excludes_gitignored_candidates_in_worktree() -> None:
"""真实 worktree 必须按 Git ignore 语义和临时命名共同排除候选文件。"""
source_registry = sample(REGISTRY_V1) if REGISTRY_V1.is_file() else {}
source_paths = expected_manifest_paths(source_registry) if source_registry else []
with tempfile.TemporaryDirectory(dir=REPO_ROOT) as directory:
root = Path(directory)
copy_manifest_inputs(root, source_paths)
shutil.copyfile(REPO_ROOT / ".gitignore", root / ".gitignore")
with (root / ".gitignore").open("ab") as ignore_file:
ignore_file.write(
b"\n/game-runtime/games/shanhai-xingji/src/debug.js\n"
b"/game-runtime/games/shanhai-xingji/src/cache.js\n"
b"/game-runtime/games/shanhai-xingji/assets/atlas/generated.json\n"
)
initialized = subprocess.run(
["git", "init", "--quiet"],
cwd=root,
capture_output=True,
text=True,
check=False,
)
check("临时 worktree 可初始化 Git 元数据", initialized.returncode == 0, initialized.stderr[-1000:])
if initialized.returncode != 0:
return
# 这组 /2 判例复验的是迁移前已经入库的历史金标。仓根现已默认忽略所有具体游戏,
# 因此临时 worktree 必须显式复刻“批准范围已被 Git 跟踪”,新增候选仍保持未跟踪并受 ignore 约束。
tracked = subprocess.run(
["git", "add", "-f", "--", *source_paths],
cwd=root,
capture_output=True,
text=True,
check=False,
)
check("临时 worktree 复刻历史金标已跟踪状态", tracked.returncode == 0, tracked.stderr[-1000:])
if tracked.returncode != 0:
return
ignored_candidates = (
"game-runtime/games/shanhai-xingji/src/debug.js",
"game-runtime/games/shanhai-xingji/src/cache.js",
"game-runtime/games/shanhai-xingji/assets/atlas/generated.json",
)
temporary_candidates = (
"game-runtime/games/shanhai-xingji/src/entry.tmp.js",
"game-runtime/games/shanhai-xingji/assets/atlas/atlas.generated.json",
)
for relative in ignored_candidates + temporary_candidates:
candidate = root / relative
candidate.parent.mkdir(parents=True, exist_ok=True)
candidate.write_bytes(b"candidate output")
ignored_checks = [
subprocess.run(
["git", "check-ignore", "--quiet", "--", relative],
cwd=root,
capture_output=True,
check=False,
).returncode
for relative in ignored_candidates
]
check("临时 worktree 的 ignored 候选命中 Git 规则", ignored_checks == [0, 0, 0], str(ignored_checks))
output = root / "worktree-manifest.json"
result = run_manifest_generator(root, output)
check("真实 worktree manifest 生成器可执行", result.returncode == 0, (result.stdout + result.stderr)[-3000:])
if result.returncode != 0:
return
actual_paths = {entry["path"] for entry in json.loads(output.read_bytes()) ["entries"]}
check(
"真实 worktree 排除 gitignored 与临时输出候选",
not (set(ignored_candidates) | set(temporary_candidates)) & actual_paths,
str(sorted(actual_paths)),
)
check(
"真实 worktree 保持固定批准范围",
sorted(actual_paths, key=lambda value: value.encode("utf-8")) == source_paths,
str(sorted(actual_paths)),
)
def test_task2_v1_trust_anchor_rejects_pre_mutation() -> None:
"""批准 hash 必须拒绝测试启动前已经发生的 /1 registry 变异。"""
observed_hash = hashlib.sha256(REGISTRY_V1.read_bytes()).hexdigest() if REGISTRY_V1.is_file() else "missing"
check("/1 registry 原始字节匹配批准冻结 hash", observed_hash == EXPECTED_V1_REGISTRY_HASH, observed_hash)
with tempfile.TemporaryDirectory(dir=REPO_ROOT) as directory:
mutated = Path(directory) / "reference-asset-registry.initial.json"
mutated.write_bytes(REGISTRY_V1.read_bytes() + b" ")
mutated_hash = hashlib.sha256(mutated.read_bytes()).hexdigest()
check(
"预先变异的 /1 registry 被批准 hash 门拒绝",
not matches_v1_trust_anchor(mutated),
mutated_hash,
)
def test_task2_registry_policy_release_and_real_hashes() -> None:
"""Registry/2、policy、release 必须闭合真实 hash且不能改变 /1 或 bundle。"""
required = (REGISTRY_V2, POLICY_INITIAL, RELEASE_INITIAL, MANIFEST)
missing = [str(path.relative_to(REPO_ROOT)) for path in required if not path.is_file()]
check("Task 2 跨文件数据均可读取", not missing, ", ".join(missing))
if missing:
return
registry_v1 = sample(REGISTRY_V1)
registry_v2 = sample(REGISTRY_V2)
policy = sample(POLICY_INITIAL)
release = sample(RELEASE_INITIAL)
manifest = sample(MANIFEST)
records = registry_v2.get("records", [])
by_id = {record.get("recordId"): record for record in records}
check("Registry/2 机械迁移全部 15 条记录", len(records) == 15 and len(by_id) == 15, str(len(records)))
check(
"Registry/2 所有记录均为 Record/2",
all(record.get("schemaVersion") == "ReferenceAssetRecord/2" for record in records),
)
migration_errors = []
for old in registry_v1.get("records", []):
new = by_id.get(old.get("recordId"))
if new is None:
migration_errors.append(f"缺记录:{old.get('recordId')}")
continue
for field, value in old.items():
if field == "schemaVersion":
continue
if old.get("recordId") == "gac-shanhai-xingji" and field == "consumerRef":
if new.get(field) != "generation-runtime@reference-assets/2":
migration_errors.append(f"active consumerRef:{new.get(field)}")
elif new.get(field) != value:
migration_errors.append(f"{old.get('recordId')}.{field}")
for field in ("artifactRef", "consumptionManifestRef", "consumptionManifestHash"):
if field not in new:
migration_errors.append(f"缺字段:{old.get('recordId')}.{field}")
elif old.get("recordId") != "gac-shanhai-xingji" and new.get(field) is not None:
migration_errors.append(f"非 active 非 null:{old.get('recordId')}.{field}")
check("Registry/2 保留 /1 历史字段并显式迁移 null", not migration_errors, ", ".join(migration_errors))
active = by_id.get("gac-shanhai-xingji", {})
manifest_hash = hashlib.sha256(MANIFEST.read_bytes()).hexdigest()
check(
"active 记录补齐真实双身份",
active.get("artifactRef") == "game-runtime/games/shanhai-xingji/dist/shanhai-bundle.js"
and active.get("consumptionManifestRef") == EXPECTED_MANIFEST_REF
and active.get("consumptionManifestHash") == manifest_hash,
json.dumps(active, ensure_ascii=False),
)
check(
"policy 固定为 survivor-gold frozen preflight",
policy == {
"schemaVersion": "ReferenceAssetConsumptionPolicy/1",
"policyId": "survivor-gold-v1",
"recordId": "gac-shanhai-xingji",
"role": "game_content_gold",
"consumerRef": "generation-runtime@reference-assets/2",
"route": "survivor-gold",
"autoSelect": False,
"mode": "frozen_preflight",
},
json.dumps(policy, ensure_ascii=False),
)
release_errors = []
if release.get("registryRef") != "contracts/play-loop/reference-asset-registry.v2.initial.json":
release_errors.append("registryRef")
if release.get("policyRef") != "contracts/play-loop/reference-asset-consumption-policy.initial.json":
release_errors.append("policyRef")
if release.get("registryHash") != hashlib.sha256(REGISTRY_V2.read_bytes()).hexdigest():
release_errors.append("registryHash")
if release.get("policyHash") != hashlib.sha256(POLICY_INITIAL.read_bytes()).hexdigest():
release_errors.append("policyHash")
if release.get("verifierVersion") != "reference-asset-verifier/1.0.0":
release_errors.append("verifierVersion")
if not release.get("trustedRootId") or "/" in release["trustedRootId"] or "\\" in release["trustedRootId"]:
release_errors.append("trustedRootId")
check("release 绑定 registry/policy 原始字节真实 hash", not release_errors, ", ".join(release_errors))
bundle = GAME_ROOT / "dist/shanhai-bundle.js"
check(
"当前 bundle hash 保持批准值",
bundle.is_file() and hashlib.sha256(bundle.read_bytes()).hexdigest() == EXPECTED_BUNDLE_HASH,
hashlib.sha256(bundle.read_bytes()).hexdigest() if bundle.is_file() else "missing",
)
check(
"/1 registry 原始字节在本任务内保持不变",
V1_BYTES_AT_TEST_START is not None and REGISTRY_V1.read_bytes() == V1_BYTES_AT_TEST_START,
hashlib.sha256(REGISTRY_V1.read_bytes()).hexdigest(),
)
def test_receipt_rejects_registry_hash_drift() -> None:
"""release 伪造 registryHash 时,即使 receipt 跟随声明也必须失败。"""
receipt = sample(HERE / "samples/reference-asset-verification-receipt/valid/01-matching-hashes.json")
release = sample(HERE / "samples/reference-asset-release/valid/01-trusted-release.json")
registry_ref = repo_ref(HERE / "samples/reference-asset-registry-v2/valid/01-trusted-release-registry.json")
policy_ref = repo_ref(HERE / "samples/reference-asset-consumption-policy/valid/01-survivor-gold-v1.json")
with tempfile.TemporaryDirectory(dir=HERE.parent.parent) as directory:
release_path = Path(directory) / "release-registry-drift.json"
release["registryRef"] = registry_ref
release["registryHash"] = "0" * 64
release["policyRef"] = policy_ref
release["policyHash"] = receipt["policyHash"]
write_json(release_path, release)
receipt["releaseRef"] = repo_ref(release_path)
receipt["expectedRegistryHash"] = "0" * 64
receipt["observedRegistryHash"] = "0" * 64
errors = errors_of("reference-asset-verification-receipt.schema.json", receipt)
check("receipt 拒绝 registry 原始字节 hash 漂移", bool(errors), "; ".join(errors))
def test_receipt_rejects_policy_hash_drift() -> None:
"""release 伪造 policyHash 时,即使 receipt 跟随声明也必须失败。"""
receipt = sample(HERE / "samples/reference-asset-verification-receipt/valid/01-matching-hashes.json")
release = sample(HERE / "samples/reference-asset-release/valid/01-trusted-release.json")
release["registryRef"] = repo_ref(
HERE / "samples/reference-asset-registry-v2/valid/01-trusted-release-registry.json",
)
release["registryHash"] = receipt["expectedRegistryHash"]
release["policyRef"] = repo_ref(
HERE / "samples/reference-asset-consumption-policy/valid/01-survivor-gold-v1.json",
)
with tempfile.TemporaryDirectory(dir=HERE.parent.parent) as directory:
release_path = Path(directory) / "release-policy-drift.json"
release["policyHash"] = "0" * 64
write_json(release_path, release)
receipt["releaseRef"] = repo_ref(release_path)
receipt["policyHash"] = "0" * 64
errors = errors_of("reference-asset-verification-receipt.schema.json", receipt)
check("receipt 拒绝 policy 原始字节 hash 漂移", bool(errors), "; ".join(errors))
def test_receipt_requires_typed_registry_and_active_record() -> None:
"""receipt 不得把 schema 冒充 registry也不得消费非 active record。"""
base_receipt = sample(HERE / "samples/reference-asset-verification-receipt/valid/01-matching-hashes.json")
base_release = sample(HERE / "samples/reference-asset-release/valid/01-trusted-release.json")
policy_ref = repo_ref(HERE / "samples/reference-asset-consumption-policy/valid/01-survivor-gold-v1.json")
policy_hash = hashlib.sha256((HERE / "samples/reference-asset-consumption-policy/valid/01-survivor-gold-v1.json").read_bytes()).hexdigest()
registry_schema = HERE / "reference-asset-registry-v2.schema.json"
cases = []
cases.append(("registry schema", repo_ref(registry_schema), hashlib.sha256(registry_schema.read_bytes()).hexdigest()))
registry = sample(HERE / "samples/reference-asset-registry-v2/valid/01-trusted-release-registry.json")
registry["records"][0]["lifecycleStatus"] = "candidate"
cases.append(("non-active record", registry, None))
failures = []
with tempfile.TemporaryDirectory(dir=HERE.parent.parent) as directory:
for label, registry_value, declared_hash in cases:
release = dict(base_release)
release["policyRef"] = policy_ref
release["policyHash"] = policy_hash
receipt = dict(base_receipt)
if isinstance(registry_value, str):
release["registryRef"] = registry_value
release["registryHash"] = declared_hash
else:
registry_path = Path(directory) / f"{label.replace(' ', '-')}.json"
write_json(registry_path, registry_value)
release["registryRef"] = repo_ref(registry_path)
release["registryHash"] = hashlib.sha256(registry_path.read_bytes()).hexdigest()
release_path = Path(directory) / f"release-{label.replace(' ', '-')}.json"
write_json(release_path, release)
receipt["releaseRef"] = repo_ref(release_path)
receipt["expectedRegistryHash"] = release["registryHash"]
receipt["observedRegistryHash"] = release["registryHash"]
receipt["policyHash"] = policy_hash
errors = errors_of("reference-asset-verification-receipt.schema.json", receipt)
if not errors:
failures.append(label)
check("receipt 拒绝错误 registry 类型和非 active record", not failures, ", ".join(failures))
def test_provenance_reads_and_validates_receipt_files() -> None:
"""provenance 必须读取 receipt 原始文件并闭合 schema、hash 与声明身份。"""
provenance = sample(HERE / "samples/acceptance-provenance-v4/valid/01-with-verification-receipt.json")
receipt_path = HERE / "samples/reference-asset-verification-receipt/valid/01-matching-hashes.json"
receipt_ref = repo_ref(receipt_path)
receipt_hash = hashlib.sha256(receipt_path.read_bytes()).hexdigest()
cases = []
missing = json.loads(json.dumps(provenance))
missing["referenceAssetVerificationReceipts"][0] = {"ref": "contracts/play-loop/missing-receipt.json", "hash": "0" * 64}
cases.append(("missing receipt", missing))
wrong_type = json.loads(json.dumps(provenance))
schema_path = HERE / "reference-asset-verification-receipt.schema.json"
wrong_type["referenceAssetVerificationReceipts"][0] = {
"ref": repo_ref(schema_path),
"hash": hashlib.sha256(schema_path.read_bytes()).hexdigest(),
}
cases.append(("non-receipt JSON", wrong_type))
hash_drift = json.loads(json.dumps(provenance))
hash_drift["referenceAssetVerificationReceipts"][0] = {"ref": receipt_ref, "hash": "0" * 64}
cases.append(("receipt hash drift", hash_drift))
identity_drift = json.loads(json.dumps(provenance))
identity_drift["referenceAssetVerificationReceipts"][0] = {"ref": receipt_ref, "hash": receipt_hash}
identity_drift["referenceAssetRecordIds"] = ["other-record"]
identity_drift["consumedReferenceAssets"][0]["recordId"] = "other-record"
cases.append(("receipt identity drift", identity_drift))
failures = []
for label, candidate in cases:
errors = errors_of("acceptance-provenance-v4.schema.json", candidate)
if not errors:
failures.append(label)
check("provenance 拒绝 receipt 文件闭包负例", not failures, ", ".join(failures))
def test_provenance_rejects_non_regular_and_symlink_receipts() -> None:
"""provenance receipt ref 的末级对象必须是普通文件且不能是 symlink。"""
provenance = sample(HERE / "samples/acceptance-provenance-v4/valid/01-with-verification-receipt.json")
with tempfile.TemporaryDirectory(dir=HERE.parent.parent) as directory:
directory_path = Path(directory)
non_regular = json.loads(json.dumps(provenance))
non_regular["referenceAssetVerificationReceipts"][0] = {
"ref": repo_ref(directory_path),
"hash": "0" * 64,
}
symlink_path = directory_path / "receipt-link.json"
os.symlink(
HERE / "samples/reference-asset-verification-receipt/valid/01-matching-hashes.json",
symlink_path,
)
symlink = json.loads(json.dumps(provenance))
symlink["referenceAssetVerificationReceipts"][0] = {
"ref": repo_ref(symlink_path),
"hash": "0" * 64,
}
directory_errors = errors_of("acceptance-provenance-v4.schema.json", non_regular)
symlink_errors = errors_of("acceptance-provenance-v4.schema.json", symlink)
check("provenance 拒绝非普通 receipt 文件", bool(directory_errors), "; ".join(directory_errors))
check(
"provenance 拒绝末级 symlink receipt",
any("末级引用不得是 symlink" in error for error in symlink_errors),
"; ".join(symlink_errors),
)
def canonical_manifest_bytes(instance: dict) -> bytes:
"""按批准设计的项目自有 canonical JSON 字节规则生成比较值。"""
return json.dumps(
instance,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
allow_nan=False,
).encode("utf-8")
def test_schema_samples() -> None:
"""每条新契约的 valid 必须通过、invalid 必须被拦截。"""
for contract, schema_file in CONTRACTS:
schema_path = HERE / schema_file
if not schema_path.is_file():
check(f"{contract} schema 已创建", False, f"文件不存在:{schema_path}")
continue
check(f"{contract} schema 可加载", True)
schema = V._load(schema_path)
valid_dir = HERE / "samples" / contract / "valid"
invalid_dir = HERE / "samples" / contract / "invalid"
valid_files = sorted(valid_dir.glob("*.json"))
invalid_files = sorted(invalid_dir.glob("*.json"))
check(f"{contract} 有正负样本", bool(valid_files) and bool(invalid_files),
f"valid={len(valid_files)} invalid={len(invalid_files)}")
for path in valid_files:
errors = errors_of_path(schema_file, path)
check(f"{contract} 正样本通过:{path.name}", not errors, "; ".join(errors))
for path in invalid_files:
errors = errors_of_path(schema_file, path)
check(f"{contract} 负样本被拦:{path.name}", bool(errors), "负样本竟然通过")
def test_manifest_canonical_bytes() -> None:
"""manifest 正样本必须逐字等于 canonical JSON原始字节变体必须被拦截。"""
schema_file = "reference-asset-consumption-manifest.schema.json"
valid_dir = HERE / "samples/reference-asset-consumption-manifest/valid"
for path in sorted(valid_dir.glob("*.json")):
instance = V._load_sample_instance(path.resolve())
expected = canonical_manifest_bytes(instance)
actual = path.read_bytes()
check(
f"manifest 正样本原始字节 canonical{path.name}",
actual == expected,
f"actual_sha={hashlib.sha256(actual).hexdigest()} "
f"expected_sha={hashlib.sha256(expected).hexdigest()}",
)
invalid_dir = HERE / "samples/reference-asset-consumption-manifest/invalid"
for name in (
"09-bom.json",
"10-trailing-newline.json",
"11-key-order.json",
"12-non-ascii-escape.json",
):
path = invalid_dir / name
errors = errors_of_path(schema_file, path)
check(f"manifest 原始字节变体被拦:{name}", bool(errors), "原始非 canonical 字节竟然通过")
def test_validate_suite_registration() -> None:
"""validate.py suite 必须真正登记七条新契约,而非只在测试中手工调用。"""
result = subprocess.run(
[sys.executable, str(HERE / "validate.py"), "--suite", str(HERE / "samples")],
cwd=HERE,
capture_output=True,
text=True,
check=False,
)
output = result.stdout + result.stderr
check("validate.py suite 可执行", result.returncode in (0, 1),
f"returncode={result.returncode}\n{output[-4000:]}")
for contract, _ in CONTRACTS:
heading = f"══ {contract}"
start = output.find(heading)
next_heading = output.find("\n══ ", start + 1) if start >= 0 else -1
block = output[start:next_heading if next_heading >= 0 else None]
registered = start >= 0 and bool(
re.search(r"小计:正样本 (?P<v>\d+)/(?P=v) 过,负样本 (?P<i>\d+)/(?P=i) 拦", block)
)
check(f"validate.py suite 已登记且分段自洽:{contract}", registered,
block[-2000:] if block else output[-2000:])
def test_version_isolation() -> None:
"""旧 /1 仍按旧 schema 通过,新 /2 不得被旧 schema 接受。"""
old_record_path = HERE / "samples/reference-asset-record/valid/03-active-game-content-gold.json"
old_record_schema = V._load(HERE / "reference-asset-record.schema.json")
old_record = sample(old_record_path)
old_errors = V.validate(old_record_schema, old_record, old_record_schema)
check("旧 ReferenceAssetRecord/1 正样本仍按 /1 通过", not old_errors, "; ".join(old_errors))
new_record_path = HERE / "samples/reference-asset-record-v2/valid/01-active-trusted-gold.json"
new_record = sample(new_record_path)
old_schema_errors = V.validate(old_record_schema, new_record, old_record_schema)
check("新 ReferenceAssetRecord/2 不被旧 /1 schema 接受", bool(old_schema_errors),
"新 /2 样本竟被旧 schema 接受")
v2_registry_invalid = HERE / "samples/reference-asset-registry-v2/invalid/04-v1-record-cannot-enter.json"
v2_errors = errors_of("reference-asset-registry-v2.schema.json", sample(v2_registry_invalid))
check("ReferenceAssetRegistry/2 拒绝嵌入 ReferenceAssetRecord/1", bool(v2_errors),
"/1 record 竟进入 /2 registry")
def main() -> int:
"""运行所有断言并以非零状态报告失败。"""
test_schema_samples()
test_manifest_canonical_bytes()
test_receipt_rejects_registry_hash_drift()
test_receipt_rejects_policy_hash_drift()
test_receipt_requires_typed_registry_and_active_record()
test_provenance_reads_and_validates_receipt_files()
test_provenance_rejects_non_regular_and_symlink_receipts()
test_validate_suite_registration()
# schema 尚未创建时跳过会再次抛 FileNotFoundError 的隔离断言,保持 RED 是可读失败。
if all((HERE / schema_file).is_file() for _, schema_file in CONTRACTS):
test_version_isolation()
test_task2_v1_trust_anchor_rejects_pre_mutation()
test_task2_artifact_files_and_manifest_scope()
test_task2_manifest_is_deterministic_and_detects_drift()
test_task2_manifest_excludes_gitignored_candidates_in_worktree()
test_task2_registry_policy_release_and_real_hashes()
print(f"\n══ 汇总:{len(_PASS)} 过 / {len(_FAIL)} 败 ══")
if _FAIL:
for name, detail in _FAIL:
print(f"{name}: {detail.splitlines()[0] if detail else ''}")
return 1
print("全部通过:/2 strict schema、suite 登记和 /1 版本隔离均符合预期。")
return 0
if __name__ == "__main__":
raise SystemExit(main())