冻结地图1二十分钟纵切版的平衡、证据与金标登记。 新增 ReferenceAsset/2 清单、策略、release、受信快照及 CLI/Service/acceptance provenance /4 消费链;保持 survivor live、R1 签名与部署关闭。
1105 lines
46 KiB
Python
1105 lines
46 KiB
Python
"""Task 3 参照资产可信消费门的真实文件边界测试。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import importlib
|
||
import json
|
||
import os
|
||
import shutil
|
||
import socket
|
||
import stat
|
||
import sys
|
||
import tempfile
|
||
import weakref
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
|
||
CHEAP_WORKER = Path(__file__).resolve().parents[1]
|
||
REPO_ROOT = CHEAP_WORKER.parent
|
||
sys.path.insert(0, str(CHEAP_WORKER))
|
||
|
||
import artifact_snapshot # noqa: E402
|
||
|
||
|
||
RELEASE_REF = "contracts/play-loop/reference-asset-release.initial.json"
|
||
REGISTRY_REF = "contracts/play-loop/reference-asset-registry.v2.initial.json"
|
||
POLICY_REF = "contracts/play-loop/reference-asset-consumption-policy.initial.json"
|
||
MANIFEST_REF = "game-runtime/games/shanhai-xingji/reference/consumption-manifest.json"
|
||
BUNDLE_REF = "game-runtime/games/shanhai-xingji/dist/shanhai-bundle.js"
|
||
POLICY_ID = "survivor-gold-v1"
|
||
EXPECTED_RELEASE_HASH = "5856d607dd973c64dbafc448ccff87cc825c917ede48b6072a52421569e6ccd0"
|
||
EXPECTED_REGISTRY_HASH = "57c82adcef617fdb929d056733a395f7c4133636eb16bd38926637857b331b78"
|
||
EXPECTED_POLICY_HASH = "695b6143fec3c7b30d7be5f5ce088ccb1c1add97899dae9f167bd3a9524d8bbf"
|
||
EXPECTED_MANIFEST_HASH = "0f600598cfb842b213f7d54ae1be1d07fe277a3e66b3e4ab3ad21b73dea320d9"
|
||
EXPECTED_BUNDLE_HASH = "1c760811ec435fe0f3b5ba79aa8c4fcc44119e019b3e1240ce2c51e55e25870b"
|
||
|
||
|
||
def _gate():
|
||
"""把缺少生产模块转成可记录的 RED,而不是 pytest collection error。"""
|
||
try:
|
||
return importlib.import_module("reference_asset_gate")
|
||
except ModuleNotFoundError as exc:
|
||
pytest.fail(f"reference_asset_gate 尚未实现: {exc}")
|
||
|
||
|
||
def _copy_into(root: Path, relative: str) -> None:
|
||
"""把仓内冻结输入复制到临时可信仓根,保留相对路径和原始字节。"""
|
||
target = root / relative
|
||
target.parent.mkdir(parents=True, exist_ok=True)
|
||
shutil.copyfile(REPO_ROOT / relative, target)
|
||
|
||
|
||
def _fixture_root(tmp_path: Path) -> Path:
|
||
"""建立真实 U2 release、registry、policy、bundle、manifest 和 entries 快照。"""
|
||
root = tmp_path / "trusted-root"
|
||
root.mkdir(parents=True)
|
||
for relative in (RELEASE_REF, REGISTRY_REF, POLICY_REF, MANIFEST_REF, BUNDLE_REF):
|
||
_copy_into(root, relative)
|
||
manifest = json.loads((REPO_ROOT / MANIFEST_REF).read_bytes())
|
||
for entry in manifest["entries"]:
|
||
_copy_into(root, entry["path"])
|
||
return root
|
||
|
||
|
||
def _verify(root: Path, *, declarations=None, mode="frozen_preflight", expected_release_hash=EXPECTED_RELEASE_HASH):
|
||
"""按生产入口调用可信消费门。"""
|
||
return _gate().verify_policy(
|
||
POLICY_ID,
|
||
release_ref=RELEASE_REF,
|
||
expected_release_hash=expected_release_hash,
|
||
trusted_root=root,
|
||
mode=mode,
|
||
declarations=declarations,
|
||
)
|
||
|
||
|
||
def _error_code(callable_):
|
||
"""读取专用异常稳定 code,同时禁止测试依赖异常正文。"""
|
||
with pytest.raises(Exception) as caught:
|
||
callable_()
|
||
assert hasattr(caught.value, "code")
|
||
return caught.value.code, str(caught.value)
|
||
|
||
|
||
def _canonical(value: object) -> bytes:
|
||
"""按消费 manifest 的 canonical JSON 字节口径序列化。"""
|
||
return json.dumps(
|
||
value,
|
||
ensure_ascii=False,
|
||
sort_keys=True,
|
||
separators=(",", ":"),
|
||
allow_nan=False,
|
||
).encode("utf-8")
|
||
|
||
|
||
def _rebind_release(root: Path, *, registry_hash=None, policy_hash=None) -> str:
|
||
"""让测试显式信任变异后的上游文件,以便继续覆盖 schema/语义门。"""
|
||
path = root / RELEASE_REF
|
||
release = json.loads(path.read_bytes())
|
||
if registry_hash is not None:
|
||
release["registryHash"] = registry_hash
|
||
if policy_hash is not None:
|
||
release["policyHash"] = policy_hash
|
||
raw = _canonical(release)
|
||
path.write_bytes(raw)
|
||
return hashlib.sha256(raw).hexdigest()
|
||
|
||
|
||
def _rebind_manifest_chain(root: Path) -> str:
|
||
"""同步临时 manifest、active record 和 release 的原始字节 hash。"""
|
||
manifest_hash = hashlib.sha256((root / MANIFEST_REF).read_bytes()).hexdigest()
|
||
registry_path = root / REGISTRY_REF
|
||
registry = json.loads(registry_path.read_bytes())
|
||
record = next(row for row in registry["records"] if row["recordId"] == POLICY_ID.replace("survivor-gold-v1", "gac-shanhai-xingji"))
|
||
record["consumptionManifestHash"] = manifest_hash
|
||
registry_raw = _canonical(registry)
|
||
registry_path.write_bytes(registry_raw)
|
||
return _rebind_release(root, registry_hash=hashlib.sha256(registry_raw).hexdigest())
|
||
|
||
|
||
def _write_sized_file(path: Path, size: int, fill: bytes = b"\x00") -> str:
|
||
"""写入测试文件并返回独立计算的 SHA-256;大文件用稀疏零文件控制磁盘占用。"""
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
if fill == b"\x00":
|
||
with path.open("wb") as handle:
|
||
handle.truncate(size)
|
||
digest = hashlib.sha256()
|
||
block = b"\x00" * min(1024 * 1024, size or 1)
|
||
remaining = size
|
||
while remaining:
|
||
chunk = block[:min(len(block), remaining)]
|
||
digest.update(chunk)
|
||
remaining -= len(chunk)
|
||
return digest.hexdigest()
|
||
content = (fill * ((size + len(fill) - 1) // len(fill)))[:size]
|
||
path.write_bytes(content)
|
||
return hashlib.sha256(content).hexdigest()
|
||
|
||
|
||
def _build_batch_policy(
|
||
root: Path,
|
||
name: str,
|
||
entry_specs: list[tuple[str, int, bytes]],
|
||
*,
|
||
record_id: str | None = None,
|
||
) -> dict:
|
||
"""构造一套 release/registry/policy,供多记录原子验证真实走完整 fd 链。"""
|
||
record_id = record_id or f"record-{name}"
|
||
policy_id = f"policy-{name}"
|
||
consumer_ref = "batch-test@reference-assets/2"
|
||
asset_root = f"assets/{name}"
|
||
artifact_ref = f"{asset_root}/bundle.js"
|
||
manifest_ref = f"{asset_root}/manifest.json"
|
||
registry_ref = f"contracts/{name}-registry.json"
|
||
policy_ref = f"contracts/{name}-policy.json"
|
||
release_ref = f"contracts/{name}-release.json"
|
||
|
||
bundle = f"bundle-{name}".encode("utf-8")
|
||
(root / artifact_ref).parent.mkdir(parents=True, exist_ok=True)
|
||
(root / artifact_ref).write_bytes(bundle)
|
||
entries = []
|
||
for relative, size, fill in entry_specs:
|
||
sha256 = _write_sized_file(root / relative, size, fill)
|
||
entries.append({"path": relative, "size": size, "sha256": sha256})
|
||
entries.sort(key=lambda entry: entry["path"].encode("utf-8"))
|
||
manifest = {
|
||
"schemaVersion": "ReferenceAssetConsumptionManifest/1",
|
||
"manifestId": f"manifest-{name}",
|
||
"canonicalization": "reference-asset-consumption-manifest/1",
|
||
"entries": entries,
|
||
}
|
||
manifest_raw = _canonical(manifest)
|
||
(root / manifest_ref).write_bytes(manifest_raw)
|
||
|
||
record = {
|
||
"schemaVersion": "ReferenceAssetRecord/2",
|
||
"recordId": record_id,
|
||
"role": "generation_exemplar",
|
||
"lifecycleStatus": "active",
|
||
"assetRef": asset_root,
|
||
"assetVersion": "1.0.0",
|
||
"artifactHash": hashlib.sha256(bundle).hexdigest(),
|
||
"consumerRef": consumer_ref,
|
||
"designRef": [
|
||
entry["path"] for entry in entries
|
||
if not entry["path"].startswith(asset_root + "/")
|
||
] or None,
|
||
"evidenceRefs": [],
|
||
"signedBy": "test",
|
||
"signedAt": "2026-07-27",
|
||
"artifactRef": artifact_ref,
|
||
"consumptionManifestRef": manifest_ref,
|
||
"consumptionManifestHash": hashlib.sha256(manifest_raw).hexdigest(),
|
||
}
|
||
registry = {
|
||
"schemaVersion": "ReferenceAssetRegistry/2",
|
||
"registryVersion": f"{name}.1",
|
||
"sourceOfTruth": "test",
|
||
"records": [record],
|
||
}
|
||
registry_raw = _canonical(registry)
|
||
(root / registry_ref).parent.mkdir(parents=True, exist_ok=True)
|
||
(root / registry_ref).write_bytes(registry_raw)
|
||
policy = {
|
||
"schemaVersion": "ReferenceAssetConsumptionPolicy/1",
|
||
"policyId": policy_id,
|
||
"recordId": record_id,
|
||
"role": record["role"],
|
||
"consumerRef": consumer_ref,
|
||
"route": f"route-{name}",
|
||
"autoSelect": False,
|
||
"mode": "frozen_preflight",
|
||
}
|
||
policy_raw = _canonical(policy)
|
||
(root / policy_ref).write_bytes(policy_raw)
|
||
release = {
|
||
"schemaVersion": "ReferenceAssetRelease/1",
|
||
"releaseId": f"release-{name}",
|
||
"registryRef": registry_ref,
|
||
"registryHash": hashlib.sha256(registry_raw).hexdigest(),
|
||
"policyRef": policy_ref,
|
||
"policyHash": hashlib.sha256(policy_raw).hexdigest(),
|
||
"verifierVersion": "reference-asset-verifier/1.0.0",
|
||
"trustedRootId": "wanxiang-reference-assets-root-v1",
|
||
}
|
||
release_raw = _canonical(release)
|
||
(root / release_ref).write_bytes(release_raw)
|
||
return {
|
||
"policy_id": policy_id,
|
||
"release_ref": release_ref,
|
||
"expected_release_hash": hashlib.sha256(release_raw).hexdigest(),
|
||
"trusted_root": root,
|
||
"mode": "frozen_preflight",
|
||
}
|
||
|
||
|
||
def test_capture_selected_files_reads_real_files_and_returns_immutable_snapshot(tmp_path):
|
||
"""选择性捕获应以相对 NFC 路径读取真实文件并返回不可变映射。"""
|
||
root = tmp_path / "root"
|
||
(root / "dir").mkdir(parents=True)
|
||
(root / "dir" / "a.txt").write_bytes(b"a\x00b")
|
||
(root / "z.txt").write_bytes(b"z")
|
||
|
||
capture = getattr(artifact_snapshot, "capture_selected_files", None)
|
||
assert capture is not None
|
||
snapshot = capture(root, ["z.txt", "dir/a.txt"], {"max_files": 2, "max_bytes": 64})
|
||
|
||
assert dict(snapshot.files) == {"dir/a.txt": b"a\x00b", "z.txt": b"z"}
|
||
with pytest.raises(TypeError):
|
||
snapshot.files["new.txt"] = b"nope"
|
||
assert snapshot.file_count == 2
|
||
assert snapshot.total_bytes == 4
|
||
|
||
|
||
def test_verify_policy_returns_frozen_files_records_receipts_and_snapshot_hash(tmp_path):
|
||
"""冻结输入匹配时应一次返回受保护文件、约束记录、回执和 canonical snapshot hash。"""
|
||
result = _verify(_fixture_root(tmp_path))
|
||
|
||
assert result.snapshot_hash == result.canonical_snapshot_hash
|
||
assert result.files
|
||
assert result.constraint_records[0]["recordId"] == "gac-shanhai-xingji"
|
||
assert result.receipts[0]["expectedRegistryHash"] == EXPECTED_REGISTRY_HASH
|
||
assert result.receipts[0]["observedRegistryHash"] == EXPECTED_REGISTRY_HASH
|
||
assert result.receipts[0]["expected"]["artifactHash"] == EXPECTED_BUNDLE_HASH
|
||
with pytest.raises(TypeError):
|
||
result.files["new.txt"] = b"nope"
|
||
with pytest.raises(TypeError):
|
||
result.constraint_records[0]["recordId"] = "tampered"
|
||
|
||
|
||
def test_snapshot_hash_matches_domain_vector(tmp_path):
|
||
"""snapshot hash 必须使用域标签、UTF-8 路径长度和原始内容长度向量。"""
|
||
root = _fixture_root(tmp_path)
|
||
result = _verify(root)
|
||
expected = hashlib.sha256()
|
||
expected.update(b"reference-asset-consumption-snapshot/1\n")
|
||
for path in sorted(result.files, key=lambda value: value.encode("utf-8")):
|
||
path_bytes = path.encode("utf-8")
|
||
content = result.files[path]
|
||
expected.update(len(path_bytes).to_bytes(8, "big"))
|
||
expected.update(path_bytes)
|
||
expected.update(len(content).to_bytes(8, "big"))
|
||
expected.update(content)
|
||
assert result.snapshot_hash == expected.hexdigest()
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("label", "mutate", "expected_code"),
|
||
[
|
||
("release hash", lambda root: None, "reference_registry_untrusted"),
|
||
("registry hash", lambda root: (root / REGISTRY_REF).write_bytes(
|
||
(root / REGISTRY_REF).read_bytes() + b" "), "reference_registry_untrusted"),
|
||
("registry schema", lambda root: _mutate_json(root / REGISTRY_REF, {"schemaVersion": "bad"}),
|
||
"reference_registry_invalid"),
|
||
("registry duplicate recordId", lambda root: _duplicate_registry_record(root),
|
||
"reference_registry_invalid"),
|
||
("policy hash", lambda root: (root / POLICY_REF).write_bytes(
|
||
(root / POLICY_REF).read_bytes() + b" "), "reference_registry_untrusted"),
|
||
("policy identity", lambda root: _mutate_json(root / POLICY_REF, {"policyId": "other"}),
|
||
"reference_policy_missing"),
|
||
("policy mode", lambda root: _mutate_json(root / POLICY_REF, {"mode": "live"}),
|
||
"reference_declaration_mismatch"),
|
||
("bundle hash", lambda root: (root / BUNDLE_REF).write_bytes(b"tampered bundle"),
|
||
"reference_artifact_hash_mismatch"),
|
||
("manifest hash", lambda root: (root / MANIFEST_REF).write_bytes(
|
||
(root / MANIFEST_REF).read_bytes() + b" "), "reference_manifest_hash_mismatch"),
|
||
("entry hash", lambda root: _tamper_manifest_entry(root), "reference_entry_hash_mismatch"),
|
||
],
|
||
)
|
||
def test_frozen_input_tampering_is_rejected_with_stable_code(tmp_path, label, mutate, expected_code):
|
||
"""release、registry、policy、bundle、manifest 和 entry 漂移不得形成消费快照。"""
|
||
root = _fixture_root(tmp_path)
|
||
if label == "release hash":
|
||
code, message = _error_code(lambda: _gate().verify_policy(
|
||
POLICY_ID,
|
||
release_ref=RELEASE_REF,
|
||
expected_release_hash="0" * 64,
|
||
trusted_root=root,
|
||
mode="frozen_preflight",
|
||
))
|
||
else:
|
||
mutate(root)
|
||
expected_hash = EXPECTED_RELEASE_HASH
|
||
if label in {"registry schema", "registry duplicate recordId"}:
|
||
expected_hash = _rebind_release(
|
||
root,
|
||
registry_hash=hashlib.sha256((root / REGISTRY_REF).read_bytes()).hexdigest(),
|
||
)
|
||
elif label in {"policy identity", "policy mode"}:
|
||
expected_hash = _rebind_release(
|
||
root,
|
||
policy_hash=hashlib.sha256((root / POLICY_REF).read_bytes()).hexdigest(),
|
||
)
|
||
code, message = _error_code(lambda: _verify(root, expected_release_hash=expected_hash))
|
||
assert code == expected_code
|
||
assert str(tmp_path) not in message
|
||
assert b"tampered" not in message.encode("utf-8")
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"invalid_record_id",
|
||
[
|
||
"bad\nrecord",
|
||
"bad\x00record",
|
||
("x" * 2049) + "/",
|
||
],
|
||
ids=["newline", "control", "overlong"],
|
||
)
|
||
def test_invalid_record_id_error_does_not_echo_untrusted_value(tmp_path, invalid_record_id):
|
||
"""recordId 未通过 identifier 校验时,异常正文只能保留稳定 code。"""
|
||
root = _fixture_root(tmp_path)
|
||
registry_path = root / REGISTRY_REF
|
||
registry = json.loads(registry_path.read_bytes())
|
||
record = next(row for row in registry["records"] if row["recordId"] == "gac-shanhai-xingji")
|
||
record["recordId"] = invalid_record_id
|
||
registry_raw = _canonical(registry)
|
||
registry_path.write_bytes(registry_raw)
|
||
expected_hash = _rebind_release(root, registry_hash=hashlib.sha256(registry_raw).hexdigest())
|
||
|
||
code, message = _error_code(lambda: _verify(root, expected_release_hash=expected_hash))
|
||
|
||
assert code == "reference_registry_invalid"
|
||
assert message == "code=reference_registry_invalid"
|
||
assert invalid_record_id not in message
|
||
assert "\n" not in message
|
||
assert str(tmp_path) not in message
|
||
|
||
|
||
def test_valid_record_id_remains_in_later_registry_error_context(tmp_path):
|
||
"""recordId 已合法时,后续结构错误仍可携带稳定上下文。"""
|
||
root = _fixture_root(tmp_path)
|
||
registry_path = root / REGISTRY_REF
|
||
registry = json.loads(registry_path.read_bytes())
|
||
record = next(row for row in registry["records"] if row["recordId"] == "gac-shanhai-xingji")
|
||
record["schemaVersion"] = "bad"
|
||
registry_raw = _canonical(registry)
|
||
registry_path.write_bytes(registry_raw)
|
||
expected_hash = _rebind_release(root, registry_hash=hashlib.sha256(registry_raw).hexdigest())
|
||
|
||
code, message = _error_code(lambda: _verify(root, expected_release_hash=expected_hash))
|
||
|
||
assert code == "reference_registry_invalid"
|
||
assert message == "code=reference_registry_invalid recordId=gac-shanhai-xingji"
|
||
|
||
|
||
def _mutate_json(path: Path, changes: dict) -> None:
|
||
"""对临时 JSON 输入做最小字段变异。"""
|
||
value = json.loads(path.read_bytes())
|
||
value.update(changes)
|
||
path.write_bytes(_canonical(value))
|
||
|
||
|
||
def _duplicate_registry_record(root: Path) -> None:
|
||
"""复制 active recordId,命中 registry 跨记录唯一性语义。"""
|
||
path = root / REGISTRY_REF
|
||
value = json.loads(path.read_bytes())
|
||
value["records"].append(dict(value["records"][-1]))
|
||
path.write_bytes(_canonical(value))
|
||
|
||
|
||
def _tamper_manifest_entry(root: Path) -> None:
|
||
"""只改清单覆盖的真实文件,保持 manifest/registry 双 hash 仍匹配。"""
|
||
manifest = json.loads((root / MANIFEST_REF).read_bytes())
|
||
path = root / manifest["entries"][0]["path"]
|
||
path.write_bytes(path.read_bytes() + b"tampered")
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("relative", "expected_code"),
|
||
[
|
||
("/absolute.txt", "reference_path_escape"),
|
||
("../escape.txt", "reference_path_escape"),
|
||
("dir/../../escape.txt", "reference_path_escape"),
|
||
("dir//file.txt", "reference_path_invalid"),
|
||
("dir/./file.txt", "reference_path_invalid"),
|
||
("dir\\file.txt", "reference_path_invalid"),
|
||
("", "reference_path_invalid"),
|
||
("e\u0301.txt", "reference_path_invalid"),
|
||
],
|
||
)
|
||
def test_selected_capture_rejects_unsafe_paths(tmp_path, relative, expected_code):
|
||
"""选择性读取器在打开任何对象前拒绝越界、非 NFC 和歧义路径。"""
|
||
root = tmp_path / "root"
|
||
root.mkdir()
|
||
code, _ = _error_code(lambda: artifact_snapshot.capture_selected_files(
|
||
root, [relative], {"max_files": 2, "max_bytes": 64},
|
||
))
|
||
assert code == expected_code
|
||
|
||
|
||
def test_selected_capture_rejects_normalization_collision_before_io(tmp_path):
|
||
"""NFC 规范化后重复的两个名字不能让读取顺序产生歧义。"""
|
||
root = tmp_path / "root"
|
||
root.mkdir()
|
||
(root / "é.txt").write_bytes(b"one")
|
||
code, _ = _error_code(lambda: artifact_snapshot.capture_selected_files(
|
||
root, ["é.txt", "e\u0301.txt"], None,
|
||
))
|
||
assert code == "reference_path_invalid"
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("setup", "expected_code"),
|
||
[
|
||
("root_symlink", "reference_symlink"),
|
||
("ancestor_symlink", "reference_symlink"),
|
||
("leaf_symlink", "reference_symlink"),
|
||
("missing", "reference_missing"),
|
||
("directory", "reference_not_regular"),
|
||
("fifo", "reference_not_regular"),
|
||
("socket", "reference_not_regular"),
|
||
],
|
||
)
|
||
def test_selected_capture_fails_closed_for_object_types_and_symlinks(tmp_path, setup, expected_code):
|
||
"""根、祖先、末级 symlink 及特殊文件均不得进入内存快照。"""
|
||
root = tmp_path / "root"
|
||
root.mkdir()
|
||
relative = "target.txt"
|
||
if setup == "root_symlink":
|
||
target = tmp_path / "real-root"
|
||
target.mkdir()
|
||
(target / relative).write_bytes(b"x")
|
||
root.rmdir()
|
||
root.symlink_to(target, target_is_directory=True)
|
||
root_for_read = root
|
||
else:
|
||
root_for_read = root
|
||
if setup == "ancestor_symlink":
|
||
target = tmp_path / "real-dir"
|
||
target.mkdir()
|
||
(target / "file.txt").write_bytes(b"x")
|
||
(root / "dir").symlink_to(target, target_is_directory=True)
|
||
relative = "dir/file.txt"
|
||
elif setup == "leaf_symlink":
|
||
(root / "real.txt").write_bytes(b"x")
|
||
(root / relative).symlink_to(root / "real.txt")
|
||
elif setup == "directory":
|
||
(root / relative).mkdir()
|
||
elif setup == "fifo":
|
||
if not hasattr(os, "mkfifo"):
|
||
pytest.skip("平台没有 FIFO")
|
||
os.mkfifo(root / relative)
|
||
elif setup == "socket":
|
||
relative = "s"
|
||
shutil.rmtree(root)
|
||
socket_parent = "/private/tmp" if Path("/private/tmp").is_dir() else "/tmp"
|
||
root = Path(tempfile.mkdtemp(prefix="rasset-", dir=socket_parent))
|
||
root_for_read = root
|
||
listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||
listener.bind(str(root / relative))
|
||
elif setup == "missing":
|
||
pass
|
||
if setup != "socket":
|
||
listener = None
|
||
try:
|
||
code, _ = _error_code(lambda: artifact_snapshot.capture_selected_files(
|
||
root_for_read, [relative], None,
|
||
))
|
||
assert code == expected_code
|
||
finally:
|
||
if setup == "socket":
|
||
listener.close()
|
||
(root / relative).unlink(missing_ok=True)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("limits", "expected_code"),
|
||
[
|
||
({"max_files": 1}, "reference_oversize"),
|
||
({"max_bytes": 2}, "reference_oversize"),
|
||
({"max_file_bytes": 2}, "reference_oversize"),
|
||
],
|
||
)
|
||
def test_selected_capture_enforces_file_count_and_logical_size_limits(tmp_path, limits, expected_code):
|
||
"""文件数、总记录和单文件上限在读前按逻辑大小 fail-closed。"""
|
||
root = tmp_path / "root"
|
||
root.mkdir()
|
||
(root / "a.txt").write_bytes(b"abc")
|
||
paths = ["a.txt", "missing.txt"] if limits.get("max_files") else ["a.txt"]
|
||
code, _ = _error_code(lambda: artifact_snapshot.capture_selected_files(root, paths, limits))
|
||
assert code == expected_code
|
||
|
||
|
||
def test_manifest_must_be_canonical_and_entries_must_be_sorted_and_bounded(tmp_path):
|
||
"""非 canonical、重复/乱序、超项数和超清单字节均拒绝且不读取 entry。"""
|
||
root = _fixture_root(tmp_path)
|
||
manifest_path = root / MANIFEST_REF
|
||
manifest = json.loads(manifest_path.read_bytes())
|
||
manifest["entries"] = list(reversed(manifest["entries"]))
|
||
manifest_path.write_bytes(json.dumps(manifest, ensure_ascii=False).encode("utf-8"))
|
||
code, _ = _error_code(lambda: _verify(root))
|
||
assert code == "reference_manifest_hash_mismatch"
|
||
|
||
|
||
def test_manifest_path_outside_protected_asset_roots_is_rejected_before_entry_io(tmp_path):
|
||
"""清单只能读取 active 记录的 assetRef 与批准 designRef 覆盖范围。"""
|
||
root = _fixture_root(tmp_path)
|
||
manifest_path = root / MANIFEST_REF
|
||
manifest = json.loads(manifest_path.read_bytes())
|
||
manifest["entries"][0]["path"] = "contracts/play-loop/reference-asset-release.initial.json"
|
||
manifest["entries"].sort(key=lambda entry: entry["path"].encode("utf-8"))
|
||
manifest_path.write_bytes(_canonical(manifest))
|
||
expected_hash = _rebind_manifest_chain(root)
|
||
code, _ = _error_code(lambda: _verify(root, expected_release_hash=expected_hash))
|
||
assert code == "reference_path_escape"
|
||
|
||
|
||
def test_declarations_must_match_policy_and_empty_live_mode_is_rejected(tmp_path):
|
||
"""声明缺失/冲突及 frozen policy 误用于 live 均在读取 bundle 前拒绝。"""
|
||
root = _fixture_root(tmp_path)
|
||
for declarations in (
|
||
{},
|
||
{"referenceAssetRecordIds": ["other"], "consumerRef": "generation-runtime@reference-assets/2"},
|
||
{"referenceAssetRecordIds": ["gac-shanhai-xingji"], "consumerRef": "other"},
|
||
):
|
||
code, _ = _error_code(lambda: _verify(root, declarations=declarations))
|
||
assert code == "reference_declaration_mismatch"
|
||
code, _ = _error_code(lambda: _verify(root, declarations=[], mode="live"))
|
||
assert code == "reference_declaration_mismatch"
|
||
|
||
|
||
def test_unknown_policy_is_rejected_before_placeholder_asset_io(tmp_path, monkeypatch):
|
||
"""未知 policy 必须在任何 bundle/manifest/entry 占位读取前终止。"""
|
||
root = _fixture_root(tmp_path)
|
||
gate = _gate()
|
||
calls = []
|
||
original = artifact_snapshot.capture_selected_files
|
||
|
||
def spy(*args, **kwargs):
|
||
calls.append(args[1] if len(args) > 1 else kwargs.get("paths"))
|
||
return original(*args, **kwargs)
|
||
|
||
monkeypatch.setattr(artifact_snapshot, "capture_selected_files", spy)
|
||
code, _ = _error_code(lambda: gate.verify_policy(
|
||
"unknown-policy",
|
||
release_ref=RELEASE_REF,
|
||
expected_release_hash=EXPECTED_RELEASE_HASH,
|
||
trusted_root=root,
|
||
mode="frozen_preflight",
|
||
))
|
||
assert code == "reference_policy_missing"
|
||
assert calls == []
|
||
|
||
|
||
@pytest.mark.parametrize("entrypoint", ["single", "batch"])
|
||
def test_auto_select_policy_is_rejected_before_target_asset_io(tmp_path, monkeypatch, entrypoint):
|
||
"""autoSelect=true 的禁用策略在两条入口均不得读取目标资产。"""
|
||
root = _fixture_root(tmp_path)
|
||
_mutate_json(root / POLICY_REF, {"autoSelect": True})
|
||
expected_hash = _rebind_release(
|
||
root,
|
||
policy_hash=hashlib.sha256((root / POLICY_REF).read_bytes()).hexdigest(),
|
||
)
|
||
manifest = json.loads((root / MANIFEST_REF).read_bytes())
|
||
target_paths = {BUNDLE_REF, MANIFEST_REF, *(entry["path"] for entry in manifest["entries"])}
|
||
calls = []
|
||
original = artifact_snapshot.capture_selected_files
|
||
|
||
def spy(*args, **kwargs):
|
||
"""记录真实选择性读取请求,保留原读取器验证其余信任链。"""
|
||
paths = tuple(args[1] if len(args) > 1 else kwargs["paths"])
|
||
calls.extend(paths)
|
||
return original(*args, **kwargs)
|
||
|
||
monkeypatch.setattr(artifact_snapshot, "capture_selected_files", spy)
|
||
if entrypoint == "single":
|
||
call = lambda: _verify(root, expected_release_hash=expected_hash)
|
||
else:
|
||
call = lambda: _gate().verify_policies([{
|
||
"policy_id": POLICY_ID,
|
||
"release_ref": RELEASE_REF,
|
||
"expected_release_hash": expected_hash,
|
||
"trusted_root": root,
|
||
"mode": "frozen_preflight",
|
||
}])
|
||
|
||
code, _ = _error_code(call)
|
||
|
||
assert code == "reference_policy_missing"
|
||
assert target_paths.isdisjoint(calls)
|
||
|
||
|
||
def test_non_active_record_rejects_without_reading_placeholder_asset(tmp_path, monkeypatch):
|
||
"""registry 中非 active 记录即使声明了路径,也不能触发占位资产 I/O。"""
|
||
root = _fixture_root(tmp_path)
|
||
registry_path = root / REGISTRY_REF
|
||
registry = json.loads(registry_path.read_bytes())
|
||
active = next(row for row in registry["records"] if row["recordId"] == "gac-shanhai-xingji")
|
||
active["lifecycleStatus"] = "migration_pending"
|
||
active["consumerRef"] = None
|
||
active["designRef"] = None
|
||
active["artifactRef"] = None
|
||
active["consumptionManifestRef"] = None
|
||
active["consumptionManifestHash"] = None
|
||
registry_raw = _canonical(registry)
|
||
registry_path.write_bytes(registry_raw)
|
||
expected_hash = _rebind_release(root, registry_hash=hashlib.sha256(registry_raw).hexdigest())
|
||
calls = []
|
||
gate = _gate()
|
||
original = artifact_snapshot.capture_selected_files
|
||
|
||
def spy(*args, **kwargs):
|
||
calls.append(tuple(args[1]))
|
||
return original(*args, **kwargs)
|
||
|
||
monkeypatch.setattr(artifact_snapshot, "capture_selected_files", spy)
|
||
code, _ = _error_code(lambda: _verify(root, expected_release_hash=expected_hash))
|
||
assert code == "reference_registry_invalid"
|
||
assert all(BUNDLE_REF not in call and MANIFEST_REF not in call for call in calls)
|
||
|
||
|
||
def test_all_or_nothing_does_not_expose_files_or_receipts_after_entry_failure(tmp_path):
|
||
"""任一 entry 失败时 verify_policy 只抛异常,不返回任何半成品对象。"""
|
||
root = _fixture_root(tmp_path)
|
||
manifest = json.loads((root / MANIFEST_REF).read_bytes())
|
||
bad_entry = manifest["entries"][-1]
|
||
(root / bad_entry["path"]).write_bytes(b"changed during setup")
|
||
code, _ = _error_code(lambda: _verify(root))
|
||
assert code == "reference_entry_hash_mismatch"
|
||
|
||
|
||
def test_cwd_does_not_change_trusted_root_resolution(tmp_path, monkeypatch):
|
||
"""releaseRef 始终相对 trusted_root 解析,调用 cwd 不参与身份和结果。"""
|
||
root = _fixture_root(tmp_path)
|
||
first = _verify(root)
|
||
other = tmp_path / "other-cwd"
|
||
other.mkdir()
|
||
monkeypatch.chdir(other)
|
||
second = _verify(root)
|
||
assert first.snapshot_hash == second.snapshot_hash
|
||
assert dict(first.files) == dict(second.files)
|
||
|
||
|
||
def test_release_registry_policy_and_manifest_missing_map_to_stable_codes(tmp_path):
|
||
"""受信链各层缺失不能落到裸 FileNotFoundError 或泄露系统路径。"""
|
||
for relative, expected_code in (
|
||
(RELEASE_REF, "reference_missing"),
|
||
(REGISTRY_REF, "reference_missing"),
|
||
(POLICY_REF, "reference_policy_missing"),
|
||
(BUNDLE_REF, "reference_missing"),
|
||
(MANIFEST_REF, "reference_missing"),
|
||
):
|
||
root = _fixture_root(tmp_path / relative.replace("/", "_"))
|
||
(root / relative).unlink()
|
||
code, message = _error_code(lambda: _verify(root))
|
||
assert code == expected_code
|
||
assert str(root) not in message
|
||
|
||
|
||
def test_reading_file_changed_during_read_is_rejected(tmp_path, monkeypatch):
|
||
"""同一 fd 读中发生截断或改写必须命中 changed_during_read。"""
|
||
root = tmp_path / "root"
|
||
root.mkdir()
|
||
target = root / "file.txt"
|
||
target.write_bytes(b"original")
|
||
original_read = os.read
|
||
changed = False
|
||
|
||
def rewrite_after_first_read(fd, size):
|
||
nonlocal changed
|
||
chunk = original_read(fd, size)
|
||
if chunk and not changed:
|
||
changed = True
|
||
target.write_bytes(b"rewritten")
|
||
return chunk
|
||
|
||
monkeypatch.setattr(artifact_snapshot.os, "read", rewrite_after_first_read)
|
||
code, _ = _error_code(lambda: artifact_snapshot.capture_selected_files(root, ["file.txt"], None))
|
||
assert code == "reference_changed_during_read"
|
||
|
||
|
||
def test_sparse_file_uses_logical_size_for_budget(tmp_path):
|
||
"""稀疏文件不能借助物理占用小绕过逻辑大小预算。"""
|
||
root = tmp_path / "root"
|
||
root.mkdir()
|
||
target = root / "sparse.bin"
|
||
with target.open("wb") as handle:
|
||
handle.seek(16 * 1024 * 1024)
|
||
handle.write(b"x")
|
||
code, _ = _error_code(lambda: artifact_snapshot.capture_selected_files(
|
||
root, ["sparse.bin"], {"max_file_bytes": 16 * 1024 * 1024},
|
||
))
|
||
assert code == "reference_oversize"
|
||
|
||
|
||
def test_verify_policy_rejects_active_missing_dual_identity_and_retired_partial_identity(tmp_path):
|
||
"""active 双身份缺失和 retired 半身份均不得进入可信消费。"""
|
||
for field in ("artifactRef", "consumptionManifestRef", "consumptionManifestHash", "consumerRef"):
|
||
root = _fixture_root(tmp_path / field)
|
||
registry_path = root / REGISTRY_REF
|
||
registry = json.loads(registry_path.read_bytes())
|
||
record = next(row for row in registry["records"] if row["recordId"] == "gac-shanhai-xingji")
|
||
record[field] = None
|
||
registry_raw = _canonical(registry)
|
||
registry_path.write_bytes(registry_raw)
|
||
expected_hash = _rebind_release(root, registry_hash=hashlib.sha256(registry_raw).hexdigest())
|
||
code, _ = _error_code(lambda: _verify(root, expected_release_hash=expected_hash))
|
||
assert code == "reference_registry_invalid"
|
||
|
||
|
||
def test_registry_allows_non_active_record_with_optional_identity_fields_omitted(tmp_path):
|
||
"""candidate/migration_pending/无历史 retired 可按 Record/2 省略可选身份字段。"""
|
||
root = _fixture_root(tmp_path)
|
||
registry_path = root / REGISTRY_REF
|
||
registry = json.loads(registry_path.read_bytes())
|
||
pending = next(row for row in registry["records"] if row["recordId"] == "_template-puzzle")
|
||
for field in (
|
||
"consumerRef", "designRef", "signedBy", "signedAt", "artifactRef",
|
||
"consumptionManifestRef", "consumptionManifestHash",
|
||
):
|
||
pending.pop(field, None)
|
||
registry_raw = _canonical(registry)
|
||
registry_path.write_bytes(registry_raw)
|
||
expected_hash = _rebind_release(root, registry_hash=hashlib.sha256(registry_raw).hexdigest())
|
||
result = _verify(root, expected_release_hash=expected_hash)
|
||
assert result.constraint_records[0]["recordId"] == "gac-shanhai-xingji"
|
||
|
||
|
||
def test_registry_rejects_retired_record_with_partial_historical_identity(tmp_path):
|
||
"""retired 一旦保留任一历史签认字段,就必须保留完整双身份闭包。"""
|
||
root = _fixture_root(tmp_path)
|
||
registry_path = root / REGISTRY_REF
|
||
registry = json.loads(registry_path.read_bytes())
|
||
retired = next(row for row in registry["records"] if row["recordId"] == "_template-puzzle")
|
||
retired["lifecycleStatus"] = "retired"
|
||
retired["consumerRef"] = "historical-consumer"
|
||
for field in ("signedBy", "signedAt", "artifactRef", "consumptionManifestRef", "consumptionManifestHash"):
|
||
retired.pop(field, None)
|
||
registry_raw = _canonical(registry)
|
||
registry_path.write_bytes(registry_raw)
|
||
expected_hash = _rebind_release(root, registry_hash=hashlib.sha256(registry_raw).hexdigest())
|
||
code, _ = _error_code(lambda: _verify(root, expected_release_hash=expected_hash))
|
||
assert code == "reference_registry_invalid"
|
||
|
||
|
||
def test_selected_capture_rejects_files_over_default_single_record_limit(tmp_path):
|
||
"""默认单文件和单记录上限分别为 16 MiB 与 64 MiB。"""
|
||
root = tmp_path / "root"
|
||
root.mkdir()
|
||
target = root / "large.bin"
|
||
with target.open("wb") as handle:
|
||
handle.seek(16 * 1024 * 1024)
|
||
handle.write(b"x")
|
||
code, _ = _error_code(lambda: artifact_snapshot.capture_selected_files(root, ["large.bin"], None))
|
||
assert code == "reference_oversize"
|
||
|
||
|
||
def test_custom_limits_cannot_relax_selected_capture_hard_caps(tmp_path):
|
||
"""调用方给更大 limits 时,16 MiB 单文件、512 文件和 64 MiB 单记录硬帽仍生效。"""
|
||
root = tmp_path / "root"
|
||
root.mkdir()
|
||
|
||
oversized = root / "oversized.bin"
|
||
with oversized.open("wb") as handle:
|
||
handle.truncate(17 * 1024 * 1024)
|
||
code, _ = _error_code(lambda: artifact_snapshot.capture_selected_files(
|
||
root,
|
||
["oversized.bin"],
|
||
{"max_file_bytes": 20 * 1024 * 1024, "max_record_bytes": 80 * 1024 * 1024},
|
||
))
|
||
assert code == "reference_oversize"
|
||
|
||
many_paths = []
|
||
for index in range(513):
|
||
relative = f"many/{index:03d}.txt"
|
||
target = root / relative
|
||
target.parent.mkdir(parents=True, exist_ok=True)
|
||
target.write_bytes(b"x")
|
||
many_paths.append(relative)
|
||
code, _ = _error_code(lambda: artifact_snapshot.capture_selected_files(
|
||
root, many_paths, {"max_files": 1000},
|
||
))
|
||
assert code == "reference_oversize"
|
||
|
||
record_paths = []
|
||
for index in range(5):
|
||
relative = f"record/{index}.bin"
|
||
target = root / relative
|
||
target.parent.mkdir(parents=True, exist_ok=True)
|
||
with target.open("wb") as handle:
|
||
handle.truncate(16 * 1024 * 1024 if index < 4 else 1)
|
||
record_paths.append(relative)
|
||
code, _ = _error_code(lambda: artifact_snapshot.capture_selected_files(
|
||
root,
|
||
record_paths,
|
||
{"max_file_bytes": 20 * 1024 * 1024, "max_record_bytes": 80 * 1024 * 1024},
|
||
))
|
||
assert code == "reference_oversize"
|
||
|
||
|
||
def test_verified_result_uses_deep_read_only_containers_and_json_copy(tmp_path):
|
||
"""内建基类旁路不能篡改验证结果,显式 JSON 副本仍符合 receipt schema。"""
|
||
gate = _gate()
|
||
result = _verify(_fixture_root(tmp_path))
|
||
record = result.records[0]
|
||
receipt = result.receipts[0]
|
||
|
||
assert not isinstance(record, dict)
|
||
assert not isinstance(receipt, dict)
|
||
assert isinstance(record["designRef"], tuple)
|
||
with pytest.raises(TypeError):
|
||
dict.__setitem__(record, "recordId", "tampered")
|
||
with pytest.raises(TypeError):
|
||
dict.update(receipt, {"recordId": "tampered"})
|
||
with pytest.raises(TypeError):
|
||
list.extend(record["designRef"], ["tampered"])
|
||
|
||
receipt_json = gate.to_json_value(receipt)
|
||
assert isinstance(receipt_json, dict)
|
||
assert json.loads(json.dumps(receipt_json, ensure_ascii=False)) == receipt_json
|
||
|
||
validate_path = REPO_ROOT / "contracts/play-loop/validate.py"
|
||
spec = importlib.util.spec_from_file_location("play_loop_validate_for_receipt", validate_path)
|
||
validator = importlib.util.module_from_spec(spec)
|
||
assert spec.loader is not None
|
||
spec.loader.exec_module(validator)
|
||
schema = validator._load(REPO_ROOT / "contracts/play-loop/reference-asset-verification-receipt.schema.json")
|
||
assert validator.validate(schema, receipt_json, schema) == []
|
||
|
||
|
||
def test_files_is_a_pure_path_to_bytes_mapping_with_separate_root_index(tmp_path):
|
||
"""files 的迭代、成员关系和取值必须都只表达逻辑路径到 bytes。"""
|
||
result = _verify(_fixture_root(tmp_path))
|
||
assert all(isinstance(path, str) and isinstance(result.files[path], bytes) for path in result.files)
|
||
assert "gac-shanhai-xingji" not in result.files
|
||
with pytest.raises(KeyError):
|
||
result.files["gac-shanhai-xingji"]
|
||
assert result.reference_roots["gac-shanhai-xingji"]
|
||
with pytest.raises(TypeError):
|
||
result.reference_roots["other"] = ("assets",)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("mutation", "expected_code"),
|
||
[
|
||
("canonical-unsorted", "reference_path_invalid"),
|
||
("duplicate", "reference_path_invalid"),
|
||
("513", "reference_oversize"),
|
||
("over-1mib", "reference_oversize"),
|
||
],
|
||
)
|
||
def test_manifest_semantics_are_checked_after_rebinding_upstream_hashes(tmp_path, mutation, expected_code):
|
||
"""每类 manifest 负例都重绑上游 hash,确保命中自身语义而非陈旧 hash。"""
|
||
root = _fixture_root(tmp_path)
|
||
manifest_path = root / MANIFEST_REF
|
||
manifest = json.loads(manifest_path.read_bytes())
|
||
if mutation == "canonical-unsorted":
|
||
manifest["entries"] = list(reversed(manifest["entries"]))
|
||
elif mutation == "duplicate":
|
||
manifest["entries"].append(dict(manifest["entries"][0]))
|
||
elif mutation == "513":
|
||
manifest["entries"] = [
|
||
{"path": f"game-runtime/games/shanhai-xingji/src/generated-{index:03d}.js", "size": 0,
|
||
"sha256": hashlib.sha256(b"").hexdigest()}
|
||
for index in range(513)
|
||
]
|
||
else:
|
||
manifest["padding"] = "x" * (1024 * 1024)
|
||
manifest_path.write_bytes(_canonical(manifest))
|
||
expected_hash = _rebind_manifest_chain(root)
|
||
code, _ = _error_code(lambda: _verify(root, expected_release_hash=expected_hash))
|
||
assert code == expected_code
|
||
|
||
|
||
@pytest.mark.parametrize("field", ["role", "consumerRef"])
|
||
def test_active_record_role_and_consumer_ref_drift_are_rejected(tmp_path, field):
|
||
"""active record 与 release 绑定 policy 的 role/consumerRef 漂移必须 fail-closed。"""
|
||
root = _fixture_root(tmp_path)
|
||
registry_path = root / REGISTRY_REF
|
||
registry = json.loads(registry_path.read_bytes())
|
||
record = next(row for row in registry["records"] if row["recordId"] == "gac-shanhai-xingji")
|
||
record[field] = "generation_exemplar" if field == "role" else "drifted-consumer"
|
||
registry_raw = _canonical(registry)
|
||
registry_path.write_bytes(registry_raw)
|
||
expected_hash = _rebind_release(root, registry_hash=hashlib.sha256(registry_raw).hexdigest())
|
||
code, _ = _error_code(lambda: _verify(root, expected_release_hash=expected_hash))
|
||
assert code == "reference_declaration_mismatch"
|
||
|
||
|
||
def test_inode_replacement_after_stat_is_rejected(tmp_path, monkeypatch):
|
||
"""初次 lstat 后、open 前替换目录项 inode,必须命中 changed_during_read。"""
|
||
root = tmp_path / "root"
|
||
root.mkdir()
|
||
target = root / "file.txt"
|
||
replacement = root / "replacement.txt"
|
||
target.write_bytes(b"original")
|
||
replacement.write_bytes(b"replacement")
|
||
original_open = os.open
|
||
replaced = False
|
||
|
||
def replace_before_open(path, flags, *args, **kwargs):
|
||
nonlocal replaced
|
||
if path == "file.txt" and kwargs.get("dir_fd") is not None and not replaced:
|
||
replaced = True
|
||
os.replace(replacement, target)
|
||
return original_open(path, flags, *args, **kwargs)
|
||
|
||
monkeypatch.setattr(artifact_snapshot.os, "open", replace_before_open)
|
||
code, _ = _error_code(lambda: artifact_snapshot.capture_selected_files(root, ["file.txt"], None))
|
||
assert code == "reference_changed_during_read"
|
||
|
||
|
||
def test_unreadable_file_maps_to_stable_code_when_permissions_are_enforced(tmp_path):
|
||
"""平台执行权限位时,不可读普通文件必须映射为稳定 unreadable。"""
|
||
if hasattr(os, "geteuid") and os.geteuid() == 0:
|
||
pytest.skip("root 会绕过普通权限位")
|
||
root = tmp_path / "root"
|
||
root.mkdir()
|
||
target = root / "file.txt"
|
||
target.write_bytes(b"secret")
|
||
target.chmod(0)
|
||
try:
|
||
code, _ = _error_code(lambda: artifact_snapshot.capture_selected_files(root, ["file.txt"], None))
|
||
assert code == "reference_unreadable"
|
||
finally:
|
||
target.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
||
|
||
|
||
def test_unprotected_legacy_path_is_rejected_before_target_io(tmp_path, monkeypatch):
|
||
"""manifest 指向未受保护旧路径时,不得对该旧路径发起选择性读取。"""
|
||
root = _fixture_root(tmp_path)
|
||
legacy_path = "contracts/play-loop/reference-asset-registry.initial.json"
|
||
manifest_path = root / MANIFEST_REF
|
||
manifest = json.loads(manifest_path.read_bytes())
|
||
manifest["entries"][0]["path"] = legacy_path
|
||
manifest["entries"].sort(key=lambda entry: entry["path"].encode("utf-8"))
|
||
manifest_path.write_bytes(_canonical(manifest))
|
||
expected_hash = _rebind_manifest_chain(root)
|
||
calls = []
|
||
original = artifact_snapshot.capture_selected_files
|
||
|
||
def spy(*args, **kwargs):
|
||
paths = tuple(args[1] if len(args) > 1 else kwargs["paths"])
|
||
calls.append(paths)
|
||
return original(*args, **kwargs)
|
||
|
||
monkeypatch.setattr(artifact_snapshot, "capture_selected_files", spy)
|
||
code, _ = _error_code(lambda: _verify(root, expected_release_hash=expected_hash))
|
||
assert code == "reference_path_escape"
|
||
assert all(legacy_path not in paths for paths in calls)
|
||
|
||
|
||
def test_verify_policies_atomically_merges_multiple_records_and_deduplicates_identical_paths(tmp_path):
|
||
"""batch 全部通过后才合并,多记录文件与相同路径同字节按逻辑路径去重。"""
|
||
first_root = tmp_path / "first"
|
||
second_root = tmp_path / "second"
|
||
first_root.mkdir()
|
||
second_root.mkdir()
|
||
shared = "assets/shared/common.bin"
|
||
first = _build_batch_policy(first_root, "first", [(shared, 3, b"x"), ("assets/first/a.bin", 2, b"a")])
|
||
second = _build_batch_policy(second_root, "second", [(shared, 3, b"x"), ("assets/second/b.bin", 4, b"b")])
|
||
|
||
result = _gate().verify_policies([first, second])
|
||
assert [record["recordId"] for record in result.records] == ["record-first", "record-second"]
|
||
assert len(result.receipts) == 2
|
||
assert dict(result.files) == {
|
||
"assets/first/a.bin": b"aa",
|
||
"assets/second/b.bin": b"bbbb",
|
||
shared: b"xxx",
|
||
}
|
||
assert set(result.reference_roots) == {"record-first", "record-second"}
|
||
|
||
|
||
def test_verify_policies_accepts_exact_128_mib_of_deduplicated_logical_bytes(tmp_path):
|
||
"""两个各 64 MiB 的记录合并后,去重逻辑字节恰为 128 MiB 时允许。"""
|
||
root = tmp_path / "root"
|
||
root.mkdir()
|
||
chunk = 16 * 1024 * 1024
|
||
first_entries = [(f"assets/large-first/{index}.bin", chunk, b"\x00") for index in range(4)]
|
||
second_entries = [(f"assets/large-second/{index}.bin", chunk, b"\x00") for index in range(4)]
|
||
first = _build_batch_policy(root, "large-first", first_entries)
|
||
second = _build_batch_policy(root, "large-second", second_entries)
|
||
|
||
result = _gate().verify_policies([first, second])
|
||
assert sum(len(content) for content in result.files.values()) == 128 * 1024 * 1024
|
||
|
||
|
||
def test_verify_policies_rejects_deduplicated_total_over_128_mib(tmp_path):
|
||
"""所有单记录均不超 64 MiB 时,合并唯一逻辑路径超过 128 MiB 仍拒绝。"""
|
||
root = tmp_path / "root"
|
||
root.mkdir()
|
||
chunk = 16 * 1024 * 1024
|
||
first = _build_batch_policy(
|
||
root, "limit-first", [(f"assets/limit-first/{index}.bin", chunk, b"\x00") for index in range(4)],
|
||
)
|
||
second = _build_batch_policy(
|
||
root, "limit-second", [(f"assets/limit-second/{index}.bin", chunk, b"\x00") for index in range(4)],
|
||
)
|
||
third = _build_batch_policy(root, "limit-third", [("assets/limit-third/extra.bin", 1, b"x")])
|
||
code, _ = _error_code(lambda: _gate().verify_policies([first, second, third]))
|
||
assert code == "reference_oversize"
|
||
|
||
|
||
def test_verify_policies_does_not_construct_partial_result_when_later_record_fails(tmp_path, monkeypatch):
|
||
"""第二条记录失败时,公开结果对象一次也不能被构造。"""
|
||
root = tmp_path / "root"
|
||
root.mkdir()
|
||
first = _build_batch_policy(root, "atomic-first", [("assets/atomic-first/a.bin", 1, b"a")])
|
||
second = _build_batch_policy(root, "atomic-second", [("assets/atomic-second/b.bin", 1, b"b")])
|
||
(root / "assets/atomic-second/b.bin").write_bytes(b"tampered")
|
||
gate = _gate()
|
||
original_type = gate.VerifiedReferenceAssets
|
||
constructions = []
|
||
|
||
def track_construction(*args, **kwargs):
|
||
constructions.append((args, kwargs))
|
||
return original_type(*args, **kwargs)
|
||
|
||
monkeypatch.setattr(gate, "VerifiedReferenceAssets", track_construction)
|
||
code, _ = _error_code(lambda: gate.verify_policies([first, second]))
|
||
assert code == "reference_entry_hash_mismatch"
|
||
assert constructions == []
|
||
|
||
|
||
def test_verify_policies_rejects_same_logical_path_with_conflicting_bytes(tmp_path):
|
||
"""不同可信根出现同逻辑路径但内容冲突时,batch 必须 fail-closed。"""
|
||
first_root = tmp_path / "first"
|
||
second_root = tmp_path / "second"
|
||
first_root.mkdir()
|
||
second_root.mkdir()
|
||
shared = "assets/shared/conflict.bin"
|
||
first = _build_batch_policy(first_root, "conflict-first", [(shared, 3, b"a")])
|
||
second = _build_batch_policy(second_root, "conflict-second", [(shared, 3, b"b")])
|
||
code, _ = _error_code(lambda: _gate().verify_policies([first, second]))
|
||
assert code == "reference_entry_hash_mismatch"
|
||
|
||
|
||
def test_verify_policies_releases_each_stage_before_staging_next_record(tmp_path, monkeypatch):
|
||
"""batch 合并应在下一条记录读取前释放上一条未合并 stage。"""
|
||
root = tmp_path / "root"
|
||
root.mkdir()
|
||
requests = [
|
||
_build_batch_policy(
|
||
root,
|
||
f"stream-{index}",
|
||
[(f"assets/stream-{index}/item.bin", 1, bytes([65 + index]))],
|
||
)
|
||
for index in range(4)
|
||
]
|
||
gate = _gate()
|
||
original = gate._stage_one_policy
|
||
alive_stages = []
|
||
peak_alive = 0
|
||
|
||
def tracked_stage(*args, **kwargs):
|
||
"""用弱引用统计未合并 stage 的真实生命周期,不改变 stage 行为。"""
|
||
nonlocal peak_alive
|
||
item = original(*args, **kwargs)
|
||
def release(reference):
|
||
"""阶段对象释放时从生命周期计数中移除对应弱引用。"""
|
||
alive_stages.remove(reference)
|
||
|
||
reference = weakref.ref(item, release)
|
||
alive_stages.append(reference)
|
||
peak_alive = max(peak_alive, len(alive_stages))
|
||
return item
|
||
|
||
monkeypatch.setattr(gate, "_stage_one_policy", tracked_stage)
|
||
result = gate.verify_policies(requests)
|
||
|
||
assert len(result.records) == len(requests)
|
||
assert peak_alive <= 2
|