Skip to content

dmap: encode string tags with correct byte length - #2918

Open
peter-research wants to merge 1 commit into
postlund:masterfrom
peter-research:fix/dmap-string-tag-utf8-length
Open

peter-research wants to merge 1 commit into
postlund:masterfrom
peter-research:fix/dmap-string-tag-utf8-length

Conversation

@peter-research

Copy link
Copy Markdown

Problem

tags.string_tag() in pyatv/protocols/dmap/tags.py builds a DMAP TLV whose 4-byte big-endian length field is len(value) — the number of characters — while the payload it writes is value.encode("utf-8"), i.e. a number of bytes.

For any non-ASCII string those two numbers differ, so the tag is malformed:

>>> tags.string_tag("minm", "Blåbär")
b'minm\x00\x00\x00\x06Bl\xc3\xa5b\xc3\xa4r'
#  declared length: 6, actual payload: 8 bytes

DMAP is a plain TLV format (see the module docstring in pyatv/protocols/dmap/parser.py): a 4-byte name, a 4-byte big-endian length, then exactly that many bytes of data. A receiver reading this tag gets the length from the header and therefore reads 6 of the 8 payload bytes, cutting the UTF-8 sequence b'\xc3\xa5' in half:

>>> parser.parse(tags.container_tag("mlit", tags.string_tag("minm", "Blåbär")), lookup_tag)
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xc3 in position 5: unexpected end of data

Because the length is wrong, the parser also resumes at the wrong offset, so any tag following the string is misparsed as garbage. On a real device this means non-ASCII metadata is either truncated or drops the rest of the payload.

This affects every place pyatv writes DMAP strings:

  • pyatv/support/rtsp.py — minm / asal / asar metadata tags sent via SET_PARAMETER (title, album, artist of whatever is streamed)
  • pyatv/protocols/dmap/pairing.py — cmnm device name during pairing
  • pyatv/protocols/dmap/__init__.py — cmbe / cmte command strings

The bug is present since the initial commit (cd7dac1b, 2017) and is not intentional design: no commit ever touched the length computation other than a black reformat.

Approach

Encode the string once and use len(encoded) as the length field, so the declared length always matches the bytes on the wire:

def string_tag(name, value):
    """Create a DMAP tag with string data."""
    encoded = value.encode("utf-8")
    return name.encode("utf-8") + len(encoded).to_bytes(4, byteorder="big") + encoded

ASCII output is byte-for-byte identical to before, so only the previously-broken non-ASCII case changes.

Tests

Added test_parse_non_ascii_string to tests/protocols/dmap/test_parser.py, which round-trips two non-ASCII strings (Latin-1 supplement and CJK) through string_tag → parser.parse:

def test_parse_non_ascii_string():
    in_data = tags.string_tag("stra", "räksmörgås") + tags.string_tag("strb", "你好")
    parsed = parser.parse(in_data, lookup_tag)
    assert 2 == len(parsed)
    assert "räksmörgås" == parser.first(parsed, "stra")
    assert "你好" == parser.first(parsed, "strb")

Before the patch it fails with KeyError: 'åss' (the parser resumed mid-string and read a bogus tag name). After the patch it passes. tests/protocols/dmap, tests/protocols/raop and tests/support are green (537 passed, 5 skipped), and black + ruff are clean.

Risk

Low. The change is confined to one function and only affects the length field, which was already wrong for non-ASCII input. For ASCII input the emitted bytes are unchanged. No public API, no protocol behaviour and no existing test expectation is touched. The worst case if a receiver somehow depended on the buggy character count is unchanged behaviour for ASCII media, which is the overwhelming majority of traffic.

tags.string_tag declared the DMAP length field as len(value), i.e. the
number of characters, while the payload written is the UTF-8 encoding of
the string. For any non-ASCII string the declared length is shorter than
the actual data, producing a malformed TLV: a receiver parsing the tag
reads a truncated, and in the middle of a multi-byte sequence, invalid
UTF-8 payload, and any tag following it is misaligned.

Encode once and use len(encoded) as the length field.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant