|
|
@@ -0,0 +1,124 @@
|
|
|
+"""正式 Artifact 的内容封印与统一复验。"""
|
|
|
+
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import hashlib
|
|
|
+import mimetypes
|
|
|
+from pathlib import Path
|
|
|
+from typing import Iterable
|
|
|
+from urllib.parse import urlparse
|
|
|
+
|
|
|
+from ..contracts.models import Artifact, ToolCallRecord
|
|
|
+
|
|
|
+
|
|
|
+class ArtifactIntegrityError(ValueError):
|
|
|
+ """正式 Artifact 缺失、无法定位或内容与 Delivery 不一致。"""
|
|
|
+
|
|
|
+
|
|
|
+def _local_path_for_uri(
|
|
|
+ uri: str,
|
|
|
+ tool_calls: Iterable[ToolCallRecord],
|
|
|
+) -> Path | None:
|
|
|
+ if urlparse(uri).scheme not in {"http", "https"}:
|
|
|
+ return Path(uri).resolve()
|
|
|
+ mapped = {
|
|
|
+ Path(mapping.local_path).resolve()
|
|
|
+ for record in tool_calls
|
|
|
+ for mapping in record.artifact_mappings
|
|
|
+ if mapping.source == uri
|
|
|
+ }
|
|
|
+ if not mapped:
|
|
|
+ return None
|
|
|
+ if len(mapped) != 1:
|
|
|
+ raise ArtifactIntegrityError(
|
|
|
+ f"远程 Artifact 对应多个本地内容:{uri}"
|
|
|
+ )
|
|
|
+ return mapped.pop()
|
|
|
+
|
|
|
+
|
|
|
+def _sha256(path: Path) -> str:
|
|
|
+ digest = hashlib.sha256()
|
|
|
+ with path.open("rb") as handle:
|
|
|
+ for chunk in iter(lambda: handle.read(1 << 20), b""):
|
|
|
+ digest.update(chunk)
|
|
|
+ return digest.hexdigest()
|
|
|
+
|
|
|
+
|
|
|
+def content_identity_for_bytes(
|
|
|
+ content: bytes,
|
|
|
+ *,
|
|
|
+ filename: str,
|
|
|
+) -> dict[str, str | int | None]:
|
|
|
+ """为尚未落盘但字节已经确定的正式 Artifact 生成内容身份。"""
|
|
|
+
|
|
|
+ return {
|
|
|
+ "content_sha256": hashlib.sha256(content).hexdigest(),
|
|
|
+ "size_bytes": len(content),
|
|
|
+ "mime_type": mimetypes.guess_type(filename)[0],
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def content_identity_for_path(path: Path) -> dict[str, str | int | None]:
|
|
|
+ """读取一次本地文件,返回可持久化的内容身份。"""
|
|
|
+
|
|
|
+ resolved = path.resolve()
|
|
|
+ if not resolved.is_file():
|
|
|
+ raise ArtifactIntegrityError(f"正式 Artifact 不存在:{resolved}")
|
|
|
+ return {
|
|
|
+ "content_sha256": _sha256(resolved),
|
|
|
+ "size_bytes": resolved.stat().st_size,
|
|
|
+ "mime_type": mimetypes.guess_type(resolved.name)[0],
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def content_identity_for_uri(
|
|
|
+ uri: str,
|
|
|
+ tool_calls: Iterable[ToolCallRecord],
|
|
|
+) -> dict[str, str | int | None]:
|
|
|
+ """封印正式 Artifact;远程 URI 必须有本地工具映射。"""
|
|
|
+
|
|
|
+ path = _local_path_for_uri(uri, tool_calls)
|
|
|
+ if path is None:
|
|
|
+ raise ArtifactIntegrityError(
|
|
|
+ f"远程 Artifact 缺少可封印的本地内容:{uri}"
|
|
|
+ )
|
|
|
+ return content_identity_for_path(path)
|
|
|
+
|
|
|
+
|
|
|
+def verify_artifact(
|
|
|
+ artifact: Artifact,
|
|
|
+ tool_calls: Iterable[ToolCallRecord],
|
|
|
+) -> None:
|
|
|
+ """根据 Delivery 中保存的身份复验同一份 Artifact 内容。"""
|
|
|
+
|
|
|
+ path = _local_path_for_uri(artifact.uri, tool_calls)
|
|
|
+ if path is None:
|
|
|
+ raise ArtifactIntegrityError(
|
|
|
+ f"远程 Artifact 缺少可复验的本地映射:{artifact.artifact_id}"
|
|
|
+ )
|
|
|
+ actual = content_identity_for_path(path)
|
|
|
+ if (
|
|
|
+ artifact.content_sha256 != actual["content_sha256"]
|
|
|
+ or artifact.size_bytes != actual["size_bytes"]
|
|
|
+ ):
|
|
|
+ raise ArtifactIntegrityError(
|
|
|
+ f"Artifact 内容已变化:{artifact.artifact_id}"
|
|
|
+ )
|
|
|
+ if (
|
|
|
+ artifact.mime_type is not None
|
|
|
+ and artifact.mime_type != actual["mime_type"]
|
|
|
+ ):
|
|
|
+ raise ArtifactIntegrityError(
|
|
|
+ f"Artifact MIME 类型已变化:{artifact.artifact_id}"
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def verify_delivery_artifacts(
|
|
|
+ artifacts: Iterable[Artifact],
|
|
|
+ tool_calls: Iterable[ToolCallRecord],
|
|
|
+) -> None:
|
|
|
+ """在各运行边界复用同一套正式 Artifact 完整性规则。"""
|
|
|
+
|
|
|
+ records = tuple(tool_calls)
|
|
|
+ for artifact in artifacts:
|
|
|
+ verify_artifact(artifact, records)
|