228 lines
14 KiB
Python
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}
|