From 1c2ce7d8d8fd68e01940978786f267e217e888db Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:02:22 +0000 Subject: [PATCH] feat(recovery): bind physical/WAL/PITR profiles with fail-closed UTF-8 Record a caller-owned physical or PITR profile without executing pg_basebackup. Parse wraps lone-surrogate UTF-8 failures as PostgresPhysicalRecoveryError. wal_archive_required=False means no continuous archive, not missing backup-internal WAL. pitr+immediate is recovery_target=immediate, not replay-to-end-of-archive. Refs #204. Successor of #217. Co-authored-by: Seongho Bae --- CHANGELOG.md | 9 + .../0019-postgres-physical-pitr-profile.md | 78 +++++ .../postgres-physical-pitr-profile.md | 89 +++++ pg_llm_batch/postgres_physical_recovery.py | 235 +++++++++++++ tests/test_postgres_physical_recovery.py | 314 ++++++++++++++++++ ...ostgres_physical_recovery_documentation.py | 30 ++ 6 files changed, 755 insertions(+) create mode 100644 docs/adr/0019-postgres-physical-pitr-profile.md create mode 100644 docs/doctoring/postgres-physical-pitr-profile.md create mode 100644 pg_llm_batch/postgres_physical_recovery.py create mode 100644 tests/test_postgres_physical_recovery.py create mode 100644 tests/test_postgres_physical_recovery_documentation.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 97ecdbf16..f6ae80a19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Caller-owned physical/WAL/PITR recovery profile binder + (`bind_postgres_physical_recovery_profile()` / + `parse_postgres_physical_recovery_profile()`). The seam records method, + recovery-target kind, continuous-WAL necessity, isolated-target readiness, + and optional RPO/RTO objectives. It does not execute `pg_basebackup` or + restore. `wal_archive_required=False` means no continuous archive, not the + absence of backup-internal WAL. `pitr` plus `immediate` is a consistent-state + stop, not replay-to-end-of-archive. Lone-surrogate profile text fails as + `PostgresPhysicalRecoveryError`. - Read-only exact-head release acceptance that builds wheel and source distribution artifacts twice from clean Git archives, proves byte-identical SHA-256 identity, records bounded canonical evidence, and keeps publication and attestation authority separate. - Optional bounded provider output/error-file lifetime controls for batch creation, with exact local validation before credential resolution and backward-compatible omission for provider-neutral callers. - Trusted tenant-scoped durable lifecycle identities for shared-table MSA deployments, including `TenantDurableBatchAPIClient`, tenant-qualified persistence and read helpers, transaction-local PostgreSQL context, forced default-deny row-level security, and explicit standalone compatibility. diff --git a/docs/adr/0019-postgres-physical-pitr-profile.md b/docs/adr/0019-postgres-physical-pitr-profile.md new file mode 100644 index 000000000..8c20bcb6b --- /dev/null +++ b/docs/adr/0019-postgres-physical-pitr-profile.md @@ -0,0 +1,78 @@ +# ADR 0019: Bind a caller-owned physical/WAL/PITR recovery profile + +- **Status:** Accepted for the bounded physical/PITR profile seam +- **Date:** 2026-08-16 + +## Context + +Protected main already records content-free logical-recovery receipts and +artifact/schema integrity evidence. Issue #204 still requires a separately +reviewed physical/WAL/PITR profile. A buyer who can dump and restore SQL still +cannot state, in machine-readable form, whether recovery is crash-consistent +only or can follow time. + +PostgreSQL continuous archiving uses a base backup plus a WAL archive and a +recovery target (`immediate`, time, transaction identifier, named restore +point, or LSN). Those choices are deployer-owned. The package must not execute +`pg_basebackup`, invent an RPO/RTO, or treat a physical base backup as a +point-in-time restore. + +## Decision + +`bind_postgres_physical_recovery_profile()` records one caller-owned profile: + +- `backup_method` is exactly `physical` or `pitr`. +- `recovery_target_kind` is exactly `immediate`, `time`, `xid`, `name`, or `lsn`. +- Point-in-time kinds require `backup_method="pitr"`. +- `pitr` requires `wal_archive_required=True`. +- `wal_archive_required=False` on a `physical` profile means no continuous WAL + archive. Crash-consistent restore still requires backup-internal WAL from + `pg_basebackup -X stream` or `-X fetch`. +- `pitr` plus `immediate` is `recovery_target=immediate`, not replay to the end + of the archive. +- `isolated_target_prepared` must be the exact boolean `True`. +- Optional `rpo_seconds` and `rto_seconds` are deployer-selected objectives. +- Emitted evidence always sets `package_capability_claim` to `False`. +- Profile parse wraps UTF-8 encoding failures, including lone surrogates, as + `PostgresPhysicalRecoveryError` so hosts catching only that type see a + content-free diagnostic. + +The seam does not receive archive paths, DSNs, WAL locations, or restore +commands, and it does not start PostgreSQL tools. + +```mermaid +flowchart TD + A[Caller asserts isolated target] --> B{Method} + B -->|physical| C[immediate target only] + B -->|pitr| D[WAL archive required] + D --> E[time / xid / name / lsn / immediate] + C --> F[Profile JSON with capability_claim false] + E --> F + F --> G[Operator runs pg_basebackup / restore_command outside this package] +``` + +## Consequences + +Hosts can persist a reviewed physical/PITR contract next to #205 receipts +without claiming that this package completed backup, restore, CSAP, or SOC 2. +Logical restore (#208/#212) remains a separate executor. Live WAL replay and +base-backup execution remain later #204 slices. + +## Rollback + +Delete the profile module, tests, and this decision record. No schema +migration is required. + +## References + +Swanson, M., Bowen, P., Phillips, A., Gallup, D., & Lynes, D. (2010). +*Contingency planning guide for federal information systems* (NIST Special +Publication 800-34 Rev. 1). National Institute of Standards and Technology. +https://doi.org/10.6028/NIST.SP.800-34r1 + +The PostgreSQL Global Development Group. (2026). *Continuous archiving and +point-in-time recovery (PITR)*. PostgreSQL 18 documentation. +https://www.postgresql.org/docs/18/continuous-archiving.html + +The PostgreSQL Global Development Group. (2026). *pg_basebackup*. PostgreSQL 18 +documentation. https://www.postgresql.org/docs/18/app-pgbasebackup.html diff --git a/docs/doctoring/postgres-physical-pitr-profile.md b/docs/doctoring/postgres-physical-pitr-profile.md new file mode 100644 index 000000000..96b71dd78 --- /dev/null +++ b/docs/doctoring/postgres-physical-pitr-profile.md @@ -0,0 +1,89 @@ +# PostgreSQL physical and PITR recovery profile + +This record is the operator contract for +`bind_postgres_physical_recovery_profile()`. Use it before you claim that a +deployment can recover through WAL replay. The function binds evidence. It does +not run `pg_basebackup`, `restore_command`, or `pg_restore`. + +## What to do next + +1. Create an isolated recovery target first. Pass + `isolated_target_prepared=True` only after that target exists. The boolean + is your assertion, not package proof of isolation. +2. Choose `backup_method="physical"` only for a crash-consistent base backup + whose recovery target kind is `immediate`. `wal_archive_required=False` + means you are not relying on a continuous WAL archive. It does **not** mean + the base backup may omit backup-internal WAL. Crash-consistent restore still + needs the WAL that `pg_basebackup -X stream` or `-X fetch` wrote into the + backup. +3. Choose `backup_method="pitr"` only when a continuous WAL archive is already + being written and you can restore to a time, transaction identifier, named + restore point, LSN, or `immediate` consistent state. `pitr` plus + `immediate` stops at the first consistent state (`recovery_target = + immediate`). It is not replay-to-end-of-archive. Default replay to the end + of the archive is the absence of a recovery-target kind, which this binder + does not encode. +4. Record deployer-selected `rpo_seconds` and `rto_seconds` when your + organization has those objectives. Read `package_capability_claim=false` as + a hard rule: this package did not meet those numbers for you. +5. Keep Fernet keys, TLS private material, host configuration, and provider + files outside database and WAL custody unless a separately reviewed adapter + owns them. +6. After you bind the profile, run the actual base backup and WAL restore + outside this seam, then prove schema, tenant/RLS, checkpoint, and lifecycle + usability on the isolated target. + +## Time-flow boundary + +PostgreSQL point-in-time recovery is a time-flow control. Kinds `time`, +`xid`, `name`, and `lsn` are accepted only on a `pitr` profile with +`wal_archive_required=True`. A physical profile cannot borrow those kinds even +when WAL is being archived. That prevents labeling a crash-consistent restore +as a point-in-time restore. + +## What this slice does not prove + +This binder does not execute physical backup, WAL archive, or replay. It does +not prove schema/RLS/constraint/extension parity, target isolation, universal +RPO/RTO, high availability, disaster recovery, CSAP, or SOC 2 readiness. Those +remain issue #204 and deployment-specific evidence. Canonical CHANGELOG and +README ownership for the broader recovery program stays with the documentation +and restore-executor writers. + +## Verification + +Confirm on the exact current head that: + +- a PostgreSQL 18 `pitr` + `time` profile round-trips without a capability claim; +- `pitr` + `immediate` is accepted as a consistent-state stop, not end-of-WAL; +- lone-surrogate profile text raises `PostgresPhysicalRecoveryError`, not + `UnicodeEncodeError`; +- `pitr` without a WAL archive fails closed; +- point-in-time kinds on a `physical` method fail closed; +- `isolated_target_prepared=False` fails closed; +- exact-type and hostile-subclass metadata fail closed; +- parse rejects duplicate keys, unknown keys, and a true capability claim; +- production statement and branch coverage and public docstrings remain 100%. + +## References + +International Organization for Standardization. (2022). *Information security, +cybersecurity and privacy protection — Information security management systems +— Requirements* (ISO/IEC 27001:2022). Control A.8.13 requires information +backup; this package records a bounded physical/PITR profile, not a certified +ISMS. + +Swanson, M., Bowen, P., Phillips, A., Gallup, D., & Lynes, D. (2010). +*Contingency planning guide for federal information systems* (NIST Special +Publication 800-34 Rev. 1). National Institute of Standards and Technology. +https://doi.org/10.6028/NIST.SP.800-34r1 + +The PostgreSQL Global Development Group. (2026). *Backup and restore*. +PostgreSQL 18 documentation. https://www.postgresql.org/docs/18/backup.html + +The PostgreSQL Global Development Group. (2026). *Continuous archiving and +point-in-time recovery (PITR)*. PostgreSQL 18 documentation. +https://www.postgresql.org/docs/18/continuous-archiving.html + +The PostgreSQL Global Development Group. (2026). *pg_basebackup*. PostgreSQL 18 +documentation. https://www.postgresql.org/docs/18/app-pgbasebackup.html diff --git a/pg_llm_batch/postgres_physical_recovery.py b/pg_llm_batch/postgres_physical_recovery.py new file mode 100644 index 000000000..fc8c7c8e9 --- /dev/null +++ b/pg_llm_batch/postgres_physical_recovery.py @@ -0,0 +1,235 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Bind a caller-owned physical/WAL/PITR recovery profile without executing backup.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass + + +_BACKUP_METHODS = frozenset({"physical", "pitr"}) +_POINT_IN_TIME_KINDS = frozenset({"time", "xid", "name", "lsn"}) +_RECOVERY_TARGET_KINDS = frozenset({"immediate"}) | _POINT_IN_TIME_KINDS +_PROFILE_KEYS = frozenset( + { + "schema_version", + "postgres_major", + "backup_method", + "recovery_target_kind", + "wal_archive_required", + "isolated_target_prepared", + "rpo_seconds", + "rto_seconds", + "package_capability_claim", + } +) +_MAX_SIGNED_BIGINT = (1 << 63) - 1 +_MAX_PROFILE_JSON_BYTES = 2048 + + +class PostgresPhysicalRecoveryError(ValueError): + """Report a fail-closed physical/WAL/PITR recovery-profile violation.""" + + +def _exact_major(value: object) -> bool: + """Return whether a value is an exact PostgreSQL major-version integer.""" + return type(value) is int and 1 <= value <= 99 + + +def _exact_method(value: object) -> bool: + """Return whether a value is one reviewed physical or PITR method string.""" + return type(value) is str and value in _BACKUP_METHODS + + +def _exact_target_kind(value: object) -> bool: + """Return whether a value is one reviewed recovery-target kind string.""" + return type(value) is str and value in _RECOVERY_TARGET_KINDS + + +def _exact_bool(value: object) -> bool: + """Return whether a value is an exact built-in boolean.""" + return type(value) is bool + + +def _optional_positive_seconds(value: object) -> bool: + """Return whether a value is omitted or an exact positive second budget.""" + return value is None or ( + type(value) is int and 1 <= value <= _MAX_SIGNED_BIGINT + ) + + +def _reject_duplicate_object_pairs( + pairs: list[tuple[str, object]], +) -> dict[str, object]: + """Build one JSON object while rejecting ambiguous duplicate member names.""" + decoded: dict[str, object] = {} + for key, value in pairs: + if key in decoded: + raise PostgresPhysicalRecoveryError( + "invalid PostgreSQL physical recovery profile schema" + ) + decoded[key] = value + return decoded + + +@dataclass(frozen=True, slots=True) +class PostgresPhysicalRecoveryProfile: + """Represent one caller-owned physical or PITR recovery profile. + + The profile records deployer-selected method, time-flow target kind, WAL + archive necessity, isolated-target readiness, and optional RPO/RTO + objectives. It does not execute ``pg_basebackup``, archive WAL, or restore + a cluster, and it never claims those objectives as package capability. + """ + + postgres_major: int + backup_method: str + recovery_target_kind: str + wal_archive_required: bool + isolated_target_prepared: bool + rpo_seconds: int | None + rto_seconds: int | None + + def __post_init__(self) -> None: + """Fail closed when the profile violates the bounded recovery contract.""" + if not ( + _exact_major(self.postgres_major) + and _exact_method(self.backup_method) + and _exact_target_kind(self.recovery_target_kind) + and _exact_bool(self.wal_archive_required) + and _exact_bool(self.isolated_target_prepared) + and _optional_positive_seconds(self.rpo_seconds) + and _optional_positive_seconds(self.rto_seconds) + ): + raise PostgresPhysicalRecoveryError( + "invalid PostgreSQL physical recovery profile" + ) + if self.isolated_target_prepared is False: + raise PostgresPhysicalRecoveryError( + "PostgreSQL physical recovery requires an isolated target" + ) + if self.backup_method == "pitr" and self.wal_archive_required is False: + raise PostgresPhysicalRecoveryError( + "PostgreSQL PITR profile requires a WAL archive" + ) + if ( + self.recovery_target_kind in _POINT_IN_TIME_KINDS + and self.backup_method != "pitr" + ): + raise PostgresPhysicalRecoveryError( + "PostgreSQL point-in-time target requires a PITR profile" + ) + + def as_dict(self) -> dict[str, object]: + """Return the stable machine-readable physical recovery profile schema.""" + return { + "schema_version": 1, + "postgres_major": self.postgres_major, + "backup_method": self.backup_method, + "recovery_target_kind": self.recovery_target_kind, + "wal_archive_required": self.wal_archive_required, + "isolated_target_prepared": self.isolated_target_prepared, + "rpo_seconds": self.rpo_seconds, + "rto_seconds": self.rto_seconds, + "package_capability_claim": False, + } + + def to_json(self) -> str: + """Return deterministic compact JSON without deployment or business content.""" + return json.dumps( + self.as_dict(), + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + ) + + +def bind_postgres_physical_recovery_profile( + *, + postgres_major: int, + backup_method: str, + recovery_target_kind: str, + wal_archive_required: bool, + isolated_target_prepared: bool, + rpo_seconds: int | None = None, + rto_seconds: int | None = None, +) -> PostgresPhysicalRecoveryProfile: + """Bind one caller-owned physical or PITR profile without executing recovery. + + Callers must already have an isolated recovery target. The service name, + archive path, WAL location, and restore command stay outside this seam. + ``backup_method="pitr"`` requires ``wal_archive_required=True``. Point-in-time + kinds ``time``, ``xid``, ``name``, and ``lsn`` require ``backup_method="pitr"`` + so a crash-consistent physical base backup cannot be labeled as a time-flow + restore. ``wal_archive_required=False`` means no continuous WAL archive; a + crash-consistent restore still needs backup-internal WAL from + ``pg_basebackup -X stream`` or ``-X fetch``. ``pitr`` plus ``immediate`` is + ``recovery_target=immediate``, not replay to the end of the archive. + Optional ``rpo_seconds`` and ``rto_seconds`` are deployer-selected + objectives only; the emitted profile always sets + ``package_capability_claim`` to ``False``. + """ + return PostgresPhysicalRecoveryProfile( + postgres_major=postgres_major, + backup_method=backup_method, + recovery_target_kind=recovery_target_kind, + wal_archive_required=wal_archive_required, + isolated_target_prepared=isolated_target_prepared, + rpo_seconds=rpo_seconds, + rto_seconds=rto_seconds, + ) + + +def parse_postgres_physical_recovery_profile( + raw_profile: str, +) -> PostgresPhysicalRecoveryProfile: + """Parse one bounded profile and reject extensions or capability claims. + + Lone-surrogate or other UTF-8 encoding failures raise + ``PostgresPhysicalRecoveryError`` with a content-free JSON diagnostic so + hosts catching only that type do not observe a leaked ``UnicodeEncodeError``. + """ + if type(raw_profile) is not str: + raise PostgresPhysicalRecoveryError( + "invalid PostgreSQL physical recovery profile JSON" + ) + try: + encoded_size = len(raw_profile.encode("utf-8")) + except UnicodeError: + raise PostgresPhysicalRecoveryError( + "invalid PostgreSQL physical recovery profile JSON" + ) from None + if encoded_size == 0 or encoded_size > _MAX_PROFILE_JSON_BYTES: + raise PostgresPhysicalRecoveryError( + "invalid PostgreSQL physical recovery profile JSON" + ) + try: + decoded = json.loads( + raw_profile, + object_pairs_hook=_reject_duplicate_object_pairs, + ) + except (json.JSONDecodeError, RecursionError): + raise PostgresPhysicalRecoveryError( + "invalid PostgreSQL physical recovery profile JSON" + ) from None + if type(decoded) is not dict or frozenset(decoded) != _PROFILE_KEYS: + raise PostgresPhysicalRecoveryError( + "invalid PostgreSQL physical recovery profile schema" + ) + if decoded.get("schema_version") != 1 or type(decoded.get("schema_version")) is not int: + raise PostgresPhysicalRecoveryError( + "invalid PostgreSQL physical recovery profile schema" + ) + if decoded.get("package_capability_claim") is not False: + raise PostgresPhysicalRecoveryError( + "PostgreSQL physical recovery profile cannot claim package capability" + ) + return PostgresPhysicalRecoveryProfile( + postgres_major=decoded["postgres_major"], + backup_method=decoded["backup_method"], + recovery_target_kind=decoded["recovery_target_kind"], + wal_archive_required=decoded["wal_archive_required"], + isolated_target_prepared=decoded["isolated_target_prepared"], + rpo_seconds=decoded["rpo_seconds"], + rto_seconds=decoded["rto_seconds"], + ) diff --git a/tests/test_postgres_physical_recovery.py b/tests/test_postgres_physical_recovery.py new file mode 100644 index 000000000..0e95bfe83 --- /dev/null +++ b/tests/test_postgres_physical_recovery.py @@ -0,0 +1,314 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Regression contracts for bounded physical/WAL/PITR recovery profiles.""" + +from __future__ import annotations + +import json + +import pytest + +from pg_llm_batch.postgres_physical_recovery import ( + PostgresPhysicalRecoveryError, + PostgresPhysicalRecoveryProfile, + bind_postgres_physical_recovery_profile, + parse_postgres_physical_recovery_profile, +) + + +def _profile(**overrides: object) -> PostgresPhysicalRecoveryProfile: + arguments: dict[str, object] = { + "postgres_major": 18, + "backup_method": "pitr", + "recovery_target_kind": "time", + "wal_archive_required": True, + "isolated_target_prepared": True, + "rpo_seconds": 300, + "rto_seconds": 3600, + } + arguments.update(overrides) + return bind_postgres_physical_recovery_profile(**arguments) # type: ignore[arg-type] + + +def test_pitr_time_profile_is_deterministic_and_not_a_capability_claim() -> None: + """A realistic PostgreSQL 18 time-flow PITR profile stays content-free.""" + profile = _profile() + + assert profile.as_dict() == { + "schema_version": 1, + "postgres_major": 18, + "backup_method": "pitr", + "recovery_target_kind": "time", + "wal_archive_required": True, + "isolated_target_prepared": True, + "rpo_seconds": 300, + "rto_seconds": 3600, + "package_capability_claim": False, + } + assert profile.to_json() == ( + '{"backup_method":"pitr","isolated_target_prepared":true,' + '"package_capability_claim":false,"postgres_major":18,' + '"recovery_target_kind":"time","rpo_seconds":300,"rto_seconds":3600,' + '"schema_version":1,"wal_archive_required":true}' + ) + + +def test_physical_immediate_profile_allows_crash_consistent_base_backup() -> None: + """physical+immediate may omit a continuous WAL archive, not backup-internal WAL.""" + profile = _profile( + backup_method="physical", + recovery_target_kind="immediate", + wal_archive_required=False, + rpo_seconds=None, + rto_seconds=None, + ) + + assert profile.backup_method == "physical" + assert profile.wal_archive_required is False + assert profile.as_dict()["rpo_seconds"] is None + assert profile.as_dict()["package_capability_claim"] is False + + +def test_physical_immediate_profile_may_archive_wal_without_claiming_pitr() -> None: + """Archiving WAL does not let a physical profile claim a time-flow target.""" + profile = _profile( + backup_method="physical", + recovery_target_kind="immediate", + wal_archive_required=True, + ) + + assert profile.wal_archive_required is True + assert profile.recovery_target_kind == "immediate" + + +@pytest.mark.parametrize("kind", ["time", "xid", "name", "lsn"]) +def test_point_in_time_kinds_require_pitr_method(kind: str) -> None: + """Time-flow restore kinds cannot be attached to a physical-only profile.""" + with pytest.raises( + PostgresPhysicalRecoveryError, + match="^PostgreSQL point-in-time target requires a PITR profile$", + ): + _profile( + backup_method="physical", + recovery_target_kind=kind, + wal_archive_required=True, + ) + + +def test_pitr_immediate_is_consistent_state_not_end_of_archive() -> None: + """pitr+immediate is a recovery_target=immediate stop, not replay-to-end-of-WAL.""" + profile = _profile( + backup_method="pitr", + recovery_target_kind="immediate", + wal_archive_required=True, + rpo_seconds=None, + rto_seconds=None, + ) + + assert profile.backup_method == "pitr" + assert profile.recovery_target_kind == "immediate" + assert profile.wal_archive_required is True + assert profile.as_dict()["package_capability_claim"] is False + + +def test_pitr_method_requires_wal_archive() -> None: + """PITR without a WAL archive is not a usable time-flow recovery contract.""" + with pytest.raises( + PostgresPhysicalRecoveryError, + match="^PostgreSQL PITR profile requires a WAL archive$", + ): + _profile(backup_method="pitr", wal_archive_required=False) + + +def test_profile_requires_isolated_target() -> None: + """Physical or PITR restore must start in an isolated recovery target.""" + with pytest.raises( + PostgresPhysicalRecoveryError, + match="^PostgreSQL physical recovery requires an isolated target$", + ): + _profile(isolated_target_prepared=False) + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("postgres_major", True), + ("postgres_major", 0), + ("postgres_major", 100), + ("backup_method", "logical"), + ("backup_method", "snapshot"), + ("recovery_target_kind", "latest"), + ("recovery_target_kind", "immediate "), + ("wal_archive_required", 1), + ("isolated_target_prepared", 1), + ("rpo_seconds", 0), + ("rpo_seconds", True), + ("rpo_seconds", -1), + ("rpo_seconds", 1 << 63), + ("rto_seconds", 0), + ("rto_seconds", True), + ("rto_seconds", 1 << 63), + ], +) +def test_profile_rejects_invalid_metadata(field: str, value: object) -> None: + """Exact types and reviewed enumerations fail closed before any capability claim.""" + with pytest.raises( + PostgresPhysicalRecoveryError, + match="^invalid PostgreSQL physical recovery profile$", + ): + _profile(**{field: value}) + + +def test_profile_rejects_hostile_string_subclass() -> None: + """Subclassed method strings cannot sneak past the exact-type contract.""" + + class HostileString(str): + def __str__(self) -> str: + raise AssertionError("must not render hostile metadata") + + with pytest.raises( + PostgresPhysicalRecoveryError, + match="^invalid PostgreSQL physical recovery profile$", + ): + _profile(backup_method=HostileString("pitr")) + + +def test_parse_round_trips_exact_pitr_profile() -> None: + """Hosts can persist and reload the exact machine-readable profile.""" + profile = _profile(recovery_target_kind="lsn", rpo_seconds=None) + + assert parse_postgres_physical_recovery_profile(profile.to_json()) == profile + + +def test_parse_rejects_non_string() -> None: + """Binary or object input is not a profile document.""" + with pytest.raises( + PostgresPhysicalRecoveryError, + match="^invalid PostgreSQL physical recovery profile JSON$", + ): + parse_postgres_physical_recovery_profile(b"{}") # type: ignore[arg-type] + + +def test_parse_rejects_surrogate_text() -> None: + """Lone surrogates must stay inside the package JSON error, not UnicodeEncodeError.""" + with pytest.raises( + PostgresPhysicalRecoveryError, + match="^invalid PostgreSQL physical recovery profile JSON$", + ): + parse_postgres_physical_recovery_profile("\ud800") + + +def test_parse_rejects_empty_or_oversized_document() -> None: + """The profile document stays inside a fixed content-free byte budget.""" + with pytest.raises( + PostgresPhysicalRecoveryError, + match="^invalid PostgreSQL physical recovery profile JSON$", + ): + parse_postgres_physical_recovery_profile("") + with pytest.raises( + PostgresPhysicalRecoveryError, + match="^invalid PostgreSQL physical recovery profile JSON$", + ): + parse_postgres_physical_recovery_profile("{" + ("a" * 2048) + "}") + + +def test_parse_rejects_malformed_json() -> None: + """Broken JSON cannot become a recovery profile.""" + with pytest.raises( + PostgresPhysicalRecoveryError, + match="^invalid PostgreSQL physical recovery profile JSON$", + ): + parse_postgres_physical_recovery_profile("{") + + +def test_parse_rejects_recursive_json(monkeypatch: pytest.MonkeyPatch) -> None: + """Parser recursion failures stay inside the content-free JSON error.""" + + def exploding_load(*_args: object, **_kwargs: object) -> object: + raise RecursionError("secret recursion") + + monkeypatch.setattr(json, "loads", exploding_load) + with pytest.raises( + PostgresPhysicalRecoveryError, + match="^invalid PostgreSQL physical recovery profile JSON$", + ) as caught: + parse_postgres_physical_recovery_profile('{"schema_version":1}') + assert "secret" not in str(caught.value) + + +def test_parse_rejects_duplicate_keys() -> None: + """Duplicate JSON members are ambiguous evidence and must fail closed.""" + profile = _profile() + raw = profile.to_json().replace( + '"backup_method":"pitr"', + '"backup_method":"pitr","backup_method":"physical"', + 1, + ) + with pytest.raises( + PostgresPhysicalRecoveryError, + match="^invalid PostgreSQL physical recovery profile schema$", + ): + parse_postgres_physical_recovery_profile(raw) + + +def test_parse_rejects_unknown_or_missing_keys() -> None: + """The profile schema is closed; extra or missing members are invalid.""" + payload = _profile().as_dict() + payload["extra_field"] = True + with pytest.raises( + PostgresPhysicalRecoveryError, + match="^invalid PostgreSQL physical recovery profile schema$", + ): + parse_postgres_physical_recovery_profile(json.dumps(payload)) + payload = _profile().as_dict() + del payload["backup_method"] + with pytest.raises( + PostgresPhysicalRecoveryError, + match="^invalid PostgreSQL physical recovery profile schema$", + ): + parse_postgres_physical_recovery_profile(json.dumps(payload)) + with pytest.raises( + PostgresPhysicalRecoveryError, + match="^invalid PostgreSQL physical recovery profile schema$", + ): + parse_postgres_physical_recovery_profile("[]") + + +@pytest.mark.parametrize("schema_version", [2, True, "1"]) +def test_parse_rejects_non_v1_schema(schema_version: object) -> None: + """Only exact integer schema version 1 is accepted.""" + payload = _profile().as_dict() + payload["schema_version"] = schema_version + with pytest.raises( + PostgresPhysicalRecoveryError, + match="^invalid PostgreSQL physical recovery profile schema$", + ): + parse_postgres_physical_recovery_profile(json.dumps(payload)) + + +@pytest.mark.parametrize("claim", [True, None, 0]) +def test_parse_rejects_capability_claim(claim: object) -> None: + """A persisted profile cannot assert that the package met RPO or RTO.""" + payload = _profile().as_dict() + payload["package_capability_claim"] = claim + with pytest.raises( + PostgresPhysicalRecoveryError, + match="^PostgreSQL physical recovery profile cannot claim package capability$", + ): + parse_postgres_physical_recovery_profile(json.dumps(payload)) + + +def test_bind_does_not_execute_backup_or_restore(monkeypatch: pytest.MonkeyPatch) -> None: + """The binder is evidence-only; it must not start PostgreSQL recovery tools.""" + + def forbidden(*_args: object, **_kwargs: object) -> object: + raise AssertionError("physical recovery profile must not execute tools") + + monkeypatch.setattr("os.system", forbidden) + profile = bind_postgres_physical_recovery_profile( + postgres_major=18, + backup_method="pitr", + recovery_target_kind="xid", + wal_archive_required=True, + isolated_target_prepared=True, + ) + assert profile.recovery_target_kind == "xid" diff --git a/tests/test_postgres_physical_recovery_documentation.py b/tests/test_postgres_physical_recovery_documentation.py new file mode 100644 index 000000000..1335f5da9 --- /dev/null +++ b/tests/test_postgres_physical_recovery_documentation.py @@ -0,0 +1,30 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Documentation contracts for bounded physical/WAL/PITR recovery profiles.""" + +from pathlib import Path + + +DOCTORING = Path("docs/doctoring/postgres-physical-pitr-profile.md") +ADR = Path("docs/adr/0019-postgres-physical-pitr-profile.md") + + +def _normalized(path: Path) -> str: + """Read one authoritative document with layout-only whitespace collapsed.""" + return " ".join(path.read_text(encoding="utf-8").split()).lower() + + +def test_physical_pitr_docs_record_operator_and_citation_contract() -> None: + """Operators must see isolation, WAL, time-flow, and non-capability steps.""" + documents = (_normalized(DOCTORING), _normalized(ADR)) + for document in documents: + assert "isolated_target_prepared" in document + assert "wal_archive_required" in document + assert "package_capability_claim" in document + assert "point-in-time" in document or "point in time" in document + assert "backup-internal" in document + assert "pg_basebackup" in document + assert "immediate" in document + assert "swanson" in document + assert "nist" in document + assert "postgresql" in document + assert "continuous archiving" in document