fix(task-4): refuse LF/CR in XDATA strings before any write

encode_values() accepted strings containing "\n" or "\r". ezdxf writes
1000-tag values verbatim, so the separator split the tag pair: the saved
DXF failed to reopen (DXFStructureError) and, because every undo and
transaction snapshot is also a DXF save, transaction_rollback broke after
a single such write. Both engines route through encode_values, so the
refusal (ValueError naming values[i]) now happens there, before either
backend touches the document. Only LF and CR split a DXF tag; tabs and
the other Unicode separators round-trip and stay allowed.

Tests: pure encoder refusal for \n, \r, \r\n; other control characters
still typed; ezdxf refuses-then-rollback-and-reopen round trip; COM fake
sees no SetXData / RegisteredApplications call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-15 14:33:00 +03:00
co-authored by Claude Opus 5
parent cda8b69dba
commit 41d6d3505a
2 changed files with 61 additions and 1 deletions
+11 -1
View File
@@ -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):
+50
View File
@@ -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 == []