From 499a481debef552efc92ff7b8e4c289592da7478 Mon Sep 17 00:00:00 2001 From: Chenghao Liu Date: Fri, 11 Sep 2026 01:08:43 +0800 Subject: [PATCH] fix: decode XML references in DOCX artifacts Signed-off-by: Chenghao Liu --- src/google/adk/tools/load_artifacts_tool.py | 33 ++++++++++++++++++- .../tools/test_load_artifacts_tool.py | 23 ++++++++++--- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/google/adk/tools/load_artifacts_tool.py b/src/google/adk/tools/load_artifacts_tool.py index b90219ad57..d84e06f607 100644 --- a/src/google/adk/tools/load_artifacts_tool.py +++ b/src/google/adk/tools/load_artifacts_tool.py @@ -111,6 +111,31 @@ def _maybe_base64_to_bytes(data: str) -> bytes | None: return None +def _decode_xml_reference(match: re.Match[str]) -> str: + """Decodes one predefined XML entity or numeric character reference.""" + reference = match.group(1) + if not reference.startswith('#'): + return {'amp': '&', 'lt': '<', 'gt': '>', 'quot': '"', 'apos': "'"}[ + reference + ] + try: + codepoint = ( + int(reference[2:], 16) + if reference.startswith('#x') + else int(reference[1:]) + ) + # Preserve invalid XML character references instead of aborting extraction. + if codepoint in (9, 10, 13) or ( + 0x20 <= codepoint <= 0xD7FF + or 0xE000 <= codepoint <= 0xFFFD + or 0x10000 <= codepoint <= 0x10FFFF + ): + return chr(codepoint) + except ValueError: + pass + return match.group(0) + + def _try_extract_docx_text(data: bytes) -> str | None: """Extracts raw text from a DOCX binary.""" # We use regex instead of standard XML parser to avoid XML bomb vulnerabilities, @@ -139,7 +164,13 @@ def _try_extract_docx_text(data: bytes) -> str | None: for p in re.split(rf'<{p_tag}(?:[^>]*)>', xml_content): texts = re.findall(rf'<{t_tag}(?:[^>]*)>([^<]*)', p) if texts: - paragraphs.append(''.join(texts)) + paragraphs.append( + re.sub( + r'&(#x[0-9a-fA-F]+|#[0-9]+|amp|lt|gt|quot|apos);', + _decode_xml_reference, + ''.join(texts), + ) + ) return '\n'.join(paragraphs) except (zipfile.BadZipFile, KeyError, struct.error) as e: diff --git a/tests/unittests/tools/test_load_artifacts_tool.py b/tests/unittests/tools/test_load_artifacts_tool.py index d0f6e34ad3..a7e8907d47 100644 --- a/tests/unittests/tools/test_load_artifacts_tool.py +++ b/tests/unittests/tools/test_load_artifacts_tool.py @@ -171,7 +171,20 @@ async def test_load_artifacts_converts_csv_octet_stream_to_text(): @pytest.mark.asyncio -async def test_load_artifacts_converts_docx_to_text(): +@pytest.mark.parametrize( + ('xml_text', 'expected_text'), + [ + ('Hello DOCX', 'Hello DOCX'), + ('Research & Development', 'Research & Development'), + ('x < 5 && y > 1', 'x < 5 && y > 1'), + ('"Hello" 'world'', '"Hello" \'world\''), + ('中文 😀', 'δΈ­ζ–‡ πŸ˜€'), + ('Literal &lt; and &#65;', 'Literal < and A'), + ('€', '\x80'), + ('&unknown; � �', '&unknown; � �'), + ], +) +async def test_load_artifacts_converts_docx_to_text(xml_text, expected_text): """DOCX binary payloads are extracted to raw text.""" artifact_name = 'document.docx' @@ -180,9 +193,9 @@ async def test_load_artifacts_converts_docx_to_text(): with zipfile.ZipFile(docx_bytes_io, 'w') as zf: zf.writestr( 'word/document.xml', - b'\nHello' - b' DOCX', + '\n' + f'{xml_text}', ) docx_bytes = docx_bytes_io.getvalue() @@ -216,7 +229,7 @@ async def test_load_artifacts_converts_docx_to_text(): artifact_part = llm_request.contents[-1].parts[1] assert artifact_part.inline_data is None - assert artifact_part.text == 'Hello DOCX' + assert artifact_part.text == expected_text @pytest.mark.asyncio