Source code for aeat.adapters.persistence.storage.bucket._sealed_archive_reader
"""Sealed bucket-export archive reader.Validates the gzipped tar layout, strict-parses the header, andyields the encrypted payload bytes + optional recovery-wrap bytesfor the caller to decrypt. Fast-fails on layout drift (extra,missing, out-of-order, or unknown members) before any decryptionattempt so a tampered or wrong-version archive surfaces precisely.Authority: ``2026-06-03-bucket-sealed-archive-adr``."""from__future__importannotationsimportgzipimporttarfilefromdataclassesimportdataclassfrompathlibimportPathfromtypingimportClassVarfrom._export_headerimportExportArchiveHeaderfrom._sealed_archive_errorsimport(SealedArchiveHeaderError,SealedArchiveLayoutError,SealedArchivePayloadError,)from._sealed_archive_writerimport(HEADER_MEMBER_NAME,PAYLOAD_MEMBER_NAME,RECOVERY_WRAP_MEMBER_NAME,)
[docs]@dataclass(frozen=True)classSealedArchiveContents:"""Decoded sealed-archive contents ready for downstream decryption. The reader returns this aggregate so the caller composes its own decryption + validation pipeline without re-parsing the archive. ``payload_envelope_bytes`` is opaque to this layer — the caller deserialises it via the existing :class:`Envelope` pipeline. """__test__:ClassVar[bool]=Falseheader:ExportArchiveHeaderpayload_envelope_bytes:bytesrecovery_wrap_bytes:bytes|None
def_read_member(archive:tarfile.TarFile,expected_name:str)->bytes:"""Read one tar member by name; raise layout error if absent or empty."""try:member=archive.getmember(expected_name)exceptKeyErrorasexc:raiseSealedArchiveLayoutError(f"sealed-archive read refused: required member {expected_name!r} is missing",)fromexcextracted=archive.extractfile(member)ifextractedisNone:raiseSealedArchiveLayoutError(f"sealed-archive read refused: member {expected_name!r} is not a regular file",)withextracted:returnextracted.read()
[docs]defread_sealed_archive(source_path:Path)->SealedArchiveContents:"""Read and strict-validate a sealed bucket-export archive. Args: source_path: Operator-specified input path. Returns: A :class:`SealedArchiveContents` carrying the parsed header, the encrypted payload bytes, and the optional recovery-wrap bytes when ``header.recovery_wrap_present`` is ``True``. Raises: SealedArchiveLayoutError: When the tar layout deviates from the ADR contract (extra / missing / out-of-order / unknown members, non-regular members). SealedArchiveHeaderError: When ``header.json`` fails strict validation as :class:`ExportArchiveHeader`. SealedArchivePayloadError: When the payload member cannot be read, or when a torn write truncated the gzip stream so the decompression layer raises ``EOFError`` / ``gzip.BadGzipFile``. Decryption failures surface from this same class when the caller's :class:`Envelope` parse fails. Truncation-detection scope: a torn write that damages the gzip stream (the common case) is caught here at read time and surfaces as ``SealedArchivePayloadError``. A *near-complete* truncation that still decompresses to the expected two or three members passes this reader; it is caught downstream by the AEAD tag on the encrypted payload, which the importer verifies before it provisions any bucket store, so a torn archive never restores a partial bucket. Read-time detection of a near-complete truncation would require a trailing integrity marker in the archive format (writer + reader change); that hardening is a tracked follow-up recorded in the crash-window reference. """try:withtarfile.open(source_path,mode="r:gz")asarchive:member_names=tuple(member.nameformemberinarchive.getmembers())_validate_layout(member_names)header_bytes=_read_member(archive,HEADER_MEMBER_NAME)try:header=ExportArchiveHeader.model_validate_json(header_bytes)exceptExceptionasexc:# pydantic ValidationError or its subclassesraiseSealedArchiveHeaderError(f"sealed-archive read refused: header schema validation failed: {type(exc).__name__}: {exc}",)fromexcpayload_bytes=_read_member(archive,PAYLOAD_MEMBER_NAME)recovery_wrap_bytes:bytes|None=Noneifheader.recovery_wrap_present:recovery_wrap_bytes=_read_member(archive,RECOVERY_WRAP_MEMBER_NAME)excepttarfile.TarErrorasexc:raiseSealedArchiveLayoutError(f"sealed-archive read refused: tar layer rejected the archive: {type(exc).__name__}: {exc}",)fromexcexcept(gzip.BadGzipFile,EOFError)asexc:# A torn write truncates the gzip stream: the decompression layer# raises ``EOFError`` (stream ended before the end-of-stream marker)# or ``gzip.BadGzipFile`` (a damaged gzip header/CRC). Neither is an# ``OSError`` on every platform, so surface them explicitly as the# documented truncation error rather than leaking a raw builtin.raiseSealedArchivePayloadError(f"sealed-archive read of {source_path!s} refused: archive is truncated or its gzip "f"stream is incomplete: {type(exc).__name__}: {exc}",)fromexcexceptOSErrorasexc:raiseSealedArchivePayloadError(f"sealed-archive read of {source_path!s} failed at IO layer: {type(exc).__name__}: {exc}",)fromexcreturnSealedArchiveContents(header=header,payload_envelope_bytes=payload_bytes,recovery_wrap_bytes=recovery_wrap_bytes,)
def_validate_layout(member_names:tuple[str,...])->None:"""Ensure the archive carries exactly the expected member set in order."""iflen(member_names)notin(2,3):raiseSealedArchiveLayoutError(f"sealed-archive read refused: expected 2 or 3 members, got {len(member_names)}: {list(member_names)!r}",)ifmember_names[0]!=HEADER_MEMBER_NAME:raiseSealedArchiveLayoutError(f"sealed-archive read refused: first member must be {HEADER_MEMBER_NAME!r}, got {member_names[0]!r}",)ifmember_names[1]!=PAYLOAD_MEMBER_NAME:raiseSealedArchiveLayoutError(f"sealed-archive read refused: second member must be {PAYLOAD_MEMBER_NAME!r}, got {member_names[1]!r}",)iflen(member_names)==3andmember_names[2]!=RECOVERY_WRAP_MEMBER_NAME:raiseSealedArchiveLayoutError(f"sealed-archive read refused: third member must be {RECOVERY_WRAP_MEMBER_NAME!r}, got {member_names[2]!r}",)__all__=["SealedArchiveContents","read_sealed_archive"]