Source code for aeat.adapters.persistence.storage.bucket._manifest_io
"""Atomic read/write IO for the plaintext bucket manifest.The manifest sits at ``<bucket-dir>/manifest.toml``. Writes use thewrite-then-rename pattern (tmp sibling, ``os.replace``) so a crash mid-writecan never surface as a torn read; reads round-trip through the strictpydantic v2 :class:`BucketManifest` record so an unknown key or a tornpayload fails closed at the boundary."""from__future__importannotationsimportbase64importosfromdatetimeimportdatetimefrompathlibimportPathfrom.....coreimportparse_toml_textfrom.....core.external_constantsimportUTF_8_ENCODINGas_UTF_8_ENCODINGfrom.....core.locksimportfsync_parent_dirfrom.....core.loggingimportget_loggerfrom.._namespace_registryimportBUCKET_MANIFEST_FILENAMEfrom..errorsimportStorageValidationErrorfrom._errorsimportBucketValidationErrorfrom._layoutimportBucketPathsfrom._manifestimportBucketManifestMISSING_BUCKET_MANIFEST_MESSAGE="bucket manifest is missing"_BUCKET_VALIDATION_MESSAGE_KEY="errors.integrity.integrity_storage_bucket_validation"_log=get_logger(__name__)
[docs]defmanifest_path(paths:BucketPaths)->Path:"""Return the canonical manifest path for the bucket."""returnpaths.bucket_dir/BUCKET_MANIFEST_FILENAME
[docs]defmanifest_validation_error(message:str)->StorageValidationError:"""Build the typed, localized :class:`StorageValidationError` used by manifest I/O."""returnStorageValidationError(message,translated_message=_BUCKET_VALIDATION_MESSAGE_KEY)
def_format_scalar(value:object)->str:ifisinstance(value,bool):return"true"ifvalueelse"false"ifisinstance(value,int):returnstr(value)ifisinstance(value,datetime):# RFC 3339 offset datetime is the TOML-native representation.text=value.isoformat()# Python emits ``+00:00`` for UTC; TOML accepts both ``Z`` and the# offset form so we keep the offset form for byte-stable serialise/# parse round-trips.returntextifisinstance(value,str):escaped=value.replace("\\","\\\\").replace('"','\\"')returnf'"{escaped}"'raiseBucketValidationError(f"unsupported TOML scalar type: {type(value)!r}")def_serialise_manifest(manifest:BucketManifest)->str:"""Render the manifest as deterministic single-document TOML. Hand-formatted to keep the dependency footprint minimal (Python's stdlib provides ``tomllib`` for parsing but no writer); the schema is fixed at a small set of scalar keys plus the nested ``[kdf_params]`` table, so a hand-rolled emitter is bounded and reviewable. """kdf=manifest.kdf_paramslines:list[str]=[]lines.append(f"bucket_id = {_format_scalar(manifest.bucket_id)}")lines.append(f"label = {_format_scalar(manifest.label)}")lines.append(f"created_at = {_format_scalar(manifest.created_at)}")ifmanifest.last_unlocked_atisNone:lines.append("last_unlocked_at =")else:lines.append(f"last_unlocked_at = {_format_scalar(manifest.last_unlocked_at)}")lines.append(f"recovery_enrolled = {_format_scalar(manifest.recovery_enrolled)}")ifmanifest.idle_lock_minutesisnotNone:lines.append(f"idle_lock_minutes = {_format_scalar(manifest.idle_lock_minutes)}")lines.append(f"key_schedule = {_format_scalar(manifest.key_schedule.value)}")lines.append(f"schema_version = {_format_scalar(manifest.schema_version)}")lines.append(f"status = {_format_scalar(manifest.status.value)}")lines.append("")lines.append("[kdf_params]")lines.append(f"algorithm = {_format_scalar(kdf.algorithm)}")lines.append(f"version = {_format_scalar(kdf.version)}")lines.append(f"memory_cost = {_format_scalar(kdf.memory_cost)}")lines.append(f"time_cost = {_format_scalar(kdf.time_cost)}")lines.append(f"parallelism = {_format_scalar(kdf.parallelism)}")salt_b64=base64.b64encode(kdf.salt).decode("ascii")lines.append(f"salt = {_format_scalar(salt_b64)}")lines.append(f"output_length = {_format_scalar(kdf.output_length)}")# TOML accepts a missing value for an optional key only via omission, not# ``key =`` syntax; encode ``last_unlocked_at = None`` by simply omitting# the key. Replace the placeholder line we emitted above.rendered="\n".join(lineforlineinlinesifline!="last_unlocked_at =")+"\n"returnrendered
[docs]defwrite_manifest(paths:BucketPaths,manifest:BucketManifest)->None:"""Atomically write the manifest under ``<bucket-dir>/manifest.toml``. Uses a write-then-rename pattern: the payload is staged under a ``.tmp`` sibling, ``fsync``-ed, and renamed in place via :func:`os.replace`, after which the parent directory is ``fsync``-ed too. A crash mid-write leaves either the previous good manifest or the new good manifest, never a torn intermediate; the ``fsync`` pair makes that guarantee survive a hard power loss (matching the rotation atomic-write path), so a half-flushed tmp file can no longer become a zero-length manifest on reboot. """target=manifest_path(paths)tmp=target.with_suffix(target.suffix+".tmp")payload=_serialise_manifest(manifest)try:withopen(tmp,"w",encoding=_UTF_8_ENCODING)ashandle:handle.write(payload)handle.flush()os.fsync(handle.fileno())os.replace(tmp,target)fsync_parent_dir(target)exceptOSErrorasexc:_unlink_tmp_manifest(tmp)raisemanifest_validation_error("bucket manifest cannot be written")fromexc
[docs]defread_manifest(paths:BucketPaths)->BucketManifest:"""Read and strict-validate the manifest from ``<bucket-dir>/manifest.toml``. Args: paths: The resolved bucket paths providing the manifest file location. Returns: A strict-validated :class:`BucketManifest`. Raises: StorageValidationError: When the manifest cannot be read, cannot be parsed, or is missing the lifecycle status key. """target=manifest_path(paths)try:text=target.read_text(encoding=_UTF_8_ENCODING)exceptFileNotFoundErrorasexc:raisemanifest_validation_error(MISSING_BUCKET_MANIFEST_MESSAGE)fromexcexceptOSErrorasexc:raisemanifest_validation_error("bucket manifest cannot be read")fromexcpayload:dict[str,object]=parse_toml_text(text,error_factory=manifest_validation_error)# TOML carries no native null; an absent ``last_unlocked_at`` key on disk# signals "never unlocked" and is hydrated to ``None`` at the boundary so# the strict pydantic model still rejects unknown keys.payload.setdefault("last_unlocked_at",None)payload.setdefault("idle_lock_minutes",None)if"status"notinpayload:raisemanifest_validation_error("bucket manifest is missing required lifecycle status")returnBucketManifest.model_validate(payload)
def_unlink_tmp_manifest(tmp:Path)->None:try:tmp.unlink()exceptFileNotFoundError:_log.debug("bucket manifest temp cleanup skipped because temp file is absent")exceptOSErrorasexc:_log.debug("bucket manifest temp cleanup failed error_type=%s",type(exc).__name__)__all__=["MISSING_BUCKET_MANIFEST_MESSAGE","manifest_path","manifest_validation_error","read_manifest","write_manifest",]