"""Read-only reservation status evidence from a pinned, complete data capture. This module never creates a transport or changes acquired data. Unknown nonempty Oracle status strings remain verbatim evidence; no abbreviations are interpreted. """ from __future__ import annotations import hashlib import os from pathlib import Path import re import stat from urllib.parse import parse_qs, urlsplit from . import collect_arr_source as base from .audit_arr_capture import protected_read from .data_client import FETCH, typed_id VERSION = "arr-reservation-status-evidence/v1" POLICY_ID = "arr-exclude-cancelled/v1" SOURCE_VERSION = "arr-ohip-data/v1" MAX_DATA_BYTES = 25 * 1024 * 1024 FILE_PATTERN = re.compile(r"(?:capture\.json|arr-data\.json|replay-provenance\.json|" r"data-request-[0-9]{6}\.(?:json|meta\.json|response\.bin))") REQUEST_PATTERN = re.compile(r"data-request-([0-9]{6})\.json") RESPONSE_PATTERN = re.compile(r"data-request-[0-9]{6}\.response\.bin") require = base.require def _hash(raw): return hashlib.sha256(raw).hexdigest() def saved_status_evidence(capture_root, expected_manifest_sha256, original_payload: bytes) -> dict: """Verify saved HTTP evidence and bind one unchanged status to every source row.""" require(type(expected_manifest_sha256) is str and bool(re.fullmatch(r"[0-9a-f]{64}", expected_manifest_sha256)), "status_evidence_manifest_pin_invalid") require(type(original_payload) is bytes and len(original_payload) <= MAX_DATA_BYTES, "status_evidence_original_invalid") root = Path(capture_root) info = root.lstat() require(stat.S_ISDIR(info.st_mode) and info.st_uid == os.getuid() and stat.S_IMODE(info.st_mode) == 0o700, "status_evidence_directory_unsafe") manifest_raw = protected_read(root / "result.json", base.MAX_MANIFEST_BYTES) require(_hash(manifest_raw) == expected_manifest_sha256, "status_evidence_manifest_changed") manifest = base.strict_json(manifest_raw) require(manifest.get("version") == SOURCE_VERSION and manifest.get("collection_complete") is True and manifest.get("status") in {"collected", "collected_with_gaps"} and manifest.get("error") is None and manifest.get("finance_ready") is False, "status_evidence_capture_incomplete") entries = manifest.get("files") require(type(entries) is list and 2 <= len(entries) <= 300002, "status_evidence_inventory_invalid") inventory, raw_files, total = {}, {}, 0 for entry in entries: require(type(entry) is dict and set(entry) == {"name", "bytes", "sha256"}, "status_evidence_inventory_invalid") name, size, digest = entry["name"], entry["bytes"], entry["sha256"] require(type(name) is str and bool(FILE_PATTERN.fullmatch(name)) and name not in inventory, "status_evidence_inventory_name_invalid") require(type(size) is int and 0 <= size <= MAX_DATA_BYTES and type(digest) is str and bool(re.fullmatch(r"[0-9a-f]{64}", digest)), "status_evidence_inventory_invalid") total += size require(total <= base.MAX_ARCHIVE_BYTES, "status_evidence_archive_too_large") raw = protected_read(root / name, size) require(len(raw) == size and _hash(raw) == digest, "status_evidence_archive_changed") inventory[name], raw_files[name] = entry, raw require({p.name for p in root.iterdir()} == set(inventory) | {"result.json"}, "status_evidence_inventory_changed") require(raw_files.get("arr-data.json") == original_payload and manifest.get("data_sha256") == _hash(original_payload), "status_evidence_original_changed") original = base.strict_json(original_payload) require(original.get("version") == SOURCE_VERSION and original.get("collection_complete") is True and original.get("status") == manifest["status"] and original.get("source_kind") == manifest.get("source_kind"), "status_evidence_original_invalid") require("capture.json" in raw_files, "status_evidence_capture_missing") capture = base.strict_json(raw_files["capture.json"]) require(capture.get("version") == SOURCE_VERSION and capture.get("service_url") == base.SERVICE and capture.get("application_id") == base.APPLICATION and capture.get("source_kind") == original.get("source_kind") and capture.get("source_kind") in {"ohip_platform", "test_transport"}, "status_evidence_context_mismatch") require(type(capture.get("options")) is dict and set(capture["options"]) == {"arrival_date", "hotel_id", "page_size", "max_pages", "max_records"}, "status_evidence_context_mismatch") options = base.Options(**capture["options"]) options.validate() require(original.get("hotel_id") == options.hotel_id and original.get("report_date") == options.arrival_date, "status_evidence_context_mismatch") rows = original.get("records") require(type(rows) is list and 1 <= len(rows) <= options.max_records and type(manifest.get("records")) is int and manifest["records"] == len(rows), "status_evidence_records_invalid") identities = [] for sequence, row in enumerate(rows, 1): require(type(row) is dict and type(row.get("source_sequence")) is int and row["source_sequence"] == sequence and type(row.get("sources")) is list and bool(row["sources"]) and type(row.get("fields")) is dict, "status_evidence_records_invalid") identity = row.get("reservation_id") require(type(identity) is str and bool(re.fullmatch(r"[A-Za-z0-9_-]{1,128}", identity)) and identity not in identities, "status_evidence_records_invalid") identities.append(identity) refs = row["sources"] require(all(type(ref) is str and bool(RESPONSE_PATTERN.fullmatch(ref)) and ref in inventory for ref in refs) and len(set(refs)) == len(refs), "status_evidence_source_reference_invalid") requests = sorted(name for name in inventory if REQUEST_PATTERN.fullmatch(name)) require(type(manifest.get("http_attempts")) is int and len(requests) == manifest["http_attempts"] and requests == [f"data-request-{i:06d}.json" for i in range(1, len(requests) + 1)], "status_evidence_request_inventory_invalid") request_prefixes = {name[:-5] for name in requests} require(all(name.rsplit(".", 2)[0] in request_prefixes for name in inventory if name.endswith((".meta.json", ".response.bin"))), "status_evidence_request_inventory_invalid") documents, searches, details, operations = {}, [], {}, {} for request_name in requests: prefix = request_name[:-5] meta_name, response_name = prefix + ".meta.json", prefix + ".response.bin" require(meta_name in raw_files, "status_evidence_request_metadata_missing") request, meta = base.strict_json(raw_files[request_name]), base.strict_json(raw_files[meta_name]) operation = request.get("operation_id") operations[response_name] = operation status = meta.get("http_status") if type(status) is not int or not 200 <= status < 300: continue # Retried failures cannot be evidence, but their bytes are still pinned. require(response_name in raw_files and meta.get("error") is None and meta.get("oversized", False) is False, "status_evidence_response_invalid") if operation not in {base.SEARCH, base.DETAIL}: continue envelope = base.strict_json(raw_files[response_name]) require(envelope.get("operation_id") == operation and envelope.get("hotel_id") == options.hotel_id and type(envelope.get("oracle_request_id")) is str and bool(envelope["oracle_request_id"].strip()) and type(envelope.get("data")) is dict, "status_evidence_response_context_mismatch") if "upstream_status" in envelope: require(type(envelope["upstream_status"]) is int and 200 <= envelope["upstream_status"] < 300, "status_evidence_upstream_failure") base.check_warnings(envelope) item = {"request": request, "data": envelope["data"], "response": response_name, "number": int(REQUEST_PATTERN.fullmatch(request_name)[1])} documents[response_name] = item if operation == base.SEARCH: require(request.get("method") == "POST" and request.get("path") == "/api/v1/reservations/searches" and type(request.get("body")) is dict, "status_evidence_search_request_invalid") searches.append(item) else: require(type(request.get("path")) is str, "status_evidence_detail_request_mismatch") path = urlsplit(request["path"]) collection = envelope["data"].get("reservations") reservations = collection.get("reservation") if type(collection) is dict else None require(type(reservations) is list and len(reservations) == 1 and type(reservations[0]) is dict, "status_evidence_detail_ambiguous") detail = reservations[0] identity = base.validate_row(detail, options) require(typed_id(detail.get("reservationIdList"), "Reservation") == identity and request.get("method") == "GET" and request.get("body") is None and not path.scheme and not path.netloc and not path.fragment and path.path == f"/api/v1/reservations/{identity}" and parse_qs(path.query, keep_blank_values=True) == {"fetchInstructions": list(FETCH)}, "status_evidence_detail_request_mismatch") require(identity in identities and identity not in details, "status_evidence_detail_ambiguous") item["row"] = detail details[identity] = item # The complete collector performs two identical paginated searches. Recheck # them offline rather than trusting a new interpretation of row membership. rounds = [] for item in searches: if item["request"]["body"].get("offset") == 0: rounds.append([]) require(bool(rounds), "status_evidence_search_round_invalid") rounds[-1].append(item) require(len(rounds) == 2 and set(details) == set(identities), "status_evidence_search_round_invalid") def replay_search(items): class SavedReader: index = 0 def read(self, operation, *, body): require(self.index < len(items), "status_evidence_search_round_invalid") item = items[self.index] self.index += 1 require(operation == base.SEARCH and item["request"]["body"] == body, "status_evidence_search_request_invalid") return item["data"] reader = SavedReader() found = base.search_day(reader, options, server_sort=False) require(reader.index == len(items), "status_evidence_search_round_invalid") return found initial, final = (replay_search(items) for items in rounds) require(initial == final and [base.reservation_id(row) for row in initial] == identities, "status_evidence_search_changed") result = [] for source_row, search in zip(rows, initial): identity = source_row["reservation_id"] detail_item = details[identity] detail = detail_item["row"] require(rounds[0][-1]["number"] < detail_item["number"] < rounds[1][0]["number"], "status_evidence_request_order_invalid") status = search.get("reservationStatus") require(type(status) is str and bool(status.strip()) and status == detail.get("reservationStatus"), "status_evidence_status_conflict") modified = search.get("lastModifyDateTime") require(type(modified) is str and bool(modified.strip()) and modified == detail.get("lastModifyDateTime"), "status_evidence_state_conflict") if "reservation_status" in source_row: require(source_row["reservation_status"] == status, "status_evidence_existing_status_conflict") require(all(operations.get(ref) not in {base.SEARCH, base.DETAIL} or ref in documents for ref in source_row["sources"]), "status_evidence_source_binding_failed") initial_refs = [item["response"] for item in rounds[0] if any(base.reservation_id(row) == identity for row in item["data"]["reservations"]["reservationInfo"])] require(len(initial_refs) == 1 and initial_refs[0] in source_row["sources"] and detail_item["response"] in source_row["sources"], "status_evidence_source_binding_missing") claimed_details = [ref for ref in source_row["sources"] if ref in documents and documents[ref]["request"]["operation_id"] == base.DETAIL] require(claimed_details == [detail_item["response"]], "status_evidence_source_binding_ambiguous") final_refs = [item["response"] for item in rounds[1] if any(base.reservation_id(row) == identity for row in item["data"]["reservations"]["reservationInfo"])] proof_names = [] for ref in initial_refs + [detail_item["response"]] + final_refs: prefix = ref.removesuffix(".response.bin") proof_names.extend((prefix + ".json", prefix + ".meta.json", ref)) result.append({"source_sequence": source_row["source_sequence"], "reservation_id": identity, "reservation_status": status, "sources": [{"file": name, "sha256": inventory[name]["sha256"]} for name in proof_names]}) return {"version": VERSION, "policy_id": POLICY_ID, "original_sha256": _hash(original_payload), "source_manifest_sha256": expected_manifest_sha256, "report_date": options.arrival_date, "hotel_id": options.hotel_id, "records": result}