From f93df161f953f778d2766610f6661e3fd8db6f68 Mon Sep 17 00:00:00 2001 From: Vinit Kumar Date: Thu, 4 Jun 2026 08:42:54 +0530 Subject: [PATCH 1/4] Validate XML boundary names --- json2xml/dicttoxml.py | 28 +++++++++++++++++++++++++++- lat.md/tests.md | 8 ++++++++ tests/test_dict2xml.py | 24 ++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/json2xml/dicttoxml.py b/json2xml/dicttoxml.py index c6def5c..2e2321c 100644 --- a/json2xml/dicttoxml.py +++ b/json2xml/dicttoxml.py @@ -137,6 +137,7 @@ def make_attrstring(attr: dict[str, Any]) -> str: """ if not attr: return "" + validate_xml_attr_names(attr) if len(attr) == 1: key, val = next(iter(attr.items())) if key == "type": @@ -183,6 +184,30 @@ def key_is_valid_xml(key: str) -> bool: return False +@lru_cache(maxsize=4096) +def key_is_valid_xml_attr(key: str) -> bool: + """Return True when key can be emitted directly as an XML attribute name.""" + key = str(key) + if not key: + return False + + from defusedxml.minidom import parseString + + test_xml = f'' + try: + parseString(test_xml) + return True + except Exception: # minidom does not implement exceptions well + return False + + +def validate_xml_attr_names(attr: dict[str, Any]) -> None: + """Reject attributes that would make the generated XML malformed.""" + for key in attr: + if not key_is_valid_xml_attr(key): + raise ValueError(f"Invalid XML attribute name: {key}") + + def make_valid_xml_name(key: str, attr: dict[str, Any]) -> tuple[str, dict[str, Any]]: """Return a valid XML element name and carry the original key as metadata when needed.""" key = str(key) @@ -902,10 +927,11 @@ def dicttoxml( namespace_str += f' xmlns:{prefix}="{ns}"' if root: output.append('') + custom_root, root_attr = make_valid_xml_name(custom_root, {}) output_elem = convert( obj, ids, attr_type, item_func, cdata, item_wrap, parent=custom_root, list_headers=list_headers ) - output.append(f"<{custom_root}{namespace_str}>{output_elem}") + output.append(f"<{custom_root}{make_attrstring(root_attr)}{namespace_str}>{output_elem}") else: output.append( convert(obj, ids, attr_type, item_func, cdata, item_wrap, parent="", list_headers=list_headers) diff --git a/lat.md/tests.md b/lat.md/tests.md index cd746bb..cef6bf6 100644 --- a/lat.md/tests.md +++ b/lat.md/tests.md @@ -114,6 +114,14 @@ Special dictionary keys such as `@attrs` and `@val` should bypass the Rust calla Root scalar payloads should bypass the Rust callable until the accelerator preserves the legacy Python `` wrapper shape under the configured root element. +### Custom root names normalize before raw output + +Invalid custom root names should use the serializer's existing XML-name normalization before raw bytes are returned so `pretty=False` cannot emit malformed root tags. + +### Invalid custom attributes are rejected + +Custom `@attrs` keys that are not valid XML attribute names should fail explicitly because attributes have no safe metadata fallback equivalent to element `` output. + ## XML helper behavior These tests pin low-level XML helper contracts so performance refactors keep the same serializer output and caller-side mutation behavior. diff --git a/tests/test_dict2xml.py b/tests/test_dict2xml.py index b81726b..1a1bb6f 100644 --- a/tests/test_dict2xml.py +++ b/tests/test_dict2xml.py @@ -494,6 +494,30 @@ def test_dicttoxml_with_custom_root_missing_prefix(self) -> None: result = dicttoxml.dicttoxml(data, custom_root="custom", attr_type=False) assert b"value" in result + # @lat: [[tests#Conversion behavior#Custom root names normalize before raw output]] + def test_dicttoxml_normalizes_invalid_custom_root_name(self) -> None: + """Invalid custom roots should use the same XML-name normalization as JSON object keys.""" + result = dicttoxml.dicttoxml( + {"key": "value"}, + custom_root="custom root", + attr_type=False, + ) + + assert result == ( + b'' + b"value" + ) + + # @lat: [[tests#Conversion behavior#Invalid custom attributes are rejected]] + def test_dicttoxml_rejects_invalid_custom_attribute_names(self) -> None: + """Invalid custom attribute names should fail before dicttoxml returns malformed XML bytes.""" + with pytest.raises(ValueError, match="Invalid XML attribute name"): + dicttoxml.dicttoxml( + {"key": {"@attrs": {"bad attr": "value"}, "@val": "payload"}}, + root=False, + attr_type=False, + ) + def test_dicttoxml_with_xml_namespaces(self) -> None: """Test dicttoxml with XML namespaces.""" data = {"key": "value"} From 430377cebb348bb2370797c9c963908827c617e3 Mon Sep 17 00:00:00 2001 From: Vinit Kumar Date: Thu, 4 Jun 2026 08:50:08 +0530 Subject: [PATCH 2/4] fix: use 3.15.0 beta2 --- .github/workflows/pythonpackage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index b810b16..a45dcee 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -26,7 +26,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [pypy-3.10, pypy-3.11, '3.10', '3.11', '3.12', '3.13', '3.14', '3.14t', '3.15.0-alpha.7'] + python-version: [pypy-3.10, pypy-3.11, '3.10', '3.11', '3.12', '3.13', '3.14', '3.14t', '3.15.0b2'] os: [ ubuntu-latest, windows-latest, From 43142eebc6b847062abc435e702fc3a59e77b3f8 Mon Sep 17 00:00:00 2001 From: Vinit Kumar Date: Thu, 4 Jun 2026 08:53:52 +0530 Subject: [PATCH 3/4] fix: use correct version name --- .github/workflows/pythonpackage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index a45dcee..1bb738d 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -26,7 +26,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [pypy-3.10, pypy-3.11, '3.10', '3.11', '3.12', '3.13', '3.14', '3.14t', '3.15.0b2'] + python-version: [pypy-3.10, pypy-3.11, '3.10', '3.11', '3.12', '3.13', '3.14', '3.14t', '3.15.0-beta.2'] os: [ ubuntu-latest, windows-latest, From e50a5eb3dad39718a87a8f7237319822d4e95a04 Mon Sep 17 00:00:00 2001 From: Vinit Kumar Date: Thu, 4 Jun 2026 09:28:42 +0530 Subject: [PATCH 4/4] fix: issues with dicttoxml cases --- lat.md/tests.md | 4 ++++ tests/test_dict2xml.py | 19 +++++++++++++++++-- tests/test_dicttoxml_unit.py | 27 +++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/lat.md/tests.md b/lat.md/tests.md index cef6bf6..86e3f6c 100644 --- a/lat.md/tests.md +++ b/lat.md/tests.md @@ -133,3 +133,7 @@ Helpers that receive prevalidated XML names should add type metadata only to the ### XML name validity fast and cached paths XML name validation should agree across the ASCII fast path, parser-backed path, and repeated cached calls so optimization does not change accepted names. + +### XML attribute name validation + +Attribute name validation should reject malformed custom attribute keys while preserving parser-accepted edge names such as underscores, hyphens, and xml-prefixed names. diff --git a/tests/test_dict2xml.py b/tests/test_dict2xml.py index 1a1bb6f..13d9277 100644 --- a/tests/test_dict2xml.py +++ b/tests/test_dict2xml.py @@ -509,15 +509,30 @@ def test_dicttoxml_normalizes_invalid_custom_root_name(self) -> None: ) # @lat: [[tests#Conversion behavior#Invalid custom attributes are rejected]] - def test_dicttoxml_rejects_invalid_custom_attribute_names(self) -> None: + @pytest.mark.parametrize( + "attr_name", + ["", "1foo", "foo>bar", 'foo"bar', "foo\nbar", "bad attr"], + ) + def test_dicttoxml_rejects_invalid_custom_attribute_names(self, attr_name: str) -> None: """Invalid custom attribute names should fail before dicttoxml returns malformed XML bytes.""" with pytest.raises(ValueError, match="Invalid XML attribute name"): dicttoxml.dicttoxml( - {"key": {"@attrs": {"bad attr": "value"}, "@val": "payload"}}, + {"key": {"@attrs": {attr_name: "value"}, "@val": "payload"}}, root=False, attr_type=False, ) + @pytest.mark.parametrize("attr_name", ["a_b", "a-b", "xmlAttr"]) + def test_dicttoxml_accepts_valid_custom_attribute_edge_names(self, attr_name: str) -> None: + """Borderline valid custom attribute names should remain accepted.""" + result = dicttoxml.dicttoxml( + {"key": {"@attrs": {attr_name: "value"}, "@val": "payload"}}, + root=False, + attr_type=False, + ) + + assert result == f'payload'.encode() + def test_dicttoxml_with_xml_namespaces(self) -> None: """Test dicttoxml with XML namespaces.""" data = {"key": "value"} diff --git a/tests/test_dicttoxml_unit.py b/tests/test_dicttoxml_unit.py index 3e92dcb..91d0a40 100644 --- a/tests/test_dicttoxml_unit.py +++ b/tests/test_dicttoxml_unit.py @@ -134,3 +134,30 @@ def test_key_is_valid_xml_fast_and_parse_paths_are_stable_under_cache() -> None: assert second == cases cache_info = dicttoxml.key_is_valid_xml.cache_info() assert cache_info.hits >= len(cases) + + +# @lat: [[tests#XML helper behavior#XML attribute name validation]] +def test_xml_attribute_name_validation_accepts_only_parser_valid_names() -> None: + dicttoxml.key_is_valid_xml_attr.cache_clear() + + cases = { + "a_b": True, + "a-b": True, + "xmlAttr": True, + "": False, + "1foo": False, + "foo>bar": False, + 'foo"bar': False, + "foo\nbar": False, + } + + first = {key: dicttoxml.key_is_valid_xml_attr(key) for key in cases} + second = {key: dicttoxml.key_is_valid_xml_attr(key) for key in reversed(cases)} + + assert first == cases + assert second == cases + dicttoxml.validate_xml_attr_names({key: "value" for key, is_valid in cases.items() if is_valid}) + for key, is_valid in cases.items(): + if not is_valid: + with pytest.raises(ValueError, match="Invalid XML attribute name"): + dicttoxml.validate_xml_attr_names({key: "value"})