fix: seven defects an adversarial audit found, and the gates that missed them
Release / test (full, windows-latest, 3.11) (push) Canceled after 0s
Release / test (pdf, ubuntu-latest, 3.11) (push) Canceled after 0s
Release / test (pdf, ubuntu-latest, 3.12) (push) Canceled after 0s
Release / build (push) Canceled after 0s
Release / publish-pypi (push) Canceled after 0s
Release / github-release (push) Canceled after 0s

Every one of these was reported ok/clean to the caller while being wrong.

Silently wrong numbers
- selection_filter(min_area) read `entity.dxf.get("area", 0.0)` for SOLID,
  TRACE and HATCH, none of which carry that attribute, so every hatch
  measured 0.0. A 1600 mm^2 hatch was excluded by min_area=1.0 while
  `filtered_by` asserted the filter had run, and COM (which reads ActiveX
  .Area) returned a different selection for the same call. It now answers
  from the measurement `entity_measure` uses, so the two cannot disagree.
- Every headless dimension rendered through ezdxf's bare `Standard` style:
  1.0 mm text with a comma decimal marker, against ISO 3098's 2.5 mm minimum
  and ISO 129's point -- while the document header itself said $DIMTXT 2.5.
  `ezdxf.new(setup=True)` is not the fix; measured, it renders a 100 mm
  dimension as "10000". The attributes are now stored explicitly.
- Saving writes `dimrnd = 0.0` onto the Standard style, and ezdxf applies
  `xround(value, dimrnd)` whenever the attribute is present at all --
  `xround(33.333, 0.0)` is 33. So on any reopened file, including one this
  server saved, every dimension was rounded to a whole unit before DIMDEC
  formatted it: a 12.75 mm feature dimensioned 13. AutoCAD means DIMRND 0 as
  *no* rounding; the sentinel is now discarded at every load site, and a real
  0.5 mm rounding is left alone.
- The DIM* header fold gave out-of-range values teeth they did not have when
  they died in the header: dim_decimals=-1 returned ok and then killed the
  next dimension with `ValueError: Format specifier missing precision` from
  inside ezdxf, and 40 printed forty digits of float noise onto the sheet.
  Ranges are now AutoCAD's own (DIMDEC 0-8, DIMZIN 0-15, lengths positive).

Security
- security.py recorded the policy as "sanitize_command and sanitize_lisp each
  have exactly ONE caller ... the policy is scoped to the CHANNEL". The
  premise was false. `_ensure_linetype_loaded` and `ComBackend.linetype_load`
  build `_-LINETYPE _LOAD {name} {file}` and hand it to SendCommand, which
  treats a newline and ';' as segment separators -- so a `linetype=` argument
  carrying either injected further AutoCAD commands, and the 36-verb denylist
  never saw that channel. Guarded with the DXF symbol-name rule (the
  injection characters fall out of it rather than being enumerated) plus
  validate_path for the file argument, which was also the one path-taking
  tool reaching the filesystem unchecked. The policy comment is corrected.

Gates that could not see
- drawing_critique returned zero issues on a quarantined document: all seven
  checks wrapped their backend call in a bare `except Exception`. CLAUDE.md
  rule 7 makes that call the gate an agent is told to trust. Only the
  quarantine now escapes; an ordinary broken check still degrades.
  `_check_dimension_legibility` also read `_doc` directly from the event loop
  thread, missing the gate and performing exactly the unsynchronised access
  the quarantine exists to prevent. `construction_clear` answered
  "deleted: 0" -- there was nothing to clear -- for the same reason.
- The iso128 focus checked layer lineweights only, so a sheet lettered at
  1.0 mm critiqued clean. It now reads the effective dimension height and
  separator, header-then-style, in the order the renderer resolves.
- check_doc_numbers.py derived five values it never checked, and those were
  the ones that had drifted. The collected-test count (README said 1274, the
  tree had 1369) and the A/B pass-rate and Fixed columns are now gated.
- benchmarks/README.md's v1.4.0 table was impossible on its face -- 21
  passing plus 5 fixed is 26, not 24 -- and contradicted the report it
  linked, because `ab-v1.4.0-vs-v1.5.0.json` was regenerated from a tree that
  already carried the dimension fixes and so never measured the v1.5.0 tag.
  Replaced with a real run: ab-v1.4.0-vs-v1.5.1.json, 21/26 -> 26/26.

Release gate
- release.yml ran one ubuntu/3.11 lane, so a Windows-only defect could reach
  PyPI with no Windows job having executed. That is not hypothetical: v1.5.0
  shipped `except pywintypes.com_error` raising NameError wherever pywin32 is
  absent. It now runs the same three lanes as ci.yml, Windows included.

1368 passed, 1 xfailed. ruff clean. Tool inventory and all 16 gated document
numbers match the tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Umut CAN
2026-08-06 23:07:50 +03:00
co-authored by Claude Opus 5
parent 6b9860e68e
commit ca1c79fd01
28 changed files with 3376 additions and 148 deletions
+75 -17
View File
@@ -19,6 +19,7 @@ from typing import Any
import config
from engineering.measure import is_self_intersecting, polygon_area_perimeter
from security import sanitize_macro_argument, sanitize_symbol_name, validate_path
from . import ocs
from .base import (
@@ -203,6 +204,9 @@ def _ensure_linetype_loaded(name: str) -> None:
"""
if not name or name.lower() in _BUILTIN_LINETYPES:
return
# Before any COM call: this name is about to be interpolated into a
# SendCommand macro, where a newline or ';' would start a second command.
name = sanitize_symbol_name(name, kind="linetype")
doc = _acad_doc()
existing = {doc.Linetypes.Item(i).Name.lower() for i in range(doc.Linetypes.Count)}
if name.lower() in existing:
@@ -293,6 +297,10 @@ def _apply_dim_tolerance(dim, tol_upper, tol_lower, tol_mode="none", text_overri
#: ActiveX object names that bound no area at all. Not a capability gap — no
#: engine can give a LINE an area — so these raise a plain error.
#: Solids carry a volume but no surface area over ActiveX. Verified on a live
#: AutoCAD 2026 (2026-08-06): AcDb3dSolid answers `.Volume` and raises on `.Area`.
_COM_NO_SURFACE_AREA_TYPES = frozenset({"AcDb3dSolid", "AcDbBody"})
_COM_NO_AREA_TYPES = frozenset(
{
"AcDbLine",
@@ -652,6 +660,14 @@ class ComBackend(AutoCADBackend):
looking identical to an expired deadline. ``future.cancelled()`` tells
them apart — ``wait_for`` cancels the future it is waiting on, an
ordinary failure completes it — mirroring ``EzdxfBackend._async``.
R32 deliberately stops short of the ezdxf document quarantine here. There
is no ``self._doc`` to replace: the drawing belongs to the operator, lives
in AutoCAD's process, and AutoCAD's own single-threaded command processor
arbitrates the abandoned call — not us. The only "reopen" available would
close the operator's drawing and discard unsaved work the server never
created. What COM is exposed to is idempotency, so the message says the
call may still land.
"""
if self._executor is None:
raise RuntimeError("ComBackend not connected. Call connect() first.")
@@ -691,8 +707,11 @@ class ComBackend(AutoCADBackend):
raise RuntimeError(
f"AutoCAD did not respond within {timeout:.0f}s. The application "
"may be showing a modal dialog or have an active command prompt "
"(press ESC in AutoCAD). Increase COM_CALL_TIMEOUT if a long "
"operation is expected."
"(press ESC in AutoCAD). The abandoned call may still complete in "
"AutoCAD after this error - the worker is blocked inside an "
"uncancellable COM call, not stopped - so verify the drawing before "
"retrying: a retry can double-apply the operation. Increase "
"COM_CALL_TIMEOUT if a long operation is expected."
) from e
except _COM_ERROR as e:
hr = e.args[0] if e.args else 0
@@ -2903,15 +2922,27 @@ class ComBackend(AutoCADBackend):
# dialog deadlocks SendCommand; (2) ISO/metric drawings need acadiso.lin,
# imperial drawings need acad.lin. We pick automatically from MEASUREMENT
# if the caller didn't specify a file.
#
# Both arguments end up inside a SendCommand macro, so both are checked
# before the COM thread is entered: the name against the DXF
# symbol-table rule, the path against the same allowlist every other
# file-taking tool uses. This was the one path-taking tool that reached
# the filesystem with neither.
safe_name = sanitize_symbol_name(name, kind="linetype")
safe_file = None
if file is not None:
resolved = validate_path(str(file))
safe_file = sanitize_macro_argument(str(resolved), kind="linetype file")
def _sync():
app = _acad_app()
doc = _acad_doc()
existing = {doc.Linetypes.Item(i).Name.lower() for i in range(doc.Linetypes.Count)}
if name.lower() in existing:
return {"ok": True, "name": name, "already_loaded": True}
if safe_name.lower() in existing:
return {"ok": True, "name": safe_name, "already_loaded": True}
if file is None:
if safe_file is None:
try:
measurement = int(app.GetVariable("MEASUREMENT"))
except Exception as exc:
@@ -2919,7 +2950,7 @@ class ComBackend(AutoCADBackend):
measurement = 1
lin_file = "acadiso.lin" if measurement == 1 else "acad.lin"
else:
lin_file = file
lin_file = safe_file
try:
old_filedia = int(app.GetVariable("FILEDIA"))
@@ -2930,7 +2961,7 @@ class ComBackend(AutoCADBackend):
app.SetVariable("FILEDIA", 0)
# Double newline: first ends the file name, second exits
# -LINETYPE's [?/Create/Load/Set] option menu.
doc.SendCommand(f"_-LINETYPE _LOAD {name} {lin_file}\n\n")
doc.SendCommand(f"_-LINETYPE _LOAD {safe_name} {lin_file}\n\n")
finally:
try:
app.SetVariable("FILEDIA", old_filedia)
@@ -2938,13 +2969,13 @@ class ComBackend(AutoCADBackend):
log.debug("FILEDIA restore failed: %s", exc)
after = {doc.Linetypes.Item(i).Name.lower() for i in range(doc.Linetypes.Count)}
if name.lower() not in after:
if safe_name.lower() not in after:
raise RuntimeError(
f"Failed to load linetype '{name}' from '{lin_file}'. "
f"Failed to load linetype '{safe_name}' from '{lin_file}'. "
"Check the linetype name spelling and that the .lin file is "
"on AutoCAD's support path."
)
return {"ok": True, "name": name, "file": lin_file}
return {"ok": True, "name": safe_name, "file": lin_file}
return await self._run(_sync)
@@ -3189,10 +3220,16 @@ class ComBackend(AutoCADBackend):
area = float(ent.Area)
except Exception as exc:
log.debug("ActiveX .Area unavailable for %s: %s", obj_name, exc)
try:
perimeter = float(ent.Length)
except Exception as exc:
log.debug("ActiveX .Length unavailable for %s: %s", obj_name, exc)
# Measured on a live AutoCAD 2026 (2026-08-06): the perimeter lives
# under a different member per type, and on some types nowhere.
# AcDbPolyline -> .Length, AcDbCircle -> .Circumference,
# AcDbRegion -> .Perimeter, AcDbHatch -> none of them.
for member in ("Length", "Circumference", "Perimeter"):
try:
perimeter = float(getattr(ent, member))
break
except Exception as exc:
log.debug("ActiveX .%s unavailable for %s: %s", member, obj_name, exc)
method = "activex_area"
closed = True
@@ -3215,6 +3252,17 @@ class ComBackend(AutoCADBackend):
for i in range(0, len(coords) - 1, 2)
]
if not vertices:
if obj_name in _COM_NO_SURFACE_AREA_TYPES:
# Verified on a live AutoCAD 2026 (2026-08-06): AcDb3dSolid
# exposes `.Volume` and no `.Area` whatsoever. The generic
# message would send the caller hunting for geometry that
# was never the problem.
raise RuntimeError(
f"entity_measure: handle {handle} is {obj_name}, and ActiveX "
"exposes no surface-area member for it — only .Volume. "
"AutoCAD's own MASSPROP reports the surface area; this server "
"does not wrap it."
)
raise RuntimeError(
f"entity_measure: AutoCAD reported neither an area nor usable "
f"vertices for handle {handle} ({obj_name})."
@@ -3436,12 +3484,22 @@ class ComBackend(AutoCADBackend):
def _sync():
doc = _acad_doc()
# R32: record the mark from inside the worker, immediately before
# setting it. `_transaction_active = True` used to run only after
# `_run` returned, so a timed-out begin left StartUndoMark open in
# AutoCAD while the server recorded no transaction, and the next
# transaction_begin nested a second mark nobody could see — the
# exact inverse of the R16 fix transaction_commit already carries.
# The abandoned STA thread cannot be cancelled, so the flag has to
# be written where the call happens; and it is written *before*
# StartUndoMark because a call that hangs inside it may still land.
# A `_acad_doc()` that fails leaves the flag alone, which is right:
# nothing reached AutoCAD.
self._transaction_active = True
doc.StartUndoMark()
return {"ok": True, "message": "Transaction begun (AutoCAD undo mark set)"}
result = await self._run(_sync)
self._transaction_active = True
return result
return await self._run(_sync)
async def transaction_commit(self) -> dict:
def _sync():