from __future__ import annotations
|
|
import csv
|
from datetime import datetime, timezone
|
import hashlib
|
import io
|
import json
|
import os
|
from pathlib import Path
|
from typing import Any, Callable, Mapping, Sequence
|
|
from .archive import create_exclusive_bytes, sha256_file
|
from .models import ContractError, ErrorCode
|
|
|
MANIFEST_COLUMNS = (
|
"task_id", "requested_by", "review_owner", "source_url", "source_site", "title",
|
"publisher", "report_date", "downloaded_at", "http_status", "content_type", "file_name",
|
"relative_path", "bytes", "sha256", "download_status", "error_or_note",
|
"source_cache_path", "source_file_name", "android_package", "extension_added",
|
"pdf_magic_valid", "remote_sha256", "local_sha256", "openability", "page_count",
|
"encryption_status", "schema_version", "row_id", "handoff_id", "run_id", "item_id",
|
"slot_id", "query", "candidate_id", "report_identity", "analysts", "selection_reason",
|
"remote_bytes", "local_bytes", "quota_reservation_id", "quota_terminal_event_id",
|
"quota_artifact_event_id", "status", "stop_code", "reused_without_new_trigger",
|
"external_evidence_hash", "manifested_at_utc",
|
)
|
|
|
def canonical_json_bytes(value: Any) -> bytes:
|
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=False).encode("utf-8")
|
|
|
def validate_manifest_row(row: Mapping[str, Any]) -> dict[str, str]:
|
if tuple(row.keys()) != MANIFEST_COLUMNS:
|
raise ContractError(ErrorCode.MANIFEST_INVALID, "keys", "48-key order required")
|
result = {k: "" if row[k] is None else str(row[k]) for k in MANIFEST_COLUMNS}
|
if result["source_site"] != "慧博 APP(安卓模拟器本地缓存)":
|
raise ContractError(ErrorCode.MANIFEST_INVALID, "source_site", "literal mismatch")
|
if result["android_package"] != "cn.com.hibor":
|
raise ContractError(ErrorCode.MANIFEST_INVALID, "android_package", "literal mismatch")
|
if result["download_status"] not in {"SUCCESS", "DUPLICATE", "FAILED", "STOPPED"}:
|
raise ContractError(ErrorCode.MANIFEST_INVALID, "download_status", "enum")
|
if result["status"] != result["download_status"]:
|
raise ContractError(ErrorCode.MANIFEST_INVALID, "status", "must equal download_status")
|
if result["content_type"] == "application/pdf" and result["pdf_magic_valid"].lower() != "true":
|
raise ContractError(ErrorCode.MANIFEST_INVALID, "content_type", "PDF claim without magic")
|
if result["reused_without_new_trigger"].lower() not in {"true", "false"}:
|
raise ContractError(ErrorCode.MANIFEST_INVALID, "reused", "BOOL required")
|
return result
|
|
|
def _create_or_verify(path: Path, data: bytes,
|
checkpoint: Callable[[], None] | None = None) -> None:
|
try:
|
create_exclusive_bytes(path, data, checkpoint=checkpoint)
|
except FileExistsError as exc:
|
if not path.is_file() or path.is_symlink():
|
raise ContractError(ErrorCode.PERSIST_LATE, "path", "existing non-file") from exc
|
try:
|
existing = path.read_bytes()
|
except OSError as read_exc:
|
raise ContractError(ErrorCode.RECOVERY_UNKNOWN, "path", "existing unreadable") from read_exc
|
if existing != data:
|
raise ContractError(ErrorCode.PERSIST_LATE, "path", "existing bytes differ") from exc
|
if checkpoint:
|
checkpoint()
|
|
|
def write_manifest_create_new(path: Path, rows: Sequence[Mapping[str, Any]], *,
|
checkpoint: Callable[[], None] | None = None) -> tuple[int, str]:
|
buffer = io.StringIO(newline="")
|
writer = csv.DictWriter(buffer, fieldnames=MANIFEST_COLUMNS, lineterminator="\r\n")
|
writer.writeheader()
|
for row in rows:
|
writer.writerow(validate_manifest_row(row))
|
data = buffer.getvalue().encode("utf-8")
|
_create_or_verify(path, data, checkpoint=checkpoint)
|
try:
|
parsed_rows = list(csv.DictReader(io.StringIO(path.read_text(encoding="utf-8"), newline="")))
|
except (OSError, UnicodeError, csv.Error) as exc:
|
raise ContractError(ErrorCode.MANIFEST_INVALID, "manifest", "reopen failed") from exc
|
if (parsed_rows and tuple(parsed_rows[0].keys()) != MANIFEST_COLUMNS) or (not parsed_rows and rows):
|
raise ContractError(ErrorCode.MANIFEST_INVALID, "manifest", "reopen schema mismatch")
|
if path.read_bytes() != data:
|
raise ContractError(ErrorCode.PERSIST_LATE, "manifest", "reopen bytes mismatch")
|
return len(data), hashlib.sha256(data).hexdigest()
|
|
|
def write_json_create_new(path: Path, value: Any, *,
|
checkpoint: Callable[[], None] | None = None) -> tuple[int, str]:
|
data = canonical_json_bytes(value)
|
_create_or_verify(path, data, checkpoint=checkpoint)
|
parsed = json.loads(path.read_text(encoding="utf-8"))
|
if canonical_json_bytes(parsed) != data:
|
raise ContractError(ErrorCode.PERSIST_LATE, "json", "canonical roundtrip mismatch")
|
return len(data), hashlib.sha256(data).hexdigest()
|
|
|
def write_bytes_create_new(path: Path, data: bytes, *,
|
checkpoint: Callable[[], None] | None = None) -> tuple[int, str]:
|
"""Create a closure artifact or accept only an exact durable replay."""
|
_create_or_verify(path, data, checkpoint=checkpoint)
|
try:
|
reopened = path.read_bytes()
|
except OSError as exc:
|
raise ContractError(ErrorCode.RECOVERY_UNKNOWN, "bytes", "reopen failed") from exc
|
if reopened != data:
|
raise ContractError(ErrorCode.PERSIST_LATE, "bytes", "reopen mismatch")
|
return len(data), hashlib.sha256(data).hexdigest()
|
|
|
def utc_now() -> str:
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|