diff --git a/backends/xdata_specs.py b/backends/xdata_specs.py index a0e3dd1..c6b3240 100644 --- a/backends/xdata_specs.py +++ b/backends/xdata_specs.py @@ -3,7 +3,11 @@ DXF XDATA is a list of (group code, value) pairs under a registered APPID. This module is the one place that decides how a JSON value becomes a group code and enforces the two limits AutoCAD enforces silently: 255 characters -per string and 16 KB per entity. +per string and 16 KB per entity. It also refuses the one thing neither engine +can store: a line separator inside a string. DXF is a text format of +alternating "group code" / "value" lines, and ezdxf writes a 1000 tag verbatim, +so a LF or CR in the value splits the tag pair and the saved file -- and every +undo/transaction snapshot, which is also a DXF save -- becomes unreadable. """ from __future__ import annotations @@ -15,6 +19,7 @@ RESERVED_APPS = {"ACAD"} MAX_STRING = 255 MAX_BYTES = 16 * 1024 INT_MIN, INT_MAX = -(2**31), 2**31 - 1 +LINE_SEPARATORS = ("\n", "\r") def validate_app_name(app) -> str: @@ -40,6 +45,11 @@ def encode_values(values) -> list[tuple[int, object]]: raise ValueError( f"xdata: {where} is {len(value)} characters; the limit is {MAX_STRING}" ) + if any(sep in value for sep in LINE_SEPARATORS): + raise ValueError( + f"xdata: {where} contains a newline; " + "line separators are not allowed in XDATA strings" + ) tags.append((1000, value)) size += len(value.encode("utf-8")) + 2 elif isinstance(value, int): diff --git a/tests/test_xdata.py b/tests/test_xdata.py index e78d195..e60cee6 100644 --- a/tests/test_xdata.py +++ b/tests/test_xdata.py @@ -48,6 +48,23 @@ def test_string_over_255_is_refused(): encode_values(["x" * (MAX_STRING + 1)]) +@pytest.mark.parametrize("sep", ["\n", "\r", "\r\n"]) +def test_line_separators_in_strings_are_refused(sep): + # DXF is "code\nvalue\n" lines and ezdxf writes 1000 tags verbatim, so a + # separator inside the value splits the tag pair and corrupts the file. + with pytest.raises(ValueError, match=r"values\[1\].*line separators are not allowed"): + encode_values(["fine", f"Inspect weld{sep}before paint"]) + + +def test_other_control_characters_are_still_allowed(): + # Only LF/CR split a DXF tag; tabs and the exotic separators round-trip. + assert encode_values(["a\tb", "a\x0bb", "a
b"]) == [ + (1000, "a\tb"), + (1000, "a\x0bb"), + (1000, "a
b"), + ] + + def test_total_size_over_16k_is_refused(): with pytest.raises(ValueError, match="16"): encode_values(["x" * 255] * (MAX_BYTES // 255 + 1)) @@ -108,6 +125,32 @@ async def test_limits_are_enforced_before_writing(backend): assert (await backend.entity_get_xdata(line.handle))["xdata"] == {} +async def test_newline_is_refused_before_writing_and_snapshots_still_load(backend, tmp_path): + # Every undo/transaction snapshot is a DXF save, so an accepted newline + # would break rollback, not just the file on disk. + line = await backend.entity_create_line(0, 0, 1, 1) + with pytest.raises(ValueError, match="line separators are not allowed"): + await backend.entity_set_xdata(line.handle, "NOTES", ["Inspect weld\nbefore paint"]) + assert (await backend.entity_get_xdata(line.handle))["xdata"] == {} + + await backend.entity_set_xdata(line.handle, "NOTES", ["Inspect weld before paint"]) + await backend.transaction_begin() + circle = await backend.entity_create_circle(5, 5, 1) + await backend.transaction_rollback() + assert (await backend.entity_get_xdata(line.handle, "NOTES"))["xdata"] == { + "NOTES": ["Inspect weld before paint"] + } + with pytest.raises(RuntimeError, match="not found"): + await backend.entity_get(circle.handle) + + path = str(tmp_path / "notes.dxf") + await backend.drawing_save(path) + await backend.drawing_open(path) + assert (await backend.entity_get_xdata(line.handle, "NOTES"))["xdata"] == { + "NOTES": ["Inspect weld before paint"] + } + + # ── live engine, fake ActiveX ──────────────────────────────────────────────── @@ -170,3 +213,10 @@ async def test_com_empty_values_write_only_the_marker(com_backend): result = await backend.entity_set_xdata("2F", "ACADMCP_PID", []) assert result["removed"] is True assert entity.stored == ([1001], ["ACADMCP_PID"]) + + +async def test_com_newline_is_refused_before_any_activex_call(com_backend): + backend, entity, registered = com_backend + with pytest.raises(ValueError, match="line separators are not allowed"): + await backend.entity_set_xdata("2F", "NOTES", ["a\r\nb"]) + assert entity.stored is None and registered == []