Skip to content

Commit ac37e41

Browse files
howethomasclaude
andauthored
fix(links): decode base64url dialog audio in the whisper links (#208)
* feat(lib): add decode_inline_dialog_body for base64url dialog bodies draft-ietf-vcon-vcon-core-04 allows base64url, json or none as dialog encoding, and telephony adapters are moving to unpadded base64url for inline audio. Plain base64.b64decode either raises on the missing padding or, in its default non-validating mode, silently drops "-"/"_" and returns corrupted audio instead of raising. decode_inline_dialog_body() dispatches on the dialog's own encoding: base64url (with or without padding) via a validated decode, legacy base64/missing encoding for vCons stored before encoding was mandatory, and a clear ValueError for "none" or anything unrecognized rather than guessing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(links): decode inline dialog audio via shared base64url-safe helper groq_whisper and hugging_face_whisper decoded inline dialog audio with plain base64.b64decode(dialog["body"]) regardless of the dialog's encoding field, which corrupts or rejects unpadded base64url bodies from adapters moving to that encoding. wtf_transcribe already branched on encoding == "base64url" but used a non-validating, unpadded urlsafe_b64decode that has the same failure mode. Switch all three to common/lib/dialog_body.decode_inline_dialog_body(), which validates and pads as needed. URL-referenced (non-inline) dialogs are unchanged in all three links. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(lib): accept line-wrapped base64 in decode_inline_dialog_body The previous non-validating decode skipped line breaks, so MIME-style bodies wrapped at 76 columns decoded. Strip whitespace before the validating decode so those keep working; other stray characters still fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent cb25d06 commit ac37e41

8 files changed

Lines changed: 238 additions & 7 deletions

File tree

‎common/lib/dialog_body.py‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
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}")
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import base64
2+
3+
import pytest
4+
5+
from lib.dialog_body import decode_inline_dialog_body
6+
7+
8+
def test_base64url_without_padding_round_trips():
9+
raw = b"hello world, this is audio-ish binary data"
10+
body = base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
11+
assert decode_inline_dialog_body({"encoding": "base64url", "body": body}) == raw
12+
13+
14+
def test_base64url_with_padding_round_trips():
15+
raw = b"hello world, this is audio-ish binary dat" # len % 3 != 0 -> padded
16+
body = base64.urlsafe_b64encode(raw).decode("ascii")
17+
assert body.endswith("=")
18+
assert decode_inline_dialog_body({"encoding": "base64url", "body": body}) == raw
19+
20+
21+
def test_legacy_base64_round_trips():
22+
raw = b"legacy audio bytes"
23+
body = base64.b64encode(raw).decode("ascii")
24+
assert decode_inline_dialog_body({"encoding": "base64", "body": body}) == raw
25+
26+
27+
def test_missing_encoding_treated_as_legacy_base64():
28+
raw = b"vcon stored before encoding was mandatory"
29+
body = base64.b64encode(raw).decode("ascii")
30+
assert decode_inline_dialog_body({"body": body}) == raw
31+
32+
33+
def test_bytes_that_encode_to_dash_and_underscore_in_base64url_round_trip_exactly():
34+
# 0xFB -> "-" and 0xFF -> "_" only in the URL-safe alphabet ("+"/"/" in
35+
# standard base64). Plain base64.b64decode in its default, non-validating
36+
# mode silently drops "-"/"_" instead of raising, which is exactly the
37+
# silent-corruption failure mode this helper exists to prevent.
38+
raw = bytes([0xFB, 0xFF, 0x00, 0x10, 0x83, 0xFB, 0xFF])
39+
body = base64.urlsafe_b64encode(raw).decode("ascii")
40+
assert "-" in body or "_" in body
41+
unpadded = body.rstrip("=")
42+
43+
decoded = decode_inline_dialog_body({"encoding": "base64url", "body": unpadded})
44+
assert decoded == raw
45+
46+
# Prove the corruption this guards against: plain b64decode on the
47+
# same (padded) base64url text does NOT raise and does NOT return the
48+
# original bytes.
49+
corrupted = base64.b64decode(body)
50+
assert corrupted != raw
51+
52+
53+
def test_none_encoding_raises_instead_of_returning_string_as_audio():
54+
with pytest.raises(ValueError):
55+
decode_inline_dialog_body({"encoding": "none", "body": "just text"})
56+
57+
58+
def test_unrecognized_encoding_raises():
59+
with pytest.raises(ValueError):
60+
decode_inline_dialog_body({"encoding": "json", "body": "{}"})
61+
62+
63+
def test_invalid_base64url_body_raises():
64+
with pytest.raises(ValueError):
65+
decode_inline_dialog_body({"encoding": "base64url", "body": "not valid base64!!"})
66+
67+
68+
def test_invalid_legacy_base64_body_raises():
69+
with pytest.raises(ValueError):
70+
decode_inline_dialog_body({"body": "not valid base64!!"})
71+
72+
73+
def test_line_wrapped_bodies_decode():
74+
raw = bytes(range(256)) * 2
75+
std = base64.b64encode(raw).decode()
76+
wrapped = "\n".join(std[i : i + 76] for i in range(0, len(std), 76))
77+
assert decode_inline_dialog_body({"body": wrapped, "encoding": "base64"}) == raw
78+
url = base64.urlsafe_b64encode(raw).decode().rstrip("=")
79+
wrapped_url = "\r\n".join(url[i : i + 76] for i in range(0, len(url), 76))
80+
assert decode_inline_dialog_body({"body": wrapped_url, "encoding": "base64url"}) == raw

‎conserver/links/groq_whisper/__init__.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"""
1313

1414
from lib.redaction import safe_opts
15+
from lib.dialog_body import decode_inline_dialog_body
1516
import base64
1617
import hashlib
1718
import logging
@@ -139,8 +140,9 @@ def get_file_content(dialog: dict) -> bytes:
139140
Exception: If file cannot be retrieved or verified
140141
"""
141142
if "body" in dialog:
142-
# body contains the base64 encoded content. Decode and return
143-
return base64.b64decode(dialog["body"])
143+
# body contains the base64/base64url encoded content. Decode per
144+
# the dialog's own encoding and return.
145+
return decode_inline_dialog_body(dialog)
144146

145147
elif "url" in dialog:
146148
# Handle external file

‎conserver/links/groq_whisper/test_groq_whisper.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,17 @@ def test_get_file_content_from_body():
451451
content = get_file_content(dialog)
452452
assert content == b"test audio content"
453453

454+
def test_get_file_content_from_base64url_body_without_padding():
455+
"""base64url dialogs (no padding, per draft-ietf-vcon-vcon-core-04) decode
456+
to the original bytes, not the corrupted/raised result plain b64decode gives."""
457+
raw = b"test audio content, base64url, no padding"
458+
dialog = {
459+
"encoding": "base64url",
460+
"body": base64.urlsafe_b64encode(raw).decode('utf-8').rstrip('='),
461+
}
462+
content = get_file_content(dialog)
463+
assert content == raw
464+
454465
@patch('links.groq_whisper.requests.get')
455466
def test_get_file_content_from_url(mock_get):
456467
"""Test extracting file content from URL reference"""

‎conserver/links/hugging_face_whisper/__init__.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"""
1212

1313
from lib.redaction import safe_opts
14+
from lib.dialog_body import decode_inline_dialog_body
1415
import base64
1516
import hashlib
1617
import logging
@@ -83,8 +84,9 @@ def get_file_content(dialog: dict) -> bytes:
8384
Exception: If file cannot be retrieved or verified
8485
"""
8586
if "body" in dialog:
86-
# body contains the base64 encoded content. Decode and return
87-
return base64.b64decode(dialog["body"])
87+
# body contains the base64/base64url encoded content. Decode per
88+
# the dialog's own encoding and return.
89+
return decode_inline_dialog_body(dialog)
8890

8991
elif "url" in dialog:
9092
# Handle external file
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""get_file_content() decode coverage for CON-1112.
2+
3+
The rest of this link's test suite (test_hugging_face_whisper.py,
4+
test_hugging_face_whisper_integration.py) is commented out pending an HF API
5+
key, but get_file_content() itself is pure and needs no network or model
6+
call, so it is covered here unconditionally.
7+
"""
8+
9+
import base64
10+
11+
import pytest
12+
13+
from links.hugging_face_whisper import get_file_content
14+
15+
16+
def test_get_file_content_from_legacy_base64_body():
17+
dialog = {"body": base64.b64encode(b"test audio content").decode("utf-8")}
18+
assert get_file_content(dialog) == b"test audio content"
19+
20+
21+
def test_get_file_content_from_base64url_body_without_padding():
22+
"""base64url dialogs (no padding, per draft-ietf-vcon-vcon-core-04) decode
23+
to the original bytes, not the corrupted/raised result plain b64decode gives."""
24+
raw = b"test audio content, base64url, no padding"
25+
dialog = {
26+
"encoding": "base64url",
27+
"body": base64.urlsafe_b64encode(raw).decode("utf-8").rstrip("="),
28+
}
29+
assert get_file_content(dialog) == raw
30+
31+
32+
def test_get_file_content_from_base64url_body_with_padding():
33+
raw = b"test audio content, base64url, padded"
34+
dialog = {
35+
"encoding": "base64url",
36+
"body": base64.urlsafe_b64encode(raw).decode("utf-8"),
37+
}
38+
assert get_file_content(dialog) == raw
39+
40+
41+
def test_get_file_content_none_encoding_raises():
42+
dialog = {"encoding": "none", "body": "just text, not audio"}
43+
with pytest.raises(ValueError):
44+
get_file_content(dialog)

‎conserver/links/wtf_transcribe/__init__.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323
url-timeout: 300
2424
"""
2525

26-
import base64
2726
import json
2827
import logging
2928
import requests
@@ -32,6 +31,7 @@
3231
from lib.vcon_redis import VconRedis
3332
from lib.logging_utils import init_logger
3433
from lib.error_tracking import init_error_tracker
34+
from lib.dialog_body import decode_inline_dialog_body
3535

3636
init_error_tracker()
3737
logger = init_logger(__name__)
@@ -111,13 +111,13 @@ def is_base64url_dialog(dialog):
111111
return has_base64url_encoding(dialog) and has_body(dialog)
112112

113113
def base64url_dialog_to_binary(dialog):
114-
return base64.urlsafe_b64decode(dialog["body"])
114+
return decode_inline_dialog_body(dialog)
115115

116116
def is_base64_dialog(dialog):
117117
return has_body(dialog)
118118

119119
def base64_dialog_to_binary(dialog):
120-
return base64.b64decode(dialog["body"])
120+
return decode_inline_dialog_body(dialog)
121121

122122
def dialog_to_binary(dialog, url_timeout=60):
123123
if is_url_dialog(dialog):

‎conserver/links/wtf_transcribe/test_wtf_transcribe_unit.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,11 @@ def test_dialog_to_binary_handles_url_base64url_base64_and_invalid_inputs():
9292
base64url_body = base64.urlsafe_b64encode(b"url-safe").decode("ascii")
9393
assert dialog_to_binary({"encoding": "base64url", "body": base64url_body}) == b"url-safe"
9494

95+
# Telephony adapters emit base64url WITHOUT padding (draft-ietf-vcon-vcon-core-04);
96+
# plain base64.urlsafe_b64decode raises on that, so this must still round-trip.
97+
unpadded_base64url_body = base64.urlsafe_b64encode(b"url-safe, unpadded").decode("ascii").rstrip("=")
98+
assert dialog_to_binary({"encoding": "base64url", "body": unpadded_base64url_body}) == b"url-safe, unpadded"
99+
95100
base64_body = base64.b64encode(b"plain").decode("ascii")
96101
assert dialog_to_binary({"body": base64_body}) == b"plain"
97102

0 commit comments

Comments
 (0)