fix(task-4): refuse non-finite/non-numeric XDATA and budget 16 KB per entity
encode_values() let strings and nan/inf through float() into 1010 point
coordinates and never finiteness-checked bare 1040 reals, so
entity_set_xdata(handle, app, [["nan", 1]]) or [float("inf")] wrote a
literal "nan"/"inf" into the DXF (and every undo snapshot) and read back
as null. Numbers now go through the same rule block_specs._scalar applies:
int/float only, bool refused, math.isfinite, error naming values[i][j].
The advertised 16 KB limit was per call per app; AutoCAD's is per object
across every application (xdroom/xdsize), so three 15 KB apps on one
entity passed headless and would fail SetXData live. check_entity_budget()
now sums the entity's existing XDATA under every other app (ACAD's DSTYLE
overrides included) plus each 1001 marker with AutoCAD's resbuf sizes,
and both engines run it before registering the APPID or writing; removal
is never refused. The result's "bytes" is that same accounting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
co-authored by
Claude Opus 5
parent
41d6d3505a
commit
f4c2f27bee
@@ -9,6 +9,8 @@ import pytest
|
||||
from backends.xdata_specs import (
|
||||
MAX_BYTES,
|
||||
MAX_STRING,
|
||||
app_size,
|
||||
check_entity_budget,
|
||||
decode_values,
|
||||
encode_values,
|
||||
split_by_app,
|
||||
@@ -42,6 +44,34 @@ def test_untypeable_values_are_refused(bad):
|
||||
encode_values([bad])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad",
|
||||
[
|
||||
["1", "2"], # strings coerce with float() but are not numbers
|
||||
["nan", 1], # ... and "nan" is the silent wrong number end to end
|
||||
[1, "2", 3],
|
||||
[float("nan"), 1],
|
||||
[1, float("inf")],
|
||||
[1, 2, float("-inf")],
|
||||
],
|
||||
)
|
||||
def test_point_coordinates_must_be_finite_numbers(bad):
|
||||
with pytest.raises((TypeError, ValueError), match=r"values\[0\]\[\d\]"):
|
||||
encode_values([bad])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [float("nan"), float("inf"), float("-inf")])
|
||||
def test_non_finite_floats_are_refused(bad):
|
||||
# ezdxf writes a 1040 real verbatim, so nan/inf would land in the file
|
||||
# (and every undo snapshot) as literal text that is not a DXF double.
|
||||
with pytest.raises(ValueError, match=r"values\[1\] must be finite"):
|
||||
encode_values([1.5, bad])
|
||||
|
||||
|
||||
def test_integer_coordinates_still_encode_as_reals():
|
||||
assert encode_values([[1, 2], [3, 4, 5]]) == [(1010, (1.0, 2.0, 0.0)), (1010, (3.0, 4.0, 5.0))]
|
||||
|
||||
|
||||
def test_string_over_255_is_refused():
|
||||
encode_values(["x" * MAX_STRING])
|
||||
with pytest.raises(ValueError, match="255"):
|
||||
@@ -70,6 +100,29 @@ def test_total_size_over_16k_is_refused():
|
||||
encode_values(["x" * 255] * (MAX_BYTES // 255 + 1))
|
||||
|
||||
|
||||
def test_entity_budget_counts_every_app_and_the_markers():
|
||||
# AutoCAD's 16 KB is per object across all apps (xdroom/xdsize), so the
|
||||
# gate must add what the entity already carries under other apps, ACAD's
|
||||
# own DSTYLE overrides included, plus each app's 1001 marker.
|
||||
big = encode_values(["x" * 255] * 40) # 40 * 258 = 10320 bytes + marker
|
||||
assert app_size("APP_A", big) == 40 * 258 + 3 + len("APP_A")
|
||||
assert check_entity_budget("APP_A", big, {}) == app_size("APP_A", big)
|
||||
# Replacing the same app does not double-count its old payload.
|
||||
assert check_entity_budget("APP_A", big, {"APP_A": big}) == app_size("APP_A", big)
|
||||
# A second app of the same size no longer fits next to the first.
|
||||
with pytest.raises(ValueError, match=r"other applications.*16 KB per entity"):
|
||||
check_entity_budget("APP_B", big, {"APP_A": big})
|
||||
# Removal is never refused, even when what is left is already over the
|
||||
# limit (a file opened with too much XDATA must still be shrinkable).
|
||||
assert check_entity_budget("APP_B", [], {"APP_A": big}) == app_size("APP_A", big)
|
||||
assert check_entity_budget("APP_A", [], {"APP_A": big, "APP_B": big}) == app_size("APP_B", big)
|
||||
# Existing tags may carry their own 1001 marker (ezdxf stores it); it is
|
||||
# counted once either way.
|
||||
with_marker = [(1001, "APP_A"), *big]
|
||||
assert app_size("APP_A", with_marker) == app_size("APP_A", big)
|
||||
assert check_entity_budget("APP_B", [], {"APP_A": with_marker}) == app_size("APP_A", big)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["", "1abc", "a-b", "a" * 32, "ACAD"])
|
||||
def test_bad_app_names_are_refused(bad):
|
||||
with pytest.raises(ValueError):
|
||||
@@ -125,6 +178,68 @@ async def test_limits_are_enforced_before_writing(backend):
|
||||
assert (await backend.entity_get_xdata(line.handle))["xdata"] == {}
|
||||
|
||||
|
||||
async def test_non_finite_and_string_coordinates_are_refused_before_writing(backend):
|
||||
line = await backend.entity_create_line(0, 0, 1, 1)
|
||||
with pytest.raises(TypeError, match=r"values\[0\]\[0\] must be a number"):
|
||||
await backend.entity_set_xdata(line.handle, "APP_A", [["nan", 1]])
|
||||
with pytest.raises(ValueError, match=r"values\[0\] must be finite"):
|
||||
await backend.entity_set_xdata(line.handle, "APP_A", [float("nan")])
|
||||
with pytest.raises(ValueError, match=r"values\[1\] must be finite"):
|
||||
await backend.entity_set_xdata(line.handle, "APP_A", [1.0, float("inf")])
|
||||
assert (await backend.entity_get_xdata(line.handle))["xdata"] == {}
|
||||
|
||||
|
||||
async def test_16k_is_per_entity_across_apps_not_per_call(backend, tmp_path):
|
||||
# Three 15 KB payloads under three apps each fit on their own; the entity
|
||||
# does not, and AutoCAD's SetXData would refuse the second one live.
|
||||
line = await backend.entity_create_line(0, 0, 1, 1)
|
||||
payload = ["x" * 255] * 60
|
||||
first = await backend.entity_set_xdata(line.handle, "APP_A", payload)
|
||||
assert first["ok"] is True and first["bytes"] == 60 * 258 + 3 + len("APP_A")
|
||||
with pytest.raises(ValueError, match=r"other applications.*16 KB per entity"):
|
||||
await backend.entity_set_xdata(line.handle, "APP_B", payload)
|
||||
with pytest.raises(ValueError, match="16 KB per entity"):
|
||||
await backend.entity_set_xdata(line.handle, "APP_C", ["y" * 255] * 5)
|
||||
got = (await backend.entity_get_xdata(line.handle))["xdata"]
|
||||
assert set(got) == {"APP_A"} and "APP_B" not in backend._doc.appids
|
||||
# Replacing APP_A wholesale is judged against the entity *without* its old
|
||||
# APP_A payload, so a same-size rewrite is fine ...
|
||||
await backend.entity_set_xdata(line.handle, "APP_A", ["z" * 255] * 60)
|
||||
# ... and once APP_A shrinks, APP_B fits.
|
||||
await backend.entity_set_xdata(line.handle, "APP_A", ["small"])
|
||||
await backend.entity_set_xdata(line.handle, "APP_B", payload)
|
||||
got = (await backend.entity_get_xdata(line.handle))["xdata"]
|
||||
assert got["APP_A"] == ["small"] and len(got["APP_B"]) == 60
|
||||
path = str(tmp_path / "budget.dxf")
|
||||
await backend.drawing_save(path)
|
||||
await backend.drawing_open(path)
|
||||
assert len((await backend.entity_get_xdata(line.handle, "APP_B"))["xdata"]["APP_B"]) == 60
|
||||
|
||||
|
||||
async def test_budget_counts_acad_xdata_the_dimension_tools_write(backend):
|
||||
# A toleranced dimension carries DSTYLE overrides under the ACAD app; the
|
||||
# live engine counts them against the 16 KB and so must the gate.
|
||||
dim = await backend.dimension_linear(
|
||||
0, 0, 100, 0, 0, 20, tol_upper=0.1, tol_lower=-0.1, tol_mode="deviation"
|
||||
)
|
||||
acad = (await backend.entity_get_xdata(dim.handle))["xdata"]
|
||||
assert "ACAD" in acad and acad["ACAD"], acad
|
||||
ent = backend._get_entity(dim.handle)
|
||||
acad_bytes = app_size("ACAD", [(t.code, t.value) for t in ent.xdata.data["ACAD"]])
|
||||
assert acad_bytes > 0
|
||||
# A payload that fits an empty entity but not one carrying the override.
|
||||
base = ["x" * 255] * 63
|
||||
slack = MAX_BYTES - app_size("APP_A", encode_values(base))
|
||||
assert 3 < acad_bytes < slack
|
||||
filler = [*base, "y" * (slack - 3 - acad_bytes // 2)]
|
||||
assert app_size("APP_A", encode_values(filler)) <= MAX_BYTES
|
||||
assert app_size("APP_A", encode_values(filler)) + acad_bytes > MAX_BYTES
|
||||
with pytest.raises(ValueError, match=r"other applications.*16 KB per entity"):
|
||||
await backend.entity_set_xdata(dim.handle, "APP_A", filler)
|
||||
# Nothing was written and the override is untouched.
|
||||
assert (await backend.entity_get_xdata(dim.handle))["xdata"] == acad
|
||||
|
||||
|
||||
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.
|
||||
@@ -215,6 +330,25 @@ async def test_com_empty_values_write_only_the_marker(com_backend):
|
||||
assert entity.stored == ([1001], ["ACADMCP_PID"])
|
||||
|
||||
|
||||
async def test_com_budget_reads_the_whole_stream_before_writing(com_backend):
|
||||
backend, entity, registered = com_backend
|
||||
# Pretend AutoCAD already holds 15 KB under APP_A (and ACAD overrides).
|
||||
entity.stored = (
|
||||
[1001, 1070, 1001] + [1000] * 60,
|
||||
["ACAD", 271, "APP_A"] + ["x" * 255] * 60,
|
||||
)
|
||||
with pytest.raises(ValueError, match=r"other applications.*16 KB per entity"):
|
||||
await backend.entity_set_xdata("2F", "APP_B", ["y" * 255] * 5)
|
||||
# Refused before RegisteredApplications.Add and SetXData.
|
||||
assert registered == [] and entity.stored[1][2] == "APP_A" and len(entity.stored[0]) == 63
|
||||
# Rewriting APP_A itself is judged without its old payload.
|
||||
result = await backend.entity_set_xdata("2F", "APP_A", ["z" * 255] * 60)
|
||||
assert result["ok"] is True and registered == ["APP_A"]
|
||||
# Removal always goes through.
|
||||
result = await backend.entity_set_xdata("2F", "APP_B", [])
|
||||
assert result["removed"] is True and result["bytes"] == 0
|
||||
|
||||
|
||||
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"):
|
||||
|
||||
Reference in New Issue
Block a user