#1584 stripped the prefixes before dispatch, which turned the ribbon's own
internal names (_PCCROPPOLY, _XREFEDIT, _PDFULSNAP, ...) into unknown
commands. A name is now tried as written and retried without its prefixes
only when no family handles it; the Start-tab gate judges the bare name.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Real BT/Tj text objects ride alongside the vector outlines (Tr 3,
zero visual change): runs preserved at layout time, TTF styles embedded
as allsorts subsets with ToUnicode, width-matched Tm, clip-culled.
WireModel literals moved to ..Default::default() tails.
Codec: PRs #71-#74 merged (MPOLYGON layout, multiline ATTDEF/ATTRIB DXF,
DXF MATERIAL/ACSH matrix, restorable SAT/SAB + history roots) and issues
#55 #64-#69 fixed. EntityType now boxes Surface, Extended, Table,
MultiLeader and Helix (2344 B -> 848 B per entity): construction sites
wrap in Box, the entity traits forward through Box, and the python
plugin generator converts with into(). Kernel: PR #70 (cuboid ring
winding).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
An XLINE or RAY projects to saturated i32 corners; their difference
overflowed and panicked the debug build on every capture.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
- Pillar 1 (Runtime Auto-Sync):
* Add sync_agent_tool_schemas() in src/mcp.rs to atomically export live tool schemas & instructions to ~/.gemini/antigravity/mcp/opencadstudio/
* Automatically invoke sync on MCP startup (mcp::run()) and GUI automation startup (transport::listen())
* Add --sync-mcp-schemas CLI flag in src/cli.rs and src/main.rs
- Pillar 2 (Development Hook):
* Strengthen export_agent_tool_schemas test in src/mcp.rs to assert all schema files and instructions.md exist and are in sync on every cargo test run
- Pillar 3 (In-Band Dynamic Introspection):
* Add 'tools' to READ_OPS in src/mcp.rs and query match in src/app/control/mod.rs
* Enable agents to dynamically query live tool definitions and instructions via ocs_read (op: 'tools') over MCP or raw socket
- Pillar 4 (Non-Blocking Validation):
* Set additionalProperties: true on ocs_capture schema in src/mcp.rs to prevent rigid schema rejection of emerging options
A raster reference that cannot be resolved is the only framed entity whose
to_render emits a RenderObject::Text: the frame and its diagonals are lines,
but the saved file path becomes a real glyph run in the SDF text buffer.
gather_text_verts reuses the whole previous vertex buffer when
text_unchanged(epoch) says no text-bearing entity moved, and that asks
entity_affects_text_cache -- which enumerated Text, MText, Dimension,
MultiLeader, Leader, Table, Tolerance, attributes and Insert, but not
RasterImage. So an erase never raised CACHE_CATEGORY_TEXT, the stale buffer
was taken as valid, and the path kept drawing over empty space. The reuse
branch then re-stamps that buffer with the current epoch, which froze it
until a full rebuild. CACHE_CATEGORY_IMAGE did not cover the gap: it is set
from images.contains_key(handle), and a missing reference has no ImageModel
at all.
The same gap broke three more flows from the Added/Modified side: ATTACH of
a missing reference showed no text, MOVE and grip-drag left the text behind
while the frame followed, and UNDO after an erase did not bring it back.
entity_affects_text_cache now counts a RasterImage whose reference does not
resolve, reusing the converter's memoised resolve_image probe so the two
cannot disagree. Kept a probe rather than a blanket type match so a healthy
image does not rebuild every glyph in the drawing on each mouse move of a
grip-drag.
Four regression tests, each confirmed to fail without the fix. The main one
is end-to-end on the buffer the GPU draws: before, the erased placeholder
left 300 glyph vertices on screen (50 chars x 6).
Also: to_render ran the same resolve probe twice; now once, shared by the
outline and placeholder branches. Moved text_unchanged's doc comment off
draw_ranks_stable, where it had drifted, and recorded the rule that every
entity able to emit a GlyphRun must be declared there.
Co-Authored-By: Claude Opus 5 <[email protected]>
- Add \PyramidLevelInfo\, \PyramidManifest\, \compute_pyramid_manifest\, \compute_tile_bounds\, and \point_to_tile\ in \ision.rs\
- Expose DeepZoom pyramid manifest resource at \cad://session/{session_id}/pyramid/manifest.json\
- Support on-demand and cached pyramidal tile resources at \cad://session/{session_id}/tile/{level}/{x}/{y}.png\ and \.json\
- Add \ ile: { level, x, y }\ and \pyramid_manifest: boolean\ to \ocs_capture\ with regional camera framing
- Enable LLM agents to inspect dense CAD drawings via multiscale resolution levels without exceeding context/tile token budgets
- Add unit tests for pyramid tiling math, manifest generation, resource listing/reading, and structured metadata formatting
- Standardize spatial metadata under \_spatial\ with \CAD_WCS\ CRS, document units, forward/inverse affine matrices (\pixel_to_world_matrix\, \world_to_pixel_matrix\), camera projection, and world bounding bounds
- Implement visual differential engine (\compute_visual_diff\) detecting pixel-level changes between viewport frames
- Map dirty pixel bounding box directly to CAD world coordinates (\dirty_world_bounds\) using the spatial transform
- Add \diff\, \diff_mode\ ('highlight' | 'crop' | 'metadata_only'), and \
eset_diff_baseline\ parameters to \ocs_capture\
- Support high-efficiency dirty region cropping to drastically minimize vision token and bandwidth consumption
- Add unit tests for spatial projection, diff computation, tool schema, and structured metadata formatting
- Expose MCP resources/list and resources/read for active viewports, drawing state, and cached snapshots
- Introduce ResourceStore with SHA-256 content-addressed URIs (cad://session/{session_id}/snapshot/{hash}.png)
- Add delivery parameter (inline, resource, both) to ocs_capture tool schema
- Return standard MCP EmbeddedResource content block when delivery: 'resource' to avoid base64 context bloat
- Add comprehensive MCP unit tests for resource listing, blob reading, and tool result delivery modes
- Add Set-of-Marks (SoM) visual grounding (src/app/control/vision.rs):
* Render high-contrast numbered badges on entity visual centroids in viewport captures
* Project 3D entity bounds to 2D image coordinates with priority ranking (selected/size)
* Return bidirectional grounding manifest (annotations / visible_entities) in capture and ocs_capture payloads mapping each badge to entity handle, layer, type, and world center
- Add regional viewport framing (src/app/control/mod.rs, src/mcp.rs):
* Support view: region and bounds: [min_x, min_y, max_x, max_y] in capture and ocs_capture
* Enable programmatic zoom and high-resolution inspection of specific rooms or local geometry
- Add 2-point raster image calibration (src/app/control/actions.rs, src/mcp_ops.rs):
* Extend embed_image with optional source_points (pixel coords) and target_points (CAD coords)
* Automatically calculate scale, world dimensions, and insertion origin at
* Add unit test embed_image_calibrates_from_source_and_target_points
Point clouds: drawn from their scans, attached, cropped and stylized.
The fork's codec patch is resolved to the upstream pins: the point cloud
records landed in opencadcodec 5ef9376, carried through opencadkernel
68eeda1 and opencadgraph e204966.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Every face of the #1563 folding tool draws (balls bounded only by slits,
periodic spline edges stored reversed), and its bearings, hubs, washers
and shafts union and subtract: coaxial torus/sphere/cone intersections,
ray casts through tori and whole-surface faces, annulus imprints.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
It asserted a wall-clock race between two loops, which the parallel
suite's load lost now and then. The cache's behaviour stays pinned by
the entry-count tests.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Block children of one style are batched into a single wire, and a
plinegen wire carries its dash distance across the joins. Every LINE
wire is plinegen, so each line in a block began mid-dash wherever the
previous one had stopped. PLINEGEN governs a wire's own breaks; a member
without any now enters its batch as non-plinegen.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Once edited, a dimension is drawn live instead of from its *D block;
on the #898 drawing that differed in three places:
- Oblique / architectural-tick strokes were DIMASZ/2 to each side, the
block's are DIMASZ/sqrt(2) (its unit stroke runs corner to corner).
- Arrowheads and ticks shared the dimension line's wire, so a dashed
dim line broke its ticks into stubs. They get their own solid wire
(and explode as lines as before).
- A diameter's user-positioned text was lifted by the DIMTAD offset even
when the stored point already was the text's middle, as a file saved
elsewhere writes it; it is lifted only when it lies on the leader
(#1323's case).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Only DIMTSZ > 0 counted as ticks, so a style drawing its strokes with
the _OBLIQUE / _ARCHTICK arrow block gave each stroke a full arrow's
room: an 18-long dimension with 4.8 arrows moved its strokes outside
and ran the dim line past the extension lines once it was drawn live
(after any edit, #898). Tick arrow blocks now count as ticks for fit
and DIMDLE, like the file's own baked block shows them.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A dimension loaded from a file draws from its baked *D block, whose
lines are ByBlock and so take the dimension's linetype (ByLayer: the
layer's). Any edit - text colour, MATCHPROP, a grip - drops that block
and the dimension draws live, where a style with no DIMLTYPE/DIMLTEX1/2
drew solid: an edited dimension on a dashed layer turned solid (#898).
None or ByBlock now takes the dimension's own linetype, ByLayer its
layer's.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
--http (headless and GUI-hosted) and --serve --port answered anything
that reached the loopback port, and the REST responses allowed every
web origin. Loopback is shared by every process and user on the
machine, so the port proved nothing.
- Every REST request now needs Authorization: Bearer <token>, and every
--serve --port request line a "token" field; otherwise 401 /
unauthorized (the socket is closed).
- The token is OCS_API_TOKEN when the launcher sets it, else 32 random
bytes printed on stderr at startup; compared in constant time.
- No CORS headers are sent, so a web page cannot read an answer.
- stdio --serve and --mcp are unchanged (private to their parent / the
GUI's own token).
GHSA-c27p-xfx2-9r5x
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Bug:
SETVAR LUPREC and SETVAR AUPREC stored any parseable i16 verbatim, and
the header value flows through UnitContext into the per-call
formatters, which applied only a lower bound (.max(0)) with no upper
bound: format_length and format_angle rendered {:.*} with the raw
precision. SETVAR LUPREC 30000 therefore made format_length emit
30003-character strings on every properties and annotation formatting
call - a ~30 KB allocation per formatted value - while format_area in
the same file had long since capped itself with .min(15) and the units
dialog only ever offers 0-8 places. Negative values were also stored
verbatim.
Reproduce:
1. Command line: SETVAR LUPREC 30000 (or AUPREC 30000), then select
something so the properties palette formats lengths/angles.
2. Before: the header stores 30000 and each formatted value is ~30 KB
(the test measured 30003 chars for one length); -3 is stored as-is.
3. After: the setter stores 8 (0-8 range, echoed truthfully), -3
stores 0, 5 stores 5 - and even a file carrying LUPREC 30000 in its
header formats short output, because the formatters cap themselves.
Fix:
Two layers. The formatters get the cap format_area already uses:
format_signed_length and format_signed_angle now clamp with
.max(0).min(15), so every source - SETVAR, the units dialog, and
values carried in DWG/DXF files - produces bounded output on the hot
paths (15 matches the point past which f64 decimal output is
meaningless, which is why format_area was written that way). The
setters store x.clamp(0, 8) with the clamped value echoed back, the
same stay-inside-the-app's-own-UI-range rule SETVAR ISOLINES follows
against the Options slider. Architectural and fractional formats
ignore precision (fixed 1/64-inch resolution) and are unaffected.
Regression tests mirror the existing patterns: one forces hostile
precision through the formatters while saving and restoring the
thread-local unit context, one drives SETVAR through the command line
and checks the stored header values.
Bug:
The typed ensure_plugin_state helper and the object-safe trait method
it wraps turned two recoverable conditions into process deaths. On the
in-process host, the helper downcast the stored state with
.expect("plugin state type mismatch for plugin_id"), so registering
state under one plugin_id as one type and later asking for another
(e.g. a reloaded plugin version changes the type while the per-tab
state map survives) panicked the host. On out-of-process hosts
(ipc/client.rs, ipc/v4/client.rs) and on NullHost, the trait method
itself was implemented as a bare panic!("ensure_plugin_state is not
supported for out-of-process plugins..."), so any out-of-process
plugin calling the documented helper crashed its own runner process -
even though the sibling plugin_state_any and plugin_state_any_mut
methods on those same implementations already degrade to None
gracefully. The helper also used .expect("init called once") on the
initialiser closure.
Reproduce:
1. Out-of-process: a plugin whose runner calls ensure_plugin_state
(&mut host, "my.id", ...).
2. Type mismatch: in an in-process plugin, ensure state under one id
as u32, then ensure a different type under the same id.
3. Before: step 1 kills the plugin runner at the trait panic; step 2
kills the host at the downcast expect. After: both get None, keep
running, and existing state is untouched.
Fix:
Give the trait method a failure channel: ensure_plugin_state_any now
returns Option<&mut (dyn Any + Send + Sync)>, mirroring the sibling
plugin_state_any_mut on the same implementations, and the typed helper
returns Option<&mut T> (any?.downcast_mut). The panicking
implementations (ipc v3, ipc v4, NullHost, and the three never-called
test dummies) return None; the in-process app host wraps its real
storage in Some with unchanged insert-or-return semantics. The init
closure is now guarded by take() with a unit-Any fallback, so even an
implementation invoking it twice fails the typed downcast instead of
panicking. Object safety, argument types and vtable slot are
unchanged; external plugin sources get a compile error at the helper
and adopt Option handling on rebuild. Regression tests use catch_unwind
so they compile against both signatures: one per panic site (out-of-
process, NullHost, type mismatch), each additionally asserting the
None result and that pre-existing state survived.
Bug:
The MCP adapter advertises "ttlMs": 3_600_000 on every task it hands
out and answers "Unknown or expired taskId" for missing ids, but the
task store itself had no expiry logic: insert was a bare push_back with
no cap, no TTL and no cleanup anywhere, and get_mut the only other
method. Every tools/call that returns accepted/running stores a task
including a clone of the full request arguments JSON, so a client
looping on long-running calls grows the store without bound and the MCP
server process slowly leaks until it OOMs. The advertised number was
also hardcoded in two separate response sites with nothing behind it.
Reproduce:
1. Start OpenCADStudio --mcp, connect a client with the tasks
capability, and issue tools/call calls that return accepted
repeatedly - each one stores a task.
2. Before: the tasks queue grows monotonically for the process
lifetime; a task inserted an hour ago is still handed back by
tasks/get even though the server promised a one-hour ttl.
3. After: tasks older than the advertised ttl are gone, tasks/get on
them returns the already-existing "Unknown or expired taskId"
error, and the store holds at most one hour of task traffic.
Fix:
Enforce the value the server already advertises. Each McpTask is now
wrapped in a StoredTask with an expires_at deadline of insertion time
plus TASK_TTL_MS (3_600_000), a single constant that also feeds both
"ttlMs" response literals so the promise and the eviction rule cannot
drift apart. insert and get_mut both evict expired entries before
looking (retain on a monotonic Instant), so writes reclaim stale tasks
and reads stop resolving expired ids - the store only ever holds tasks
created within the last hour. The ttl runs from insertion rather than
being refreshed by polls: poll_task rewrites last_updated_at on every
working poll, so a sliding window would keep a stuck task alive forever
and reintroduce the leak. The expiration test injects the clock through
insert_at/get_mut_at, so it checks fresh, expired and evict-on-insert
behaviour without sleeping.
Bug:
The keep-recent-files footer on the Start page builds its [-] [count]
[+] buttons eagerly while constructing the widget, and the [+] step was
written as plain shown + STEP while the [-] button next to it already
used saturating_sub. shown comes from the input box, whose handler
keeps every ASCII digit with no length cap - so typing
18446744073709551615 (2^64-1) parses to usize::MAX and the addition
overflowed during view(), before the button was ever clicked. Debug
builds panic with "attempt to add with overflow"; release builds (no
overflow checks) wrap to a small number and request a nonsense limit.
The clamp in set_recent_limit runs only after the value is computed.
Reproduce:
1. Start page -> "keep recent files" box -> type
18446744073709551615.
2. Before: the next frame panics at widget construction (on render, no
click needed); release builds wrap the step silently.
3. After: no panic; pressing Enter or [+] applies the clamped limit and
the box resyncs to it.
Fix:
Mirror the [-] button: shown.saturating_add(STEP). At usize::MAX the
message carries usize::MAX, which the handler's existing
clamp-and-resync treats exactly like any other over-max entry, so both
step buttons are now saturating and the release-build wrap is closed as
well. on_submit has no arithmetic and needs no change. The regression
test calls start_page_content directly with the hostile string - it
fails at the same line instead of requiring a GUI session.
Bug:
$ISOLINES (header i16) and Surface.u/v_isolines (file-controlled i16)
were only ever floored at 0, never capped, and were handed to the
tessellation kernel as the per-face isolate count - which draws
0..count isolines per face with no ceiling of its own. SETVAR ISOLINES
32767 (or a DWG carrying that header, or a Surface with u_isolines =
32767) turned every rebuild and every per-cursor preview of
PRESSPULL/REVOLVE/SWEEP/LOFT into a multi-million-vertex computation:
measured 4,259,966 edge vertices for one 12-unit cylinder preview.
Reproduce:
1. SETVAR ISOLINES 32767 (or open a DWG whose $ISOLINES is large, or
edit a Surface whose u_isolines is 32767).
2. Draw a circle and start REVOLVE (or PRESSPULL/SWEEP/LOFT), or shade
any 3D solid.
3. Before: the app allocates without bound - abort on memory failure or
a permanent freeze; previews recompute on every mouse move.
4. After: previews stay interactive; a hostile header renders exactly
like the largest value the Options slider can request.
Fix:
Cap each value at the ceiling this product already agrees on, where the
type changes from i16 to the kernel count: header $ISOLINES <= 64 (the
Options slider's own "Isolines per surface" range), Surface
u/v_isolines <= 200 (the plugin editor's writable range). One helper
(clamp_header_isolines) replaces all 11 copies of .max(0) as usize
across the entity/scene/control/display feed sites,
surface_isoline_counts clamps both axes, and SETVAR ISOLINES stores
x.clamp(0, 64) so the echo, slider and re-saved files agree with what
renders; file-sourced headers keep their raw stored value and are
clamped at consumption. Clamping is density-only: geometry, bounds and
saves are untouched. Three regression tests cover hostile surface
counts, the SETVAR entry, and a file-sourced header measured through
display tessellation (hostile renders like the slider ceiling, negative
renders like zero).
Bug:
--serve in socket mode serves one client at a time: the accept loop
blocks in the per-client line read before it reaches the next
connection, and this path never called set_read_timeout - unlike
rest.rs, http_bridge.rs and control::transport, which all use 15
seconds. A client that connects and sends nothing held the read open
forever, wedging the port: every later client hung waiting for the
ready banner (the banner is written before the read loop, so the server
looked healthy while it was stuck).
Reproduce:
1. Start OpenCADStudio --serve --port 8765.
2. From any shell, connect and send nothing:
python -c "import socket,time;socket.create_connection(('127.0.0.1',8765));time.sleep(3600)"
3. From a second shell, connect and wait for the banner.
4. Before: the second client hangs forever; the port never serves
another connection.
5. After: the idle first client is disconnected 15 seconds after its
last read, and the second client receives the ready banner.
Fix:
The one timeout this transport was missing: SERVE_IDLE_TIMEOUT = 15 s
(same value as the other transports), set on each accepted socket via
serve_socket_with_idle - a seam that also lets the regression test run
with 300 ms. A timed-out read surfaces as an error from lines_capped,
which the loop's existing let Ok(line) ... else break already turns
into "drop this client and accept the next one"; a partially read line
is discarded, which is correct because it was never dispatched. The
timeout only runs while a read is pending, so long server-side
operations do not spend the idle budget, and the document session
survives the reconnect. Regression test: an idle client must see EOF
and the following client must still get its banner.
Bug:
evaluate_table_formula walked for row in r1.min(r2)..=r1.max(r2) across
columns with no bound, and table_cell_reference parses any usize -
=SUM(A1:A18446744073709551615) names about 1.8e19 rows. table.cell()
returns None outside the table, so results were correct; the loop just
never finished. The walk runs during tessellation, i.e. on the render
path - the app freezes permanently and cannot even be closed.
Reproduce:
1. Insert a table, double-click a cell.
2. Type =SUM(A1:A18446744073709551615) (or
=SUM(A1:A1000000000) - a minutes-long freeze) and press Enter.
3. Before: the next repaint hangs forever.
4. After: the value appears immediately.
Fix:
The walk is clamped to the table's own rows/columns: row_end =
r1.max(r2).min(rows - 1) (same for columns). Every skipped cell was a
None, so the collected values - and every function's result
(SUM/AVERAGE/COUNT/MIN/MAX) - are identical; the loop now runs at most
rows x columns. start > end after clamping is an empty range, so
out-of-table references simply collect nothing (as before). The
regression test also asserts total evaluation time under 5 seconds,
turning any future un-clamped loop into a fast failure.
Bug:
The properties panel's Rows/Columns fields feed apply_geom_prop's grow
loops: number.round() as usize with no upper bound - and parse_f64
accepts up to 1e15. while rows.len() < requested { add_row() } pushes
rows until the allocator aborts. The magnitude validation in this path
governs geometry, not iteration counts.
Reproduce:
1. Insert a table, select it, open Properties -> Rows (or Columns).
2. Enter 1000000000000000 and commit.
3. Before: grows until "memory allocation failed".
4. After: the table grows to at most 1,000,000 cells (e.g. 100,000 rows
x 10 columns) and stays responsive; a legitimate shrink to a smaller
number still works exactly.
Fix:
One product budget, MAX_TABLE_CELLS = 1_000_000, applied per dimension:
grow_to = requested.min(budget / other_dimension) clamps the grow loops
only - the shrink loops keep the raw requested count, so shrinks stay
exact and the two loops cannot fight (grow_to <= requested by
construction). A table loaded over the budget is preserved: growth is
skipped and only an explicit smaller request shrinks it. The product,
not per-dimension caps, is what bounds memory regardless of which field
is set first. Tests cover overshoot at cap+10 and the 1e15 input for
both fields.
Bug:
The DGN compound-file reader walked FAT chains with a fixed
1,000,000-iteration guard - a step budget, not a byte budget. A
self-referential FAT entry (fat[s] = s) burns the whole budget reading
the same sector: 4.1 GB per call at 4 KiB sectors. Directory, mini
stream and miniFAT chains are alive simultaneously (about 12 GB peak) ->
allocator abort while merely selecting the file in the attach dialog.
Separately, models() inflated every model directory's streams (512 MB
each) with no total, and directory entries can all point at one shared
payload - 1.2 GB retained from a 4.3 MB file.
Reproduce:
1. Craft a ~1.5 KB CFB (sector shift 9) whose directory start sector's
FAT entry points at itself (zeroed FAT sector).
2. ATTACH and pick the file (any listing path parses the CFB).
3. Before: the directory chain grows to 4,000,000 entries from a
1536-byte file, then abort.
4. After: every chain is bounded by the file's own sector count -
instant, memory at most the file size.
5. For the models path: a file with 300 model dirs sharing a 4 MB
payload inflated to 1,258,291,200 bytes before; now it stops at the
file-scaled budget (max(file x 64, 256 MiB)).
Fix:
chain bounds itself by file bytes / sector size: a chain can only read
in-file sectors, and after that many steps it must have repeated one -
the walk is deterministic, so a repeat is a cycle that yields nothing
new. That is the strongest bound the format allows and it never
truncates a legitimate chain (each sector is read at most once). The
mini-stream loop uses the same argument over mini.len() / 64. models()
checks a file-scaled total budget before inflating each dir; later dirs
are dropped (earlier ones still load, name/table discovery degrades
gracefully). Tests: the hostile 1.5 KB file keeps all bytes <= file
size; the 300-model file stays within budget.
Bug:
defn_inline_point_cost_recursive and defn_metrics_recursive guard against
cycles (a visited set) but not against depth: a long legal chain of
block definitions (A inserts B inserts C, ...) recurses once per level
and overflows the stack. A document with about 20k-deep nesting kills
the process the first time block metrics are computed (opening the
drawing / measuring), even though there is no cycle.
Reproduce:
1. Build a drawing whose block definitions nest deeply - a chain of
20,000 INSERTs, each into the next.
2. Open it (metrics are computed on access).
3. Before: STATUS_STACK_OVERFLOW (0xc00000fd) - process death.
4. After: traversal stops at 33 levels (depth > 32), matching
expand_defn's existing limit exactly, and metrics fall back to the
default for the truncated branch.
Fix:
Both entry points now bail when visited.len() > MAX_NESTING_DEPTH (32)
alongside the existing cycle check - the same depth + 1 / depth > 32
convention expand_defn has used (33 levels), so the two walks agree on
what "too deep" means; the cycle guard stays. The depth test runs first
(O(1)) so deep-but-cyclic graphs still short-circuit. Tests: two
deep-nesting cases that overflowed before now complete.
Bug:
EMF polyline/polygon records carry a ring count as a raw u32.
pts16/pts32 did Vec::with_capacity(count) on it directly - a crafted
4294967295 pre-allocates about 4 billion slots and the allocator aborts.
The existing total > 400_000 guard only checked the sum of ring counts,
never an individual ring's vector.
Reproduce:
1. Craft an EMF whose POLYPOLYLINE/POLYGON count is 0xFFFFFFFF.
2. Attach/import it.
3. Before: "pre-allocated 4294967295 slots" then memory allocation
failed (multi-GB pre-allocation).
4. After: the vector is sized from the bytes actually present in the
record; the parse consumes what the record holds.
Fix:
pts16/pts32 size their capacity as count.min(available_bytes / point_size)
- the record's own remaining length is the ceiling, so allocation is
O(record). The iteration logic is untouched: it stops when the bytes run
out, so output for valid records is byte-identical (the guard is a pure
allocation bound). Tests: the u32::MAX ring no longer pre-allocates;
valid single- and multi-ring records still parse.
Bug:
arc_cubics turned a raw sweep angle from the DGN file straight into a
piece count - ceil(sweep ...) as usize fed with_capacity, and an extreme
sweep (huge, or +/-inf) produced a count that overflows usize
allocation, causing a capacity overflow panic while attaching the file.
Legal DGN arcs top out near 104 radians.
Reproduce:
1. Craft a DGN with an arc element whose sweep angle is enormous (1e18+,
+/-inf).
2. ATTACH it.
3. Before: "capacity overflow" at raw_vec during model build.
4. After: the arc clamps to +/-4096 quarter-turns (sign preserved, NaN
passes through, +/-inf handled) and renders as many cubics as fit.
Fix:
The clamp happens in the one funnel (arc_cubics) before ceil: sweep is
limited to +/-4096 x 90 degrees, far above the legal 104 rad but orders
below what overflows the count. The sign of the sweep is preserved so
arcs keep their direction, and NaN passes through to the existing
empty-arc handling. Tests cover three sweep magnitudes (including
+/-inf) building without overflow.
Bug:
The PDF underlay renderer sized its pixmap from page size x requested DPI
with no ceiling: a large page at a high raster DPI (display or print)
computes a multi-gigabyte image - testing reached a 17 GB pixmap and a
543-second render attempt before the allocator gave up.
Reproduce:
1. Attach a large-page PDF; set a high raster DPI (print above 150 DPI,
or display with DPI raised).
2. Before: minutes of rendering and a multi-GB allocation, then abort.
3. After: display rasters are clamped so the longest side is at most
3072 (like DWF/DGN, the effective DPI is stored); print rasters over
268,435,456 pixels are refused with an error instead of rendered.
Fix:
Display (at or below 150 DPI): the scale is clamped to
RASTER_SIDE/longest before the pixmap is allocated, and the DPI actually
used is stored for downstream math. Print (above 150 DPI): refused when
width x height exceeds MAX_PRINT_RASTER_PIXELS (256 Mpx) or is
non-finite - print DPI is never silently rescaled because the
sheet-mm-from-exact-DPI conversion must stay exact; the user gets an
error instead of a hang. A regression test covers the hostile page x DPI
case (about 1 second, previously 543 seconds and 17 GB).
Bug:
Loading a DWFx package recursed twice on untrusted input:
1. roxmltree's tokenizer (parse_element <-> parse_content) recurses once
per element nesting level (present in versions 0.20 and 0.21) - a
page or descriptor nested about 100k deep overflows the stack inside
Document::parse, before any application code runs;
2. the XPS walk recursed once per nested Canvas with no limit - about
10k levels overflowed the stack.
Either one aborts the process during ATTACH.
Reproduce:
1. Build a DWFx (zip) whose .fpage / descriptor.xml nests elements
(e.g. Canvas) about 100,000 deep.
2. ATTACH it.
3. Before: STATUS_STACK_OVERFLOW (0xc00000fd) - process death; a test
calling only roxmltree::Document::parse on the same bytes reproduces
it, proving the dependency's tokenizer dies first.
4. After: the file is rejected before parse (a log::warn records "XML
nests deeper than 256 levels ... document rejected"), or - for Canvas
nesting between 65 and 256 - the page renders with the too-deep
subtree skipped while siblings and the rest of the page stay.
Fix:
A linear depth scan (xml_depth_within, skipping comments, CDATA, PIs,
doctype subsets and quoted attribute values so their angle brackets
never count; it may over-reject malformed markup but must never
under-count) gates all three Document::parse sites at 256 levels - real
pages nest single digits - and walk caps Canvas nesting at 64 (continue
skips that subtree, siblings unaffected), matching the compound-file
walker's cap. Rejections are logged so a rejected depth is
distinguishable from a corrupt parse.
Bug:
decode_packed (RLE-packed DIB decoding) guarded the header with a
12-byte check but then read fixed fields at offsets 14-16, 16-20 and
32-36. Any header shorter than 36 bytes that took the non-core path
panicked with an out-of-bounds slice - every input in that range.
Reproduce:
1. Open a BMP whose DIB header takes the non-core path but is shorter
than 36 bytes (a 12-35 byte header claiming packed data).
2. Before: out-of-bounds panic at the fixed-field reads.
3. After: the header is rejected and the image skipped; valid headers
(including a 1x1 DIB) still decode.
Fix:
The non-core branch requires len >= 36 before any of its fixed reads -
every input under 36 bytes panicked before the fix, so no non-panicking
behaviour changes. Valid packed DIBs (tests include a 1x1 image) decode
unchanged.
Bug:
The EMF record reader accepts nSize = 28 (header-only) so legal empty
polylines load, but the sub-arm handling those iTypes also reads a u32
count at offset 28 for records that are not empty - rec.get(28..32) on a
28-byte record is out of bounds, panicking while importing the file.
Reproduce:
1. Craft an EMF with an EMR_POLYLINE (or related iType) record whose
nSize is exactly 28.
2. Attach/import it.
3. Before: out-of-bounds panic reading at offset 28.
4. After: the undersized record is skipped; a full 36-byte record still
renders.
Fix:
The sub-arm that reads at offset 28 now requires rec.len() >= 32 (its
first read), while the shared arm's guard stays at 28 so legal
header-only empty polylines keep loading. Undersized records are skipped
the way other malformed records already are. Tests: iType 7 and 91
records of exactly 28 bytes are skipped, a 36-byte record still renders.
Bug:
Legacy CTB files carry color1 = #xx... colour values parsed with
fixed-offset byte slices (&value[2..4], ...) guarded only by a length
check. Multi-byte UTF-8 in the value (a value with a non-ASCII character
inside the hex section) passes the byte-length check but slices
mid-character, panicking while the style loads.
Reproduce:
1. Craft a legacy CTB whose color1 value passes the length check but
contains a multi-byte character.
2. Open the Plot Style panel or plot with that style.
3. Before: panic at the char-boundary slice.
4. After: invalid pairs parse as 0 (black) and the style loads.
Fix:
Fixed-offset byte slices are replaced with str::get over the same
offsets - None (a non-boundary or short position) keeps the existing
unwrap_or(0) semantics, so every previously valid value parses
identically and previously panicking values now default to zero. Tests:
the crafted multi-byte value loads as black, valid values unchanged.
Bug:
Custom lineweight table entries in a CTB are keyed by a number parsed
from the file and stored without an upper bound, but the only reader
(resolve_lineweight) indexes through entry.lineweight: u8. A key beyond
u8::MAX (e.g. 18446744073709551615 in a crafted file) caused an
out-of-bounds-style failure or a multi-GB table allocation when the
style was applied.
Reproduce:
1. Craft a CTB whose custom lineweight table uses a huge key
(usize::MAX).
2. Apply the style or open plot settings.
3. Before: overflow panic (or a giant allocation).
4. After: the key is out of range, the line is ignored like any other
malformed CTB line, and the style loads.
Fix:
Keys are bounded to u8::MAX when parsed - exactly the range the
u8-typed reader can ever use. In-range keys behave as before; keys 256
and above are dropped. Tests: a usize::MAX key does not overflow, an
in-range key is kept and applied, 256 is ignored.
Bug:
CTB plot-style tables are zlib-deflated, and decompress_ctb inflated
them with a bare read_to_end - no output budget. A few hundred KB of
crafted deflate input expands to gigabytes (A/space runs compress about
1000:1), and the Vec grows until the allocator aborts; an abort is not a
catchable panic. Both the zlib path and the legacy raw-deflate fallback
were unbounded.
Reproduce:
1. Craft a CTB whose payload deflates from 128 MiB of spaces (tiny on
disk), with a valid PIAFILEVERSION header or as legacy raw deflate.
2. Select it as the plot style (open/plot).
3. Before: RSS climbs past the limit, then "memory allocation failed".
4. After: the read stops at the 64 MiB limit and the load fails with
"... inflated data exceeds the 67108864-byte limit".
5. Boundary: a payload inflating to exactly 64 MiB loads (the limit
means "exceeds"); one byte more is refused.
Fix:
inflate_capped reads through take(MAX_INFLATED_BYTES + 1) and refuses
when the count is greater than 64 MiB. The extra byte is what makes
"exactly at the limit" distinguishable from "at least the limit": a bare
take(cap) stops at the cap, so read == cap is ambiguous, and a >= cap
comparison would wrongly refuse a legitimate boundary payload. All three
inflation sites (zlib, raw-deflate fallback, legacy decoder) route
through the helper; error strings are preserved. 64 MiB is far above any
real plot-style table. Four tests: zlib bomb refused, legacy bomb
refused, exactly-at-cap accepted, cap-plus-one refused.