"""Explicit native-XML replay for an isolated local portal, never an OHIP adapter.""" from __future__ import annotations from dataclasses import dataclass, replace from datetime import date import hashlib from http.cookies import SimpleCookie import json import os from pathlib import Path import re import stat from urllib.parse import urlsplit from arr_web.arr_download_handoff import outcome_from_handoff from arr_web.arr_downloads import validate_request_id from arr_web.contracts import MAX_UPLOAD_BYTES, PortalError, Response, failure, validate_xml_payload from integrations.ohip import processing_handoff as handoff from integrations.ohip.audit_arr_capture import protected_read from integrations.ohip.capture_job import atomic_json, fingerprint, private_directory from integrations.ohip.compare_report_xml import _parse as parse_native_report VERSION = "local-native-xml-replay/v1" COOKIE = "arr_xml_replay_session" PROJECT = Path(__file__).resolve().parents[1] def document(path: Path) -> dict: from integrations.ohip.collect_arr_source import strict_json return strict_json(protected_read(path, 65536)) @dataclass(frozen=True) class NativeXMLSnapshot: root: Path manifest: dict @classmethod def create(cls, root: Path, source: Path, expected_sha256: str, report_date: str): if root.resolve().is_relative_to(PROJECT): raise ValueError("replay_data_must_be_outside_repository") # Source is read exactly once, bounded and without following a symlink. fd = os.open(source, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) with os.fdopen(fd, "rb") as handle: if not stat.S_ISREG(os.fstat(handle.fileno()).st_mode): raise ValueError("source_must_be_regular_file") raw = handle.read(MAX_UPLOAD_BYTES + 1) validate_xml_payload(raw) if hashlib.sha256(raw).hexdigest() != expected_sha256: raise ValueError("source_hash_mismatch") report = parse_native_report(raw) if report.arrival_date != report_date: raise ValueError("source_date_mismatch") private_directory(root) manifest = {"version": VERSION, "source_kind": "native_xml_replay", "report_date": report_date, "hotel_id": report.hotel, "source_sha256": expected_sha256, "source_bytes": len(raw), "source_records": len(report.rows), "oracle_calls": 0, "source_mapping_verified": False, "report_equivalence_verified": False} fd = os.open(root / "source.xml", os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) with os.fdopen(fd, "wb") as handle: handle.write(raw) handle.flush() os.fsync(handle.fileno()) atomic_json(root / "source.json", manifest, replace=False) return cls(root, manifest) @classmethod def load(cls, root: Path): private_directory(root) instance = cls(root, document(root / "source.json")) instance.payload() return instance @property def day(self): return date.fromisoformat(self.manifest["report_date"]) def payload(self) -> bytes: if (fingerprint(document(self.root / "source.json")) != fingerprint(self.manifest) or self.manifest.get("version") != VERSION): raise ValueError("replay_source_identity_changed") raw = protected_read(self.root / "source.xml", MAX_UPLOAD_BYTES) if (len(raw) != self.manifest["source_bytes"] or hashlib.sha256(raw).hexdigest() != self.manifest["source_sha256"]): raise ValueError("replay_source_changed") report = parse_native_report(raw) if (report.hotel != self.manifest["hotel_id"] or report.arrival_date != self.manifest["report_date"] or len(report.rows) != self.manifest["source_records"]): raise ValueError("replay_source_context_changed") return raw class NativeXMLReplayExecutor: def __init__(self, root, snapshot, policy, object_store, repository, service, processor=None): self.root, self.snapshot, self.policy = root, snapshot, policy self.store, self.repository, self.service, self.processor = object_store, repository, service, processor def execute(self, *, request_id, from_date, to_date, report_stage): validate_request_id(request_id) if type(from_date) is not date or from_date != to_date or from_date != self.snapshot.day: raise ValueError("replay_requires_exact_source_date") payload = self.snapshot.payload() binding = handoff.CaptureBinding("localxml-" + request_id, self.snapshot.manifest["hotel_id"], from_date.isoformat(), fingerprint(self.snapshot.manifest), VERSION) report_stage("processing") prepared = handoff.prepare(self.root, binding, "ARR.XML", payload, self.policy, processor=self.processor) receipt = handoff.deliver(Path(prepared["directory"]), prepared["manifest_sha256"], object_store=self.store, repository=self.repository, service=self.service, expected_binding=binding, expected_policy=self.policy) return outcome_from_handoff(receipt, job_id=binding.job_id, report_date=from_date, manifest_sha256=prepared["manifest_sha256"]) class SourceDateDownloads: """Reject missing fixture dates before queueing a task, retaining the existing API.""" def __init__(self, queue, snapshot): self.queue, self.snapshot = queue, snapshot @property def ready(self): return self.queue.ready @property def context_id(self): return self.queue.context_id def latest(self): return self.queue.latest() def pending_data_reviews(self): return self.queue.pending_data_reviews() def get(self, request_id): return self.queue.get(request_id) def retry(self, request_id): return self.queue.retry(request_id) def create(self, report_date, request_id): if report_date != self.snapshot.day.isoformat(): raise PortalError("REPLAY_DATE_NOT_AVAILABLE", "本机重放仅提供已导入 XML 的报表日期。", 409) return self.queue.create(report_date, request_id) class LocalReplayPortal: """Visible simulation label and a separate cookie; underlying auth/CSRF stay active.""" allow_xml_upload = False cookie_name = COOKIE environment = "local-xml-replay" source_kind = "native_xml_replay" filename_prefix = "LOCAL-REPLAY-" banner_title = "本机 XML 重放 · 模拟联调" banner_action = "点击下载将重放已导入的 XML,使用独立测试数据库。" page_title = "本机模拟" def __init__(self, application, snapshot, port): self.app, self.snapshot = application, snapshot self.authority = f"127.0.0.1:{port}" def handle(self, method, target, headers, body=b"", client_id="direct"): headers = {k.lower(): v for k, v in headers.items()} if headers.get("host") != self.authority: return self._error("REPLAY_HOST_REJECTED", 403) if headers.get("origin") not in (None, f"http://{self.authority}"): return self._error("REPLAY_ORIGIN_REJECTED", 403) path = urlsplit(target).path if method == "GET" and path == "/healthz": ready = self._health_ready() return Response(200 if ready else 503, (self.environment + " ready\n").encode() if ready else b"unavailable\n", "text/plain; charset=utf-8", {"X-ARR-Environment": self.environment, "Cache-Control": "no-store"}) if path.startswith("/api/public/") or path in {"/h5", "/h5.html"}: return self._error("REPLAY_ROUTE_DISABLED", 404) allowed = (self.allow_xml_upload and path == "/api/jobs") or path in {"/api/login", "/api/logout", "/api/arr-downloads"} or bool( re.fullmatch(r"/api/arr-downloads/[a-f0-9]{32}/retry", path)) or bool( re.fullmatch(r"/api/arr-downloads/[a-f0-9]{32}/data-review/(?:items/[^/]+|finalize)", path)) or bool( re.fullmatch(r"/api/jobs/[^/]+/review/(?:items/[0-9]+|finalize|cancel)", path)) if method != "GET" and not allowed: return self._error("REPLAY_ROUTE_DISABLED", 405) cookie = SimpleCookie() try: cookie.load(headers.get("cookie", "")) except Exception: cookie = SimpleCookie() token = cookie.get(self.cookie_name) headers["cookie"] = f"arr_session={token.value}" if token else "" response = self.app.handle(method, target, headers, body, client_id) response_headers = dict(response.headers) if "Set-Cookie" in response_headers: response_headers["Set-Cookie"] = response_headers["Set-Cookie"].replace("arr_session=", self.cookie_name + "=", 1) if "Content-Disposition" in response_headers: response_headers["Content-Disposition"] = response_headers["Content-Disposition"].replace( 'filename="arr-report.xlsx"', f'filename="{self.filename_prefix}arr-report.xlsx"').replace( "filename*=UTF-8''", "filename*=UTF-8''" + self.filename_prefix, 1) response_headers["X-ARR-Environment"] = self.environment response = replace(response, headers=response_headers) if path == "/api/arr-downloads" and method == "GET" and response.status == 200: payload = json.loads(response.body) payload["data"].update(self._download_context()) response = replace(response, body=json.dumps(payload).encode()) if response.status == 200 and response.content_type.startswith("text/html"): html = response.body.decode().replace("", '') html = html.replace("
", "" + self._banner_html(), 1).replace("