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
+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 == []