|
| 1 | +"""Decoding for inline dialog bodies. |
| 2 | +
|
| 3 | +draft-ietf-vcon-vcon-core-04 allows exactly three values for a dialog's |
| 4 | +``encoding``: ``"base64url"``, ``"json"`` or ``"none"``. Telephony adapters |
| 5 | +are moving to ``base64url`` *without* padding for inline audio bodies. |
| 6 | +
|
| 7 | +Standard ``base64.b64decode`` chokes on that: with ``validate=True`` it |
| 8 | +raises on the missing padding, and in its default (non-validating) mode it |
| 9 | +silently drops the ``-``/``_`` characters that are only valid in the |
| 10 | +URL-safe alphabet and hands back corrupted audio instead of raising. |
| 11 | +
|
| 12 | +:func:`decode_inline_dialog_body` is the one place that should turn a |
| 13 | +dialog's inline ``body`` into raw bytes. It dispatches on ``encoding`` |
| 14 | +instead of guessing, so a body encoded in a way we do not understand raises |
| 15 | +rather than silently returning garbage. |
| 16 | +""" |
| 17 | + |
| 18 | +import base64 |
| 19 | +import binascii |
| 20 | +from typing import Any, Dict |
| 21 | + |
| 22 | + |
| 23 | +def _restore_base64url_padding(body: str) -> str: |
| 24 | + """Pad a base64url string out to a multiple of 4 characters. |
| 25 | +
|
| 26 | + Adapters that emit base64url without padding are a valid rendering of |
| 27 | + the same bytes -- padding is only there to make the length a multiple |
| 28 | + of 4, and ``-``/``_`` already disambiguate the alphabet, so restoring |
| 29 | + it here is lossless. |
| 30 | + """ |
| 31 | + return body + "=" * (-len(body) % 4) |
| 32 | + |
| 33 | + |
| 34 | +def decode_inline_dialog_body(dialog: Dict[str, Any]) -> bytes: |
| 35 | + """Decode a dialog's inline ``body`` per its ``encoding`` field. |
| 36 | +
|
| 37 | + Args: |
| 38 | + dialog: A vCon dialog object carrying an inline ``body`` (as opposed |
| 39 | + to a ``url``-referenced one). |
| 40 | +
|
| 41 | + Returns: |
| 42 | + The decoded raw bytes. |
| 43 | +
|
| 44 | + Raises: |
| 45 | + ValueError: ``encoding`` is missing/legacy and the body is not valid |
| 46 | + base64; ``encoding`` is ``"none"`` (the body is already a plain |
| 47 | + string, not encoded audio bytes); or ``encoding`` is some other, |
| 48 | + unrecognized value. |
| 49 | + KeyError: ``dialog`` has no ``body``. |
| 50 | + """ |
| 51 | + body = dialog["body"] |
| 52 | + encoding = dialog.get("encoding") |
| 53 | + if isinstance(body, str): |
| 54 | + # Some producers wrap base64 at 76 columns (MIME style). The old |
| 55 | + # non-validating decode skipped the line breaks; keep accepting them |
| 56 | + # while still rejecting any other out-of-alphabet character. |
| 57 | + body = "".join(body.split()) |
| 58 | + |
| 59 | + if encoding == "base64url": |
| 60 | + if not isinstance(body, str): |
| 61 | + raise ValueError(f"Cannot decode base64url dialog body of type {type(body).__name__}; expected str") |
| 62 | + try: |
| 63 | + padded = _restore_base64url_padding(body) |
| 64 | + # base64.urlsafe_b64decode() just translates "-"/"_" to "+"/"/" |
| 65 | + # and calls b64decode() *without* validate=True, so it silently |
| 66 | + # drops any other out-of-alphabet character instead of raising. |
| 67 | + # Translate ourselves and validate explicitly to fail loudly on |
| 68 | + # garbage input rather than return corrupted audio. |
| 69 | + standard = padded.translate({ord("-"): "+", ord("_"): "/"}) |
| 70 | + return base64.b64decode(standard, validate=True) |
| 71 | + except (binascii.Error, ValueError) as e: |
| 72 | + raise ValueError(f"Invalid base64url dialog body: {e}") from e |
| 73 | + |
| 74 | + if encoding == "base64" or encoding is None: |
| 75 | + # Legacy: vCons stored before the encoding field was spec-mandated, |
| 76 | + # or producers that never adopted base64url, use plain base64. |
| 77 | + if not isinstance(body, str): |
| 78 | + raise ValueError(f"Cannot decode base64 dialog body of type {type(body).__name__}; expected str") |
| 79 | + try: |
| 80 | + return base64.b64decode(body, validate=True) |
| 81 | + except (binascii.Error, ValueError) as e: |
| 82 | + raise ValueError(f"Invalid base64 dialog body: {e}") from e |
| 83 | + |
| 84 | + if encoding == "none": |
| 85 | + raise ValueError("Dialog body encoding is 'none' (a plain string), not audio bytes") |
| 86 | + |
| 87 | + raise ValueError(f"Unrecognized dialog body encoding: {encoding!r}") |
0 commit comments