Files
ARR-2.0-0918/integrations/ohip/reservation_status.py
T

228 lines
14 KiB
Python

"""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}