Author SHA1 Message Date
skylar taylor-barrick d0281360c2 #618: finish the grid-snapped OFFSET in the canvas test
The test stopped once the source line was picked, so a broken side-point step could leave the drawing unchanged and still pass. It now clicks a side point above the line and checks that the copy lands 5 units up, at y = 7. It also checks that OFFSET asks for the next object and that Enter ends it.
2026-10-22 21:16:48 -06:00
skylar taylor-barrick de5aecfe8b #651: compare small text against the plot build path
The full-detail baseline used build(), which takes the on-screen path; plots take build_plot(), which keeps glyphs at full detail and records them as plot text. Comparing against build_plot() checks what the test's name says. With the screen-detail change reverted it still fails (480 triangles on screen, 480 in the plot).
2026-10-22 16:49:30 -06:00
skylar taylor-barrick 546b5a1cf3 #651: test that small filled text is simplified on screen and keeps full detail in plots
Filled TrueType text was triangulated at full outline detail at every zoom, which froze the canvas on pages of small text. The test builds four O glyphs on screen at 10 px and under 2 px per em and checks the triangle count drops (to one box per glyph at the smallest), while a plot keeps every triangle. Without the fix the on-screen build has the same 1656 triangles as the plot. It skips when no system font is installed, like the other TrueType render tests.
2026-10-22 16:34:32 -06:00
skylar taylor-barrick 2ab09850bb #1336: test that the DXF LAYER table stops at the record cap
The LAYER, LTYPE, STYLE and DIMSTYLE tables had no record cap, unlike VIEW, UCS and VPORT. The test reads a DXF with over a million LAYER records and checks the layer list stays at the cap; without the guard it holds every record (1000102 layers). The other three tables scan their lists linearly per record, so a million-record file is too slow to build in a test and they stay uncovered.
2026-10-22 16:34:32 -06:00
skylar taylor-barrick 4ab8c384b3 #618: test that OFFSET takes a clicked distance and picks its source under grid snap
OFFSET only accepted a typed distance, and with grid snap on a click meant to pick the source object was moved to the nearest grid point. The engine test clicks two points for the distance and checks the guide, the distance and the resulting offset; the canvas test does the same through grid-snapped clicks and then picks the source line. Without the fix the engine test stays at the distance prompt and the canvas test never picks the line.
2026-10-22 16:34:32 -06:00
Dylan M. Taylorandskylartaylor dc4bbe86f6 security: bound font outline geometry and MTEXT line scanning (CWE-400/407) (#1852)
* security: bound font outline geometry and MTEXT line scanning (CWE-400/407)

Two font/text paths let crafted input monopolise the rendering thread:

- Glyph shaping copied a glyph's full contour set for every placed character
  with no cumulative budget, so a compact CFF/OTF font with a large expanded
  outline, repeated across a short string, could grow the shaped result without
  bound. `shape` now charges retained outline points against a cumulative
  per-line budget and stops placing geometry once it is exhausted (advances
  still accumulate, so the width stays sensible) (row 9).
- Default MTEXT layout recomputed `line.text.chars().count()` before appending
  each segment or space, rescanning the whole accumulated line every time —
  multiplicative on a long line. The line now keeps a running character count,
  updated only from newly appended text (row 20).

New tests cover the MTEXT layout; all font tests pass and render still builds,
clippy clean.

Addresses findings rows 9, 20.

* fix: budget glyph geometry before outline placement

* fix: skip empty outlines without text metadata

---------

Co-authored-by: skylartaylor <[email protected]>
2026-10-22 17:57:39 -04:00
Dylan M. Taylorandskylartaylor 953a6b3b01 security: bound Lisp callbacks, native comparisons, and local file reads (CWE-400, CWE-407) (#1842)
* security: bound Lisp callbacks, native comparisons, and local file reads (CWE-400, CWE-407)

The command and scripting engine had several unbounded paths a supplied LISP
routine or a selected path could exploit to monopolise the thread or exhaust
memory:

- `call_sync` ran synchronous callbacks (vl-sort comparators, vl-remove-if
  predicates, …) with `u64::MAX` fuel, so a constant-memory infinite loop never
  yielded and bypassed the blocking execution cap. It now drives the callback in
  bounded slices and aborts with an error past a hard step cap (row 2).
- `wcmatch`/`glob` explored `*` placements with only a recursion-depth guard,
  and `equal` re-traversed shared cons subtrees. Both now charge every step
  against a shared work budget and stop when it is exhausted (row 33).
- `setvar` converted a LISP value to JSON unboundedly, so a shared cons DAG
  expanded into gigabytes of owned arrays. Conversion is now depth- and
  element-bounded and rejects over-budget values with a LISP error (row 39).
- OPEN/LOAD/APPLOAD (`read_text_file`), SCRIPT, and native open (`read_file`,
  `run_open`) buffered a path with `std::fs::read`, so an endless device
  exhausted memory before any size check. Reads now go through a new
  `capped_read` helper that rejects non-regular files and caps bytes
  (rows 21, 34).

New tests cover the callback loop, the oversized setvar structure, and the
capped reader. Addresses findings rows 2, 21, 33, 34, 39.

* fix: bound native Lisp scans and nonblocking file reads

* fix: enforce callback fuel before each execution slice

---------

Co-authored-by: skylartaylor <[email protected]>
2026-10-22 17:57:09 -04:00
Dylan M. Taylorandskylartaylor 8085745a22 security: bound native DXF/SVG/PDF import allocation and quadratic work (CWE-400/407/789) (#1846)
* security: bound native DXF/SVG/PDF import allocation and quadratic work (CWE-400/407/789)

Several native import paths let a crafted drawing cause quadratic CPU work or
unbounded allocation:

- DXF layer decoding scanned the whole layer list for every LAYER record
  (`d.layer_mut`), so many distinct layer names made import quadratic. Keep a
  case-normalised name→index map and update/append in O(1) (row 29).
- ACAD_TABLE cells were materialised per table under a per-table cap, but many
  small tables could still add up. Add a document-wide table-cell budget in
  parse_entities; a table that would overflow it is reduced to empty (row 8).
- DXF image name allocation restarted its `_2, _3…` suffix probe for every
  definition sharing a base name (quadratic). Keep a per-base next-suffix
  counter so probing resumes where it left off (row 13).
- Image resolution cloned each definition's path/name into every referring
  image. Add an aggregate byte budget; once spent, string copies stop while
  geometry fields are still applied (row 42).
- PDF link annotation scanned the whole selected-link vector for every
  primitive. Build a HashSet of linked handles once for O(1) membership (row 15).
- SVG arc/curve flattening retained each flushed run with only a per-run cap, so
  repeated arcs amplified a compact path into unbounded geometry. Add a
  document-wide generated-vertex budget charged in `add` (row 31).

New test `svg_arc_budget` covers the SVG budget. All `cadcraft-io` tests pass;
clippy adds no new warnings.

Addresses findings rows 8, 13, 15, 29, 31, 42.

* fix: share DXF table budgets before allocation

---------

Co-authored-by: skylartaylor <[email protected]>
2026-10-22 17:35:43 -04:00
Dylan M. Taylorandskylartaylor ca578a8fb7 security: authenticate the local control server with a per-session token (CWE-306) (#1844)
* security: authenticate the local control server with a per-session token (CWE-306)

The optional `--control PORT` server listens on loopback TCP and dispatched any
JSON request without authenticating the peer. Loopback TCP does not distinguish
operating-system users, so another local account could connect and drive the
desktop user's command and file authority (for example reading files through
native Lisp, or create/truncate writes).

Authenticate the peer with an unguessable per-session token:

- New `cadcraft_mcp::control_token` module: `issue(port)` generates a 256-bit
  token and writes it to `cadcraft-control-<port>.token` in the per-user runtime
  directory with owner-only permissions (0600 on Unix; per-user LOCALAPPDATA on
  Windows). `read(port)` reads it; `matches` compares in constant time.
- The control server issues the token at startup and requires every request to
  carry a matching `token` field; requests without one get an `unauthorized`
  reply and the connection is closed before dispatch.
- The MCP bridge (`Remote`) reads the token from the owner-only file — which
  needs the desktop user's own filesystem access — and sends it on every
  request, so the bridge keeps working unchanged for the legitimate user. A
  different local user cannot read the token and cannot authenticate.

Updates `docs/control-protocol.md`. New tests cover token issue/read/compare,
file permissions, and that the server refuses unauthenticated requests while
accepting authenticated ones.

Addresses finding row 48.

* fix: recover control authentication across server restarts

---------

Co-authored-by: skylartaylor <[email protected]>
2026-10-22 17:35:18 -04:00
Dylan M. Taylorandskylartaylor 916be26ed3 security: bound constraint-engine work and allocation (CWE-400/407) (#1851)
* security: bound constraint-engine work and allocation (CWE-400/407)

The constraint engine let imported geometry or settings cause disproportionate
CPU/memory work on the synchronous analysis thread:

- Auto-constraint inference scanned the whole caller-supplied type list for every
  geometric candidate. Resolve the allowed type names into a set once and use
  O(1) membership per candidate (row 46).
- Rigid-point sampling stopped after collecting points but kept traversing block
  members that contribute none (Text, Ray, …), repeated for every Insert. Count
  every examined member against a bounded budget (row 47).
- Control-mode residual evaluation cloned the whole retained spline, including
  excess imported weights, for each free parameter. Drop weights that do not
  match the control-point count before retaining the template (row 45).
- The DOF analysis budget estimated only matrix-rank work, ignoring the spline
  reconstruction each tangent residual performs during numerical differentiation.
  Add a residual-evaluation budget (rows × free × per-residual cost, including a
  spline-reconstruction term) enforced before building the Jacobian (row 43).

All constraints tests pass; clippy adds no new warnings.

Addresses findings rows 43, 45, 46, 47.

* fix: budget constraint work without changing spline weights

---------

Co-authored-by: skylartaylor <[email protected]>
2026-10-22 17:12:04 -04:00
Dylan M. Taylorandskylartaylor 2dbc7699a0 security: budget the rendering pipeline against DoS geometry (CWE-400/407/789) (#1849)
* security: budget the rendering pipeline against DoS geometry (CWE-400/407/789)

Several render paths let crafted drawing data spin the rendering thread or
allocate without bound:

- Hatch dash sweeping reset its per-span step counter and used emitted geometry
  as its only limit, so gap-only families (which emit nothing) could multiply
  CPU work. Skip families that cannot draw and charge every dash step against a
  shared per-hatch work budget (row 10).
- Hatch polygon deduplication compared loops with an O(n²) cyclic match and
  every loop against every kept one. Charge vertex comparisons against a shared
  budget and stop deduplicating when spent (row 12).
- Table coverage marked every merge rectangle, so overlapping merges re-marked
  covered cells quadratically. Bound marking to a small multiple of the cell
  count (row 11).
- Broken-table part layout expanded sparse rows to full width before checking
  whether detailed layout is allowed. Only expand when within the cell limit;
  otherwise leave cells sparse and let layout draw an outline (row 17).
- Dimension text substitution copied the base for every `<>` placeholder with no
  size bound. Estimate the expansion and cap it at a fixed text budget (row 32).
- Linetype range generation cycled all-gap patterns forever (nothing emitted, so
  the emit limit never tripped). Return continuous for all-gap patterns and
  bound visited pattern elements (row 38).
- Viewport contents built with a fresh expansion counter, so a layout could far
  exceed the documented block-expansion limit. Share one budget across the whole
  build, seeded with what the enclosing list already spent (row 22).

New tests for the all-gap linetype path; all render tests pass, clippy clean.

Addresses findings rows 10, 11, 12, 17, 22, 32, 38.

* fix: preserve bounded render fallbacks and text output

---------

Co-authored-by: skylartaylor <[email protected]>
2026-10-22 17:08:25 -04:00
Dylan M. Taylorandskylartaylor 7c72ec45a9 security: bound drawing document-model work and allocation (CWE-400/407) (#1850)
* security: bound drawing document-model work and allocation (CWE-400/407)

Several document-model operations let crafted drawing data cause quadratic or
exponential work, or multiply retained allocations:

- Polyline grip generation rebuilt the whole segment vector inside the loop over
  every vertex, so enumerating one large polyline's grips was quadratic. Compute
  the segments once before the loop (row 35).
- Associative-array name sync searched the whole replacement vector for every
  placement. Index replacements by placement coordinate once (last entry wins),
  turning the per-item lookup O(1) (row 44).
- Dimension-extent bounds recursively expanded the referenced block without the
  shared bounds cache the Insert path uses, so a branching or self-referencing
  dimension block cost one pass per reference. Route it through the same
  `(name, depth)` cache (row 37).
- Dynamic-block parsing parsed every graph-member occurrence into its own owned
  parameter, so a graph repeating a member multiplied one record into gigabytes.
  Deduplicate member handles before resolving them (row 7).
- Stretch-action application linearly searched the vertex list and cloned the
  index vector for every selection occurrence. Index vertices by handle and skip
  handles already handled in the action's selection (row 30).

New tests for polyline grips; all doc tests pass, clippy clean.

Addresses findings rows 7, 30, 35, 37, 44.

* fix: retain first dynamic stretch vertex selection

---------

Co-authored-by: skylartaylor <[email protected]>
2026-10-22 17:08:02 -04:00
Dylan M. Taylorandskylartaylor 0158712bfd security: bound geometry-kernel degree and work (CWE-407) (#1848)
* security: bound geometry-kernel degree and work (CWE-407)

Several geometry routines let a crafted spline or region cause quadratic or
worse CPU work:

- B-spline basis evaluation is O(degree²) per sample and tessellation takes
  O(degree) samples, so a HATCH/DXF spline edge declaring a very high degree
  (DXF allows up to 100 000) turned a tiny spline into billions of iterations.
  Add `MAX_DEGREE = 64` and treat higher-degree splines as invalid, so
  `eval`/`tessellate` fall back to the control polygon. The render clip boundary
  also rejects such degrees up front (rows 18, 28).
- `Spline::derivative` re-ran the O(knots) validity scan on every call, and
  arc-length integration calls it once per sample, making a valid spline's
  measurement quadratic. Split off `derivative_unchecked` and have integration
  (validated once by its public entry points) use it (row 24).
- `infer_knot_param` matched each fit parameter against the whole knot vector
  from the start. Both are ascending, so replace it with a linear merge (row 16).
- Fillet seed collection did not stop the enclosing run-pair loops when the
  per-call candidate cap was hit, and refined duplicate candidates. Bound total
  seeds across pairs and deduplicate before Newton refinement (row 36).
- Region grid intersection counted only distinct pairs against its budget, so a
  long segment replicated across many cells re-visited the same pair unbounded.
  Charge every candidate-pair visit against a budget (row 41).

New spline degree tests added; all geom and render tests pass, clippy clean.

Addresses findings rows 16, 18, 24, 28, 36, 41.

* fix: preserve spline knot and fillet search behavior

---------

Co-authored-by: skylartaylor <[email protected]>
2026-10-22 17:05:16 -04:00
Dylan M. Taylorandskylartaylor 683eb5e6e7 security: do not follow directory symlinks in cargo xtask stats (CWE-400) (#1845)
* security: do not follow directory symlinks in `cargo xtask stats` (CWE-400)

The stats walker descended with `Path::is_dir()`, which follows symlinks, and
kept no record of visited directories. A small acyclic graph of directory
aliases planted in a workspace package (for example through a contribution a
developer inspects) therefore caused exponentially repeated enumeration and file
reads, consuming CPU and I/O far beyond the repository's real size.

`walk` now inspects each entry's own `file_type()` and skips symlinks before
descending or reading, so directory aliases are never re-scanned. A traversal
budget (`MAX_ENTRIES`) bounds total work as defense in depth.

Addresses finding row 4.

* fix: report statistics traversal exhaustion

---------

Co-authored-by: skylartaylor <[email protected]>
2026-10-22 17:04:35 -04:00
Dylan M. Taylorandskylartaylor 345fb27a69 security: write files atomically through an exclusive temp file (CWE-59) (#1840)
* security: write files atomically through an exclusive temp file (CWE-59, CWE-377)

Save, export, plot, autosave and the audit log each wrote their output by
putting bytes in a predictable sibling temporary path (`<path>.cadcraft-tmp`,
`<recovery>.tmp`, or the companion `.adt`) and renaming it over the real file.
Opening that pre-existing temporary path for a truncating write follows a
symlink planted there by another local user, letting them redirect the write
onto any file the victim can write before the rename.

Add a shared `atomic_write` helper that creates a fresh temporary file with
`create_new` (O_CREAT|O_EXCL) in the destination directory, writes through the
retained handle, flushes/syncs, and atomically renames it into place. An
existing entry (symlink or not) makes the exclusive create fail instead of
being followed. Route autosave, save, export, plot and the audit log through it.

The macOS CI cert importer likewise wrote the decoded PKCS#12 container to a
fixed name in a possibly shared temp directory; it now creates a private
per-run directory with `mktemp -d` and creates the file with noclobber.

Addresses findings rows 1, 25, 40 (CWE-59) and 19 (CWE-377).

* fix: preserve permissions during atomic replacement

* fix: restrict temporary permissions during exclusive creation

---------

Co-authored-by: skylartaylor <[email protected]>
2026-10-22 17:03:46 -04:00
Brandon ThomasandClaude Opus 5.5 8f988f630d Release 0.11.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:37:25 -04:00
Dylan M. Taylor acd109d97c Use chrono instead of hand-rolled UTC conversion (#1789)
* Use chrono for UTC/calendar conversion instead of hand-rolled copies

Replaces the three civil_from_days copies (logging timestamp, Drawing
Recovery time, parity checklist date) with chrono as a workspace
dependency. Output formats are unchanged and pinned by the existing tests.
AGENTS.md now says time goes through chrono.

Fixes #1785

* Never panic on a clock outside chrono's range; keep 5-digit years unprefixed

chrono's From<SystemTime> unwraps, so a clock past year 262143 panicked
inside the logger. timestamp and xtask's date now use the fallible
DateTime::from_timestamp and read such a clock as the epoch, as the old
saturating code did. xtask's date also clamps a pre-epoch clock to
1970-01-01 again. Years past 9999 are written with {:04} like before,
because %Y would add a '+'. Tests cover all three cases.

* Don't build an unrepresentable SystemTime in the far-future test

UNIX_EPOCH + Duration::from_secs(u64::MAX / 2) overflows Windows'
SystemTime and panicked before timestamp() ran, failing the Windows
test job. Build it with checked_add and only assert where it exists.

* logging/autosave/parity: keep extended-year dates, no epoch fallback

Applies the stricter no-epoch-fallback rule (from wordcraft#3427 / filmcraft#2662,
superseding the earlier epoch-or-last-valid suggestion) so all sibling PRs agree:
a timestamp the old code converted past chrono's year 262143 range must keep its real
proleptic-Gregorian date, not collapse to 1970-01-01. Each site uses the 400-year
Gregorian cycle (146_097 days) with chrono as the only calendar logic: chrono converts
the day within one cycle, whole cycles carry in the year (i128, saturated into i64).
This also removes the panic FredApps flagged — `DateTime::<Utc>::from(SystemTime)` could
panic past chrono's range; `from_num_days_from_ce_opt` within one cycle never does.
Verified the cycle result equals the old formula across a wide range and at the extremes.

- apps/cadcraft/src/logging.rs `timestamp`: cycle `civil_from_days(u64)`; pre-1970 epoch
  clamp and ms/time-of-day format kept. Pins: +10_000_000_000_000 s -> 318857-05-20T17:46:40.000Z
  (was 1970-...); checked_add u64::MAX/2 s -> 292277026596-12-04T15:30:07.000Z (was epoch).
- crates/ui-egui/src/autosave.rs `utc`: cycle converter. Pins: utc(10_000_000_000_000)
  = "318857-05-20 17:46"; utc(u64::MAX) = "584554051223-11-09 07:00" (was "1970-01-01 00:00").
- xtask/src/parity.rs `date`: cycle converter; pre-1970 epoch clamp kept. Pins:
  day(115_740_740) = "318857-05-20"; checked_add u64::MAX/2 s -> "292277026596-12-04" (was epoch).

* Don't build a year-318857 SystemTime unchecked in the logging test

Windows' SystemTime only reaches about year 30828, so
UNIX_EPOCH + Duration::from_secs(10_000_000_000_000) panicked before
timestamp() ran and failed the Windows test job. Build it with
checked_add and assert where the platform can hold it. The year-10000
case fits everywhere and stays unconditional.

* Name the 400-year range extension as the one exception to the time rule

The rule said never to hand-roll calendar math, but the range fix
(shifting by whole 400-year Gregorian cycles so dates past chrono's year
range keep their old output) is cycle arithmetic, so the rule and the code
disagreed. The rule now names that exception: the library still computes
month, day and time, and only whole cycles are carried in the year.

* Say the library computes month and day; time-of-day is plain arithmetic

Narrows the range-extension wording: the date library computes the
calendar fields, and splitting seconds into hours, minutes and seconds
is ordinary arithmetic, which is what the code does.
2026-10-22 14:09:16 -04:00
Antoine Boxho f4e6873130 Merge pull request #1568 from C00kieMonsta/readme-official-source
README: verify official releases and download checksums (fixes #1554)
2026-10-22 14:05:42 -04:00
skylar taylor-barrick e4214f7180 Merge pull request #1677 from MarekSpodBiedry/issue-1644
Write the ACAD_TABLE *T block relative to its insertion point (#1644)
2026-10-22 07:51:40 -06:00
skylar taylor-barrick 35934718ea Merge pull request #1683 from MarekSpodBiedry/issue-1667
Keep a layout's plot window corners when saving DXF (#1667)
2026-10-22 07:51:33 -06:00
skylar taylor-barrick 916d440bed Merge pull request #1718 from oracola91/fix/control-accept-backoff
Control server: wait after a failed accept instead of retrying at once
2026-10-22 07:51:26 -06:00
skylar taylor-barrick 632967aa78 Merge pull request #1336 from midasmatterhorn/fix/r4-dxf-table-record-caps
io: the four symbol tables honour the same record cap as their peers
2026-10-22 07:51:19 -06:00
skylar taylor-barrick f15be2305c Merge pull request #1680 from MarekSpodBiedry/issue-1650
DWG: write the current text style's own handle, so AutoCAD opens with it current (#1650)
2026-10-22 07:51:12 -06:00
skylar taylor-barrick f2dadec23a Merge pull request #1678 from MarekSpodBiedry/issue-1648
Multileader sizes no longer multiplied by DIMSCALE (#1648)
2026-10-22 07:51:06 -06:00
skylar taylor-barrick 00acec952e Merge pull request #1774 from philipp-tch/fix/foreign-handles-above-2-48
DXF: keep foreign objects whose handles are above 2^48 (fixes #1773)
2026-10-22 07:51:00 -06:00
skylar taylor-barrick 78254b4d3f Merge pull request #1480 from Silvertree2010/fix/h2426-polyline-segments-with-a-tiny-no
Treat polyline bulges with a sub-EPS sweep as straight segments
2026-10-22 07:50:53 -06:00
skylar taylor-barrick 8094d7746f Merge pull request #1635 from Hamza0101/entsel-pickbox
AutoLISP: an entsel prompt shows the pick box whatever its message says
2026-10-22 07:50:47 -06:00
skylar taylor-barrick 4ad4f4f598 Merge pull request #1628 from Hamza0101/lisp-eq-symbols
AutoLISP: `=` compares the symbols `type` returns
2026-10-22 07:50:41 -06:00
BFlat 477eec5026 Merge pull request #1894 from storytold/readme/app-icon
README: the app icon above the title
2026-10-22 18:01:10 +07:00
bflatastic 38750c6eb8 README: the app icon above the title 2026-10-22 17:57:20 +07:00
Dylan M. Taylor 75d9d72703 Merge pull request #1837 from storytold/sonar-fix/enforce-https
security: enforce HTTPS on curl downloads (SonarCloud S6506)
2026-10-21 20:13:55 -04:00
Philipp ThomannandClaude Opus 5.5 82424e47c9 DXF: keep foreign objects whose handles are above 2^48
AutoCAD writes handles above 2^48 (whole drawings whose handles start at 0xDAD1...).
dxf_raw dropped every foreign object with such a handle, so these drawings lost their
dynamic blocks and all other unmodelled objects on read, DXF to DXF as well. DXF handles
are up to 16 hex digits; the whole 64-bit range is now believed (null still is not).
Modelled entities and table records already accepted such handles.

Test: the DOOR fixture with every handle moved above 2^48 keeps its dynamic block
through a save (red before the change).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 07:23:16 +02:00
oracola91 4d826ed91f Control server: wait after a failed accept instead of retrying at once
The accepting thread dropped failed accepts with incoming().flatten()
and asked again at once. When accepting keeps failing, for example while
the process has no file descriptors left, that loop keeps a core busy
until it recovers.

Wait 100 ms after a failed accept. The loop goes through a small helper
so the wait can be tested without running out of file descriptors.
2026-10-20 14:49:58 +03:00
MarekSpodBiedry a7bb28d722 DWG: write the current text style's own handle, so AutoCAD opens with it current (#1650) 2026-10-19 22:33:02 +02:00
MarekSpodBiedry 27bff9cc30 Write the ACAD_TABLE *T block relative to its insertion point (#1644) 2026-10-19 22:29:01 +02:00
MarekSpodBiedry d600a80354 Multileader sizes no longer multiplied by DIMSCALE (#1648) 2026-10-19 22:27:34 +02:00
MarekSpodBiedry 81f0aa53ff Keep a layout's plot window corners when saving DXF (#1667) 2026-10-19 22:27:04 +02:00
Hamza 27861ad0ed Read a prompt's wording only where the engine wrote it
The pick-box heuristic read the message for a leading "Select", and
this branch had widened it past leading whitespace so that a routine's
own message would be read the same way. That makes a plain point prompt
look like a pick: (getpoint "\nSelect a point: ") showed the pick box
and lost its point modifiers, since pointmod refuses them wherever
picks_objects holds.

A routine writes all of its own message (raw), and says through
picks_one when it picks an object, so the wording needs reading only
where the engine wrote it. Covered for both spellings a routine might
use, with and without the leading newline.
2026-10-20 00:31:05 +05:00
Hamza b72680cfb7 AutoLISP: an entsel prompt shows the pick box whatever its message says
Whether the cursor shows the pick box came from `Prompt::picks_objects()`,
which read the prompt's wording: a selection-set prompt, or a point prompt whose
message starts with "Select". An `entsel`/`nentsel` prompt takes a point and is
not a selection set, so it fell to the wording -- and an AutoLISP routine writes
its own message, conventionally starting with a newline:

  (nentsel)                                       "Select object: "   pick box
  (nentsel "\nSelect second line or polyline: ")   starts with "\n"   none
  (entsel  "\nPick a line: ")                     says no such word  none

A prompt now says that it picks one object rather than being read for it, and
`GetRequest::prompt()` sets that for `GetKind::EntSel`, so all three show the
pick box. The wording test is read past leading whitespace as well, so a command
machine's prompt beginning with a newline is not caught by the same thing.

Point modifiers (`.x`, `.y`…) are offered at a point prompt that is not a pick,
so they no longer appear at an `entsel` prompt with a message -- as they never
did at one without.

Fix: #1598
2026-10-19 19:13:23 +05:00
Hamza d9adce038c AutoLISP: = compares the symbols type returns
`(= (type x) 'LIST)` is how an AutoLISP routine tests a type, because `type`
returns a symbol. `=` coerced both of its arguments to numbers, so the symbol
failed the coercion and the routine stopped at its first type test:

  _$ (= (type 1) 'INT)
  bad argument type: numberp: INT

`=` and `/=` now compare numbers and strings as before, and any other atom -- a
symbol, `nil`, an entity name, a selection set, a file handle -- by what it is,
the way `eq` does. Two consequences, both closer to AutoLISP than raising was:
a mismatch of kinds answers `nil` rather than failing, so `(= 1 "1")` and
`(= '(1) '(1))` answer (comparing list structure is `equal`'s job), and
`(= nil nil)` is `T`.

The ordering comparisons still take numbers and strings only, as AutoLISP has
them: `(< 'a 'b)` remains `bad argument type: numberp: A`.

Fix: #1597
2026-10-19 17:26:39 +05:00
NetterSilverandClaude Haiku 5.5 4c75b0c458 Treat polyline bulges with a sub-EPS sweep as straight segments
Found while testing crates/geom/src/curve.rs:435 (bulge_to_arc) together with crates/geom/src/lib.rs:45-48 (ccw_sweep)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-17 04:43:01 +00:00
midasmatterhorn 8a2b8f4bdf io: the four symbol tables honour the same record cap as their peers
LAYER, LTYPE, STYLE and DIMSTYLE records were appended with no cap while
the VIEW, UCS and VPORT arms of the same loop guard at MAX_OBJECTS — so
a hostile DXF could grow the layer table (each record found by a linear
case-insensitive name scan) without bound. The same guard now applies to
all seven table kinds.

Behaviour is unchanged for every legal drawing: no drawing approaches a
million table records, so no regression test can reach the cap at unit
scale (the same reason the peer guards shipped without one). The
quadratic cost of the name lookups themselves is a separate, larger
change (resolving table names through a map while reading) deliberately
left to the maintainers.
2026-10-15 17:43:04 +03:00
81 changed files with 2440 additions and 294 deletions

No files matched your search

+1
View File
@@ -30,6 +30,7 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
- **Programmatic calls never open dialogs.** `engine.execute` runs the JSON form with defaults; only menu/toolbar invocation starts the interactive prompt sequence.
- **Layering** is enforced by `cargo xtask layers`: L0 `geom`, `dxf` → L1 `color`, `doc` → L2 `fonts`, `render`, `constraints` → L3 `io` → L4 `engine` → L5 `ui-egui`, `mcp` → apps. Nothing below L5 depends on egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.**
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` / `app.start(id)` / `app.cmdline(text)`. Colours come from `theme::Tokens`.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). Commit after every feature arc that builds, and push to `main`.
Generated
+31 -16
View File
@@ -547,11 +547,13 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cadcraft"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
"cadcraft-mcp",
"cadcraft-ui-egui",
"chrono",
"eframe",
"egui",
"hidapi",
@@ -569,7 +571,7 @@ dependencies = [
[[package]]
name = "cadcraft-cli"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -580,14 +582,14 @@ dependencies = [
[[package]]
name = "cadcraft-color"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-constraints"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"cadcraft-doc",
"cadcraft-geom",
@@ -597,7 +599,7 @@ dependencies = [
[[package]]
name = "cadcraft-doc"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"cadcraft-color",
"cadcraft-geom",
@@ -608,14 +610,14 @@ dependencies = [
[[package]]
name = "cadcraft-dwg"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"acadrust",
]
[[package]]
name = "cadcraft-dxf"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"encoding_rs",
"log",
@@ -624,7 +626,7 @@ dependencies = [
[[package]]
name = "cadcraft-engine"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"cadcraft-color",
"cadcraft-constraints",
@@ -635,6 +637,7 @@ dependencies = [
"cadcraft-io",
"cadcraft-render",
"craft-docpath",
"libc",
"log",
"rstar",
"serde",
@@ -644,7 +647,7 @@ dependencies = [
[[package]]
name = "cadcraft-fonts"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"cadcraft-geom",
"harfrust",
@@ -654,14 +657,14 @@ dependencies = [
[[package]]
name = "cadcraft-geom"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-io"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"acadrust",
"cadcraft-color",
@@ -681,18 +684,19 @@ dependencies = [
[[package]]
name = "cadcraft-mcp"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
"cadcraft-render",
"getrandom 0.3.4",
"log",
"serde_json",
]
[[package]]
name = "cadcraft-render"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
@@ -706,7 +710,7 @@ dependencies = [
[[package]]
name = "cadcraft-ui-egui"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
@@ -714,6 +718,7 @@ dependencies = [
"cadcraft-fonts",
"cadcraft-geom",
"cadcraft-render",
"chrono",
"craft-ui",
"egui",
"egui-wgpu",
@@ -728,7 +733,7 @@ dependencies = [
[[package]]
name = "cadcraft-web"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -825,6 +830,15 @@ dependencies = [
"libc",
]
[[package]]
name = "chrono"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"num-traits",
]
[[package]]
name = "clipboard-win"
version = "5.4.1"
@@ -4811,8 +4825,9 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"chrono",
"serde_json",
]
+3 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
# default-members
[workspace.package]
version = "0.10.0"
version = "0.11.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -32,6 +32,8 @@ thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
rayon = "1"
# rendering / formats
+12
View File
@@ -7,6 +7,10 @@
</a>
</p>
<p align="center">
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.cadcraft.png" width="128" height="128" alt="CADCraft icon: a drafting set square and compass arc on a blue grid tile">
</p>
<h1 align="center">CADCraft</h1>
<p align="center">
@@ -285,6 +289,14 @@ See [ROADMAP.md](ROADMAP.md) for the stage, measured parity and the estimate of
**Download CADCraft** from GitHub: the [latest release](https://github.com/storytold/cadcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/cadcraft/releases) has earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
> [!WARNING]
> Download official CADCraft builds from the [storytold/cadcraft releases](https://github.com/storytold/cadcraft/releases). Verify that the repository owner is `storytold`. Before you run a download, check that its checksum matches the line for that file in the release's `SHA256SUMS.txt`:
>
> ```sh
> shasum -a 256 cadcraft-<ver>-macos-universal.dmg # macOS (on Linux: sha256sum FILE)
> Get-FileHash .\cadcraft-<ver>-windows-x64.msi # Windows PowerShell
> ```
### Windows
| Build | Installer | Portable |
+3
View File
@@ -14,10 +14,13 @@ workspace = true
cadcraft-engine = { workspace = true }
cadcraft-ui-egui = { workspace = true }
cadcraft-io = { workspace = true }
# For the shared per-session control-token helper (control_server.rs authentication).
cadcraft-mcp = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links", "persistence"] }
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
chrono = { workspace = true }
[build-dependencies]
winresource = "0.1"
+97 -13
View File
@@ -51,8 +51,21 @@ impl Default for Limits {
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => {
log::info!("control server listening on 127.0.0.1:{port}");
serve_listener(l, Limits::default(), ctx)
// Loopback TCP cannot tell local users apart, so issue an unguessable per-session
// token in an owner-only file and require it on every request (CWE-306). A client
// (the MCP bridge) reads it from the file, which needs the desktop user's filesystem
// access; another local account cannot.
let token = match cadcraft_mcp::control_token::issue(port) {
Ok(t) => Arc::new(t),
Err(e) => {
log::error!("control server not started: could not write the control token: {e}");
return sync_channel::<ControlRequest>(0).1;
}
};
if let Ok(path) = cadcraft_mcp::control_token::token_path(port) {
log::info!("control server listening on 127.0.0.1:{port} (token in {})", path.display());
}
serve_listener(l, Limits::default(), ctx, token)
}
Err(e) => {
log::error!("control server failed to bind 127.0.0.1:{port}: {e}");
@@ -61,10 +74,20 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
}
}
/// How long the accepting thread waits after a failed accept. Accepting can keep failing, for
/// example while the process has no file descriptors left, and retrying at once would keep a
/// core busy until it recovers.
const ACCEPT_RETRY: Duration = Duration::from_millis(100);
/// The connections `incoming` accepts, calling `wait` after each failed accept.
fn accepted<T>(incoming: impl Iterator<Item = std::io::Result<T>>, mut wait: impl FnMut()) -> impl Iterator<Item = T> {
incoming.filter_map(move |stream| stream.map_err(|_| wait()).ok())
}
/// Accept connections on `listener` under `limits`; requests arrive on the returned receiver.
fn serve_listener(listener: TcpListener, limits: Limits, ctx: egui::Context) -> Receiver<ControlRequest> {
fn serve_listener(listener: TcpListener, limits: Limits, ctx: egui::Context, token: Arc<String>) -> Receiver<ControlRequest> {
let (tx, rx) = sync_channel::<ControlRequest>(limits.queue.max(1));
std::thread::spawn(move || accept_loop(listener, tx, ctx, limits));
std::thread::spawn(move || accept_loop(listener, tx, ctx, limits, token));
rx
}
@@ -78,9 +101,9 @@ impl Drop for Slot {
}
}
fn accept_loop(listener: TcpListener, tx: SyncSender<ControlRequest>, ctx: egui::Context, limits: Limits) {
fn accept_loop(listener: TcpListener, tx: SyncSender<ControlRequest>, ctx: egui::Context, limits: Limits, token: Arc<String>) {
let open = Arc::new(AtomicUsize::new(0));
for mut stream in listener.incoming().flatten() {
for mut stream in accepted(listener.incoming(), || std::thread::sleep(ACCEPT_RETRY)) {
let _ = stream.set_write_timeout(Some(limits.write_timeout));
if open.fetch_add(1, Ordering::SeqCst) >= limits.max_clients {
open.fetch_sub(1, Ordering::SeqCst);
@@ -90,9 +113,10 @@ fn accept_loop(listener: TcpListener, tx: SyncSender<ControlRequest>, ctx: egui:
let slot = Slot(Arc::clone(&open));
let tx = tx.clone();
let ctx = ctx.clone();
let token = Arc::clone(&token);
let _ = std::thread::Builder::new().spawn(move || {
let _slot = slot;
serve(stream, &tx, &ctx, limits);
serve(stream, &tx, &ctx, limits, &token);
});
}
}
@@ -131,6 +155,7 @@ enum Line {
id: Value,
method: String,
params: Value,
token: Option<String>,
},
Blank,
/// Not a request: reply with this error and close the connection.
@@ -162,6 +187,7 @@ fn parse_line(bytes: &[u8]) -> Line {
id: obj.get("id").cloned().unwrap_or(Value::Null),
method: method.to_string(),
params: obj.get("params").cloned().unwrap_or(json!({})),
token: obj.get("token").and_then(Value::as_str).map(str::to_string),
}
}
@@ -172,7 +198,7 @@ fn is_http_request_line(text: &str) -> bool {
|| text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/"))
}
fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context, limits: Limits) {
fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context, limits: Limits, token: &str) {
if stream.set_read_timeout(Some(limits.idle_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() {
return;
}
@@ -190,7 +216,18 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
Ok(true) => match parse_line(&buf) {
Line::Blank => continue,
Line::Reject(e) => e,
Line::Request { id, method, params } => {
Line::Request { id, method, params, token: given } => {
// Authenticate the peer before dispatching: loopback TCP does not identify the
// OS user, so only a client holding the owner-only session token may drive the
// desktop user's commands (CWE-306).
if !given.map(|g| cadcraft_mcp::control_token::matches(token, &g)).unwrap_or(false) {
let mut r = json!({"ok": false, "error": "unauthorized: missing or invalid control token", "state": "not-run"});
if let Some(o) = r.as_object_mut() {
o.insert("id".into(), id);
}
let _ = writeln!(out, "{r}");
break;
}
let (req, pending) = ControlRequest::new(method, params);
let mut r = match tx.try_send(req) {
Ok(()) => {
@@ -222,6 +259,18 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
mod tests {
use super::*;
/// A failed accept is followed by a wait before the next one, so a listener that keeps
/// failing does not spin, and the connections around the failures still come through.
#[test]
fn a_failed_accept_waits_before_the_next() {
let failed = || Err(std::io::Error::other("no file descriptors left"));
let incoming = vec![failed(), Ok(1), failed(), failed(), Ok(2)];
let mut waits = 0;
let connections: Vec<i32> = super::accepted(incoming.into_iter(), || waits += 1).collect();
assert_eq!(connections, [1, 2]);
assert_eq!(waits, 3, "one wait per failed accept");
}
#[test]
fn lines_are_read_up_to_the_limit() {
let mut buf = Vec::new();
@@ -255,11 +304,14 @@ mod tests {
}
}
/// A fixed token for tests; real tokens are random (see `cadcraft_mcp::control_token`).
const TEST_TOKEN: &str = "test-control-token";
/// The control server on a loopback port under `limits`; requests arrive on the receiver.
fn serve_with(limits: Limits) -> (std::net::SocketAddr, Receiver<ControlRequest>) {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
(addr, serve_listener(listener, limits, egui::Context::default()))
(addr, serve_listener(listener, limits, egui::Context::default(), Arc::new(TEST_TOKEN.to_string())))
}
/// The control server on a loopback port, with a stand-in UI that echoes each method.
@@ -273,6 +325,11 @@ mod tests {
addr
}
/// A request line carrying the test token, so it authenticates.
fn req_line(id: i64, method: &str) -> String {
json!({"id": id, "method": method, "token": TEST_TOKEN}).to_string() + "\n"
}
/// Send `input` on a new connection and read reply lines until the server closes it.
fn exchange(addr: std::net::SocketAddr, input: &[u8]) -> Vec<Value> {
let mut c = TcpStream::connect(addr).unwrap();
@@ -332,12 +389,12 @@ mod tests {
fn a_full_queue_answers_busy() {
let (addr, rx) = serve_with(short());
// No UI drains the one-place queue: the first request fills it, the second finds it full.
let first = std::thread::spawn(move || exchange(addr, b"{\"id\":1,\"method\":\"m\"}\n"));
let first = std::thread::spawn(move || exchange(addr, req_line(1, "m").as_bytes()));
let deadline = std::time::Instant::now() + Duration::from_secs(4);
let busy = loop {
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(b"{\"id\":2,\"method\":\"m\"}\n").unwrap();
c.write_all(req_line(2, "m").as_bytes()).unwrap();
let mut line = String::new();
BufReader::new(c).read_line(&mut line).unwrap();
let v: Value = serde_json::from_str(&line).unwrap();
@@ -355,7 +412,8 @@ mod tests {
let addr = serve_echo();
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(b"{\"id\":3,\"method\":\"ping\"}\r\n\n{\"id\":4,\"method\":\"pong\"}\n").unwrap();
let input = format!("{}\n{}", req_line(3, "ping").trim_end(), req_line(4, "pong"));
c.write_all(input.as_bytes()).unwrap();
let mut r = BufReader::new(c);
for (id, method) in [(3, "ping"), (4, "pong")] {
let mut line = String::new();
@@ -365,6 +423,32 @@ mod tests {
}
}
#[test]
fn a_request_without_the_token_is_unauthorized() {
// A local peer that does not hold the session token cannot drive the app (CWE-306).
let addr = serve_echo();
let replies = exchange(addr, b"{\"id\":9,\"method\":\"ping\"}\n");
assert_eq!(replies.len(), 1, "{replies:?}");
assert_eq!(replies[0]["ok"], false);
assert!(replies[0]["error"].as_str().unwrap().contains("unauthorized"), "{replies:?}");
// A wrong token is also refused.
let wrong = exchange(addr, b"{\"id\":9,\"method\":\"ping\",\"token\":\"nope\"}\n");
assert_eq!(wrong[0]["ok"], false);
assert!(wrong[0]["error"].as_str().unwrap().contains("unauthorized"), "{wrong:?}");
}
#[test]
fn a_request_with_the_token_is_dispatched() {
let addr = serve_echo();
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(req_line(10, "ping").as_bytes()).unwrap();
let mut line = String::new();
BufReader::new(c).read_line(&mut line).unwrap();
let v: Value = serde_json::from_str(&line).unwrap();
assert_eq!((v["ok"].as_bool(), v["method"].as_str()), (Some(true), Some("ping")), "{line}");
}
#[test]
fn extra_connections_are_refused() {
let addr = serve_echo();
+36 -16
View File
@@ -93,7 +93,10 @@ fn matches(name: &str, target: &str) -> bool {
}
}
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch (the
/// `duration_since` clamp); past the last date chrono can represent (year 262143) the proleptic
/// Gregorian date is kept via the 400-year cycle rather than falling back to the epoch, so the
/// output never changes for a value the old formatter accepted and logging never panics.
pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = since.as_secs();
@@ -102,20 +105,25 @@ pub fn timestamp(t: SystemTime) -> String {
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
}
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd
/// clock can't overflow).
fn civil_from_days(days: u64) -> (u64, u64, u64) {
let z = days.saturating_add(719_468);
let era = z / 146_097;
let doe = z % 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
(y, m, d)
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day).
///
/// `timestamp` clamps a pre-1970 clock to the epoch, so `days` is an unsigned count that can run
/// past `NaiveDate`'s ±262143-year range. The Gregorian calendar repeats every 400 years
/// (146_097 days), so chrono converts the day within one cycle and whole cycles go back into the
/// year (an `i128`, then saturated into `i64`), keeping chrono as the only calendar logic and
/// reproducing the old saturating `civil_from_days` result exactly without hand-rolled day math.
/// 1970-01-01 is `num_days_from_ce` 719_163.
fn civil_from_days(days: u64) -> (i64, u32, u32) {
use chrono::Datelike;
let (cycles, rest) = (days / 146_097, days % 146_097);
// `rest + 719_163` is 719_163..865_259, always a valid CE day.
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
Some(d) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day())
}
None => (1970, 1, 1), // unreachable
}
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
@@ -385,8 +393,20 @@ mod tests {
// 2000 is a leap year (divisible by 400), 2100 is not.
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
// Before the epoch (a clock set wrong) clamps to the epoch instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
// Past chrono's range (a clock set absurdly far ahead) keeps the proleptic Gregorian date
// the old formatter gave, rather than falling back to the epoch.
// Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
}
// Windows' SystemTime can't hold u64::MAX / 2 seconds, so it is only checked where it exists.
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
}
// Years past 9999 keep the plain zero-padded year (no `+`).
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
}
#[test]
+5 -1
View File
@@ -102,7 +102,11 @@ fn ang_between(d1: Vec2, d2: Vec2) -> f64 {
pub fn infer(d: &Drawing, handles: &[Handle], o: &InferOptions) -> Vec<Constraint> {
let tol = if o.distance_tolerance.is_finite() { o.distance_tolerance.clamp(0.0, 1e9) } else { 0.05 };
let atol = if o.angle_tolerance.is_finite() { o.angle_tolerance.clamp(0.0, 45.0).to_radians() } else { 1f64.to_radians() };
let allow = |k: ConstraintKind| o.types.is_empty() || o.types.iter().any(|t| t.eq_ignore_ascii_case(k.name()));
// Resolve the allowed type names into a set once, so membership is O(1) per candidate instead
// of re-scanning the whole (attacker-controllable) `o.types` list for every geometric match
// (CWE-407). Empty `o.types` means "all types".
let allowed: std::collections::HashSet<String> = o.types.iter().map(|t| t.to_ascii_lowercase()).collect();
let allow = |k: ConstraintKind| allowed.is_empty() || allowed.contains(&k.name().to_ascii_lowercase());
let g = collect(d, handles);
let mut out: Vec<Constraint> = Vec::new();
let mut push = |kind: ConstraintKind, refs: Vec<GeomRef>| {
+46 -3
View File
@@ -12,12 +12,15 @@ use cadcraft_doc::{Constraint, Drawing, Handle};
use serde::Serialize;
use crate::model::Model;
use crate::{Comp, MAX_CONSTRAINTS, SolveError, SolveOptions, build, lm};
use crate::{Comp, MAX_CONSTRAINTS, SolveError, SolveOptions, build_budgeted, lm};
/// Most free parameters for which per-object freedom is analysed (null-space basis size).
const MAX_NULL_COLS: usize = 600;
/// Largest Jacobian (rows × columns × min) whose rank is computed.
const MAX_RANK_WORK: f64 = 4e8;
/// Largest residual-evaluation work (rows × free × per-residual cost, including spline
/// reconstruction) allowed before building the Jacobian during numerical differentiation.
const MAX_EVAL_WORK: f64 = 2e9;
/// Most objects listed in one report.
const MAX_OBJECTS: usize = 10_000;
@@ -94,6 +97,14 @@ fn analyse(comp: &Comp) -> CompDof {
if rows > lm::MAX_ROWS || (rows as f64) * (nf as f64) * (rows.min(nf) as f64) > MAX_RANK_WORK {
return unknown;
}
// Numerical differentiation evaluates every residual about `rows × free` times, and each
// spline-end residual reconstructs its spline through a dense solve. Account for that
// reconstruction cost before building the Jacobian so a component with few equations but large
// splines cannot pass the rank-work check yet still require billions of operations (row 43).
let eval_work = (rows as f64) * (nf as f64) * (1.0 + comp.built.iter().map(|b| b.spline_reconstruct_cost(&comp.model)).sum::<f64>());
if eval_work > MAX_EVAL_WORK {
return unknown;
}
let f = |x: &[f64], out: &mut Vec<f64>| comp.eval(x, out, None);
let jac = lm::jacobian(&f, &comp.model.x0, &free, rows);
if nf > MAX_NULL_COLS {
@@ -126,7 +137,8 @@ fn plural(n: usize, one: &str, many: &str) -> String {
/// (constrained or not) and the constraint components they belong to.
pub fn dof(d: &Drawing, handles: &[Handle]) -> Result<DofReport, SolveError> {
let cons: Vec<&Constraint> = d.constraints.iter().take(MAX_CONSTRAINTS).collect();
let (comps, _) = build(d, &cons, &SolveOptions::default())?;
let mut members_left = crate::model::MAX_RIGID_WORK;
let (comps, _) = build_budgeted(d, &cons, &SolveOptions::default(), &mut members_left)?;
let want = |h: Handle| handles.is_empty() || handles.contains(&h);
let mut objects = Vec::new();
let mut seen = HashSet::new();
@@ -165,7 +177,7 @@ pub fn dof(d: &Drawing, handles: &[Handle]) -> Result<DofReport, SolveError> {
if seen.contains(&h) {
continue;
}
let m = Model::build(d, &[h]);
let m = Model::build_budgeted(d, &[h], &mut members_left);
let Some(s) = m.slots.first() else { continue };
seen.insert(h);
free_objects = true;
@@ -193,3 +205,34 @@ pub fn dof(d: &Drawing, handles: &[Handle]) -> Result<DofReport, SolveError> {
};
Ok(DofReport { dof: total, status, redundant, constraints, objects, message })
}
#[cfg(test)]
mod budget_tests {
use super::*;
use crate::residual::{Built, Form};
use cadcraft_doc::{Common, ConstraintKind, EntityKind, GeomRef, Space};
use cadcraft_geom::{Spline, Vec2};
#[test]
fn endpoint_analysis_rejects_cubic_fit_work_before_differentiation() {
let mut d = Drawing::default();
let pts: Vec<_> = (0..500).map(|i| Vec2::new(i as f64, (i % 3) as f64)).collect();
let mut spline = Spline::from_control(pts.clone(), 3);
spline.fit = pts;
let h = d.add(&Space::Model, Common::default(), EntityKind::Spline(spline)).unwrap();
let model = Model::build(&d, &[h]);
let endpoint = Built {
id: 1,
kind: ConstraintKind::Tangent,
refs: vec![GeomRef::whole(h)],
form: Form::EndOn { spline: 0, at_end: false },
target: 0.0,
sign: 1.0,
rows: 2,
aux_init: Vec::new(),
aux: 0,
};
let comp = Comp { model, built: vec![endpoint], weights: vec![1.0; 2], tol: 1e-9 };
assert!(analyse(&comp).dof.is_none());
}
}
+11 -1
View File
@@ -324,12 +324,22 @@ fn components<'a>(cons: &[&'a Constraint]) -> Vec<Vec<&'a Constraint>> {
/// Build the component systems. Constraints that cannot be interpreted are returned as skipped.
pub(crate) fn build(d: &Drawing, cons: &[&Constraint], opts: &SolveOptions) -> Result<(Vec<Comp>, Vec<(u32, String)>), SolveError> {
let mut members_left = model::MAX_RIGID_WORK;
build_budgeted(d, cons, opts, &mut members_left)
}
pub(crate) fn build_budgeted(
d: &Drawing,
cons: &[&Constraint],
opts: &SolveOptions,
members_left: &mut usize,
) -> Result<(Vec<Comp>, Vec<(u32, String)>), SolveError> {
let mut e = env(d);
let mut skipped = Vec::new();
let mut comps = Vec::new();
for group in components(cons) {
let handles: Vec<Handle> = group.iter().flat_map(|c| c.refs.iter().map(|r| r.handle)).collect();
let mut model = Model::build(d, &handles);
let mut model = Model::build_budgeted(d, &handles, members_left);
let mut built = Vec::new();
for c in group {
let target = if c.kind.is_dimensional() { constraint_value(&mut e, c)? } else { 0.0 };
+97 -3
View File
@@ -27,6 +27,11 @@ pub const MAX_PARAMS: usize = 4000;
pub const MAX_SPLINE_POINTS: usize = 500;
/// Most characteristic points of a block reference used as rigid constraint points.
pub const MAX_RIGID_POINTS: usize = 256;
/// Block members examined when sampling rigid points, counting those that contribute none, so a
/// block padded with point-less entities cannot make sampling (repeated per Insert) unbounded.
const MAX_RIGID_MEMBERS: usize = 100_000;
/// Shared across every model built for one analysis request.
pub(crate) const MAX_RIGID_WORK: usize = 1_000_000;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Shape {
@@ -74,6 +79,21 @@ pub(crate) struct Model {
rigid: HashMap<Handle, Vec<Vec2>>,
}
impl Model {
/// Work for a spline that an endpoint residual actually reconstructs. Fit interpolation
/// uses dense Gaussian elimination (cubic); control splines clone and evaluate their template.
pub(crate) fn spline_reconstruct_cost(&self, handle: Handle) -> f64 {
let Some(s) = self.splines.get(&handle) else { return 0.0 };
let n = s.fit.len() as f64;
if fit_mode(s) {
n * n * n
} else {
let n = s.control.len() as f64;
n * n
}
}
}
/// Can this entity take part in constraints?
pub fn supported(kind: &EntityKind) -> bool {
matches!(
@@ -93,13 +113,24 @@ pub fn supported(kind: &EntityKind) -> bool {
/// centres, vertices, nested insertion points) of the block's own objects, at most
/// [`MAX_RIGID_POINTS`]. Nested blocks are not entered.
pub fn rigid_points(d: &Drawing, ins: &Insert) -> Vec<Vec2> {
let mut members_left = MAX_RIGID_MEMBERS;
rigid_points_budgeted(d, ins, &mut members_left)
}
fn rigid_points_budgeted(d: &Drawing, ins: &Insert, members_left: &mut usize) -> Vec<Vec2> {
let Some(block) = d.block(&ins.block) else { return Vec::new() };
let m = ins.transform(block.base.xy());
let mut out = Vec::new();
// Bound the members examined, not only the points collected: entities that contribute no
// points (Text, Ray, …) still cost traversal, so a block full of them — scanned again for
// every Insert — would otherwise be unbounded (CWE-407).
let mut examined = 0usize;
for e in block.entities.iter() {
if out.len() >= MAX_RIGID_POINTS {
examined += 1;
if out.len() >= MAX_RIGID_POINTS || examined > MAX_RIGID_MEMBERS || *members_left == 0 {
break;
}
*members_left -= 1;
let pts: Vec<Vec2> = match &e.kind {
EntityKind::Line(l) => vec![l.a.xy(), l.b.xy()],
EntityKind::Arc(a) => {
@@ -152,6 +183,11 @@ fn fit_mode(s: &GSpline) -> bool {
impl Model {
/// Build a model for the given entities. Unsupported or missing entities are skipped.
pub fn build(d: &Drawing, handles: &[Handle]) -> Model {
let mut members_left = MAX_RIGID_WORK;
Self::build_budgeted(d, handles, &mut members_left)
}
pub(crate) fn build_budgeted(d: &Drawing, handles: &[Handle], members_left: &mut usize) -> Model {
let mut m = Model::default();
for &h in handles {
if m.index.contains_key(&h) {
@@ -208,12 +244,15 @@ impl Model {
// fit point; a control polygon's ends are its first and last points when the
// knots are clamped.
let ends = !sp.closed && (fit || (clamped(sp, false) && clamped(sp, true)));
m.splines.insert(h, sp.clone());
// Missing weights default to 1 in eval; only excess weights are unused.
let mut kept = sp.clone();
kept.weights.truncate(kept.control.len());
m.splines.insert(h, kept);
Shape::Spline { n, fit, ends }
}
EntityKind::Insert(ins) => {
let base = Vec2::new(ins.insert.x, ins.insert.y);
let pts: Vec<Vec2> = rigid_points(d, ins).into_iter().map(|p| (p - base).rotate(-ins.rotation)).collect();
let pts: Vec<Vec2> = rigid_points_budgeted(d, ins, members_left).into_iter().map(|p| (p - base).rotate(-ins.rotation)).collect();
m.x0.extend([ins.insert.x, ins.insert.y, ins.rotation]);
let n = pts.len();
m.rigid.insert(h, pts);
@@ -573,3 +612,58 @@ impl Model {
moved
}
}
#[cfg(test)]
mod budget_tests {
use super::*;
use cadcraft_doc::{Block, Common, Entity, Line, Space};
use cadcraft_geom::Vec3;
use std::sync::Arc;
#[test]
fn write_back_keeps_partial_rational_weights() {
let mut drawing = Drawing::default();
let mut spline = GSpline::from_control(vec![Vec2::ZERO, Vec2::new(1.0, 2.0), Vec2::new(3.0, 0.0)], 2);
spline.weights = vec![2.0, 0.5];
let h = drawing.add(&Space::Model, Common::default(), EntityKind::Spline(spline.clone())).unwrap();
let model = Model::build(&drawing, &[h]);
model.write_back(&mut drawing, &model.x0);
let EntityKind::Spline(after) = &drawing.entity(h).unwrap().kind else { panic!("spline") };
assert_eq!(after.weights, spline.weights);
for i in 0..=10 {
assert!(after.eval(i as f64 / 10.0).near(spline.eval(i as f64 / 10.0), 1e-12));
}
}
#[test]
fn rigid_sampling_budget_is_shared_between_models() {
let mut d = Drawing::default();
let mut block = Block::new("BAR");
for _ in 0..3 {
block.entities.push(Entity {
handle: d.new_handle(),
common: Common::default(),
kind: EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(1.0, 0.0, 0.0) }),
});
}
d.blocks.insert("BAR".into(), Arc::new(block));
let ins = Insert {
block: "BAR".into(),
insert: Vec3::ZERO,
scale: Vec3::new(1.0, 1.0, 1.0),
rotation: 0.0,
attribs: Vec::new(),
cols: 1,
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
};
let h = d.add(&Space::Model, Common::default(), EntityKind::Insert(ins)).unwrap();
let mut budget = 2;
let first = Model::build_budgeted(&d, &[h], &mut budget);
assert_eq!(first.rigid[&h].len(), 4);
assert_eq!(budget, 0);
let next = Model::build_budgeted(&d, &[h], &mut budget);
assert!(next.rigid[&h].is_empty());
}
}
+9
View File
@@ -116,6 +116,15 @@ fn r_at(c: &Constraint, i: usize) -> Option<&GeomRef> {
}
impl Built {
pub(crate) fn spline_reconstruct_cost(&self, m: &Model) -> f64 {
let cost = |i: usize| self.refs.get(i).map_or(0.0, |r| m.spline_reconstruct_cost(r.handle));
match self.form {
Form::EndOn { spline, .. } => cost(spline),
Form::Join { .. } => cost(0) + cost(1),
_ => 0.0,
}
}
/// Interpret a constraint against the model at `x`. `target` is the evaluated value for
/// dimensional constraints (degrees for angular). Errors explain what references are needed.
pub fn new(c: &Constraint, m: &Model, x: &[f64], target: f64) -> Result<Built, String> {
+18
View File
@@ -363,3 +363,21 @@ fn hostile_new_geometry_never_panics() {
let _ = dof(&d, &hs);
let _ = dof(&d, &[Handle(0), Handle(u64::MAX)]);
}
#[test]
fn vertex_constraints_on_large_fit_spline_do_not_charge_reconstruction() {
let mut d = Drawing::default();
let pts: Vec<_> = (0..500).map(|i| Vec2::new(i as f64, (i % 3) as f64)).collect();
let mut sp = GSpline::from_control(pts.clone(), 3);
sp.fit = pts;
let h = put(&mut d, EntityKind::Spline(sp));
for i in 0..5 {
let p = point(&mut d, (i as f64, (i % 3) as f64));
let mut c = con(K::Coincident, &[s(h, Sub::Vertex(i)), w(p)]);
c.id = i + 1;
d.constraints.push(c);
}
let report = dof(&d, &[]).unwrap();
assert_eq!(report.status, DofStatus::Under);
assert_eq!(report.dof, Some(1000));
}
+8 -1
View File
@@ -248,10 +248,17 @@ impl ArrayParams {
return false;
}
let Some(items) = self.items() else { return false };
// Index the replacement entries by placement so each item is matched in O(1) instead of
// scanning the whole `replaced` vector per item, which is quadratic when both the items
// and the replacements are large (CWE-407). Last entry wins, as `.rev().find` did.
let mut last_replaced: std::collections::HashMap<(u32, u32, u32), usize> = std::collections::HashMap::with_capacity(self.replaced.len());
for (idx, r) in self.replaced.iter().enumerate() {
last_replaced.insert((r.i, r.j, r.level), idx);
}
let mut source: Option<&str> = None;
for (it, e) in items.iter().zip(&ents) {
let EntityKind::Insert(ins) = &e.kind else { return false };
match self.replaced.iter_mut().rev().find(|r| (r.i, r.j, r.level) == (it.i, it.j, it.level)) {
match last_replaced.get(&(it.i, it.j, it.level)).and_then(|&idx| self.replaced.get_mut(idx)) {
Some(r) => r.block.clone_from(&ins.block),
None => match source {
None => source = Some(&ins.block),
+5 -1
View File
@@ -334,7 +334,11 @@ fn graph_members<'a>(objects: &HashMap<Handle, &'a ForeignObject>, xdict: Handle
}
let graph = objects.get(&graph?).filter(|o| o.kind.eq_ignore_ascii_case("ACAD_EVALUATION_GRAPH"))?;
let members = section(&sections(&graph.tags), "AcDbEvalGraph").iter().filter(|t| t.code == 360).filter_map(|t| Handle::parse_hex(&t.value));
Some(members.take(MAX_NODES).filter_map(|h| objects.get(&h).copied()).collect())
// Deduplicate member handles before resolving them: a graph can list the same member many
// times, and parsing each occurrence into its own owned parameter multiplies a single large
// record into gigabytes of retained allocations (CWE-400).
let mut seen = std::collections::HashSet::new();
Some(members.take(MAX_NODES).filter(|h| seen.insert(*h)).filter_map(|h| objects.get(&h).copied()).collect())
}
/// The dynamic block data of block `name` (any case; `*Model_Space` for a drawing that is a
+9 -1
View File
@@ -464,7 +464,15 @@ fn apply(
if !(v.x.is_finite() && v.y.is_finite()) || (v.x == 0.0 && v.y == 0.0) {
continue;
}
// Index this action's vertex lists by handle once, and skip handles already handled in
// this action's selection, so a selection that repeats a handle does not re-scan the
// vertex list and re-clone its index vector per occurrence (CWE-400).
let vtx_by_handle: HashMap<Handle, &Vec<i32>> = vertices.iter().rev().map(|(vh, i)| (*vh, i)).collect();
let mut handled: HashSet<Handle> = HashSet::new();
for h in &a.selection {
if !handled.insert(*h) {
continue;
}
if let Some(node) = param_of.get(h) {
// A parameter in the selection is carried along: its points in the frame. Which
// points are in it is decided where the dynamic block has them (AutoCAD evaluates
@@ -489,7 +497,7 @@ fn apply(
continue;
}
let Some(c) = copy_of(h) else { continue };
let idx = vertices.iter().find(|(vh, _)| vh == h).map(|(_, i)| i.clone());
let idx = vtx_by_handle.get(h).map(|i| (*i).clone());
moves.entry(c).or_default().push((idx, v));
}
}
+22 -1
View File
@@ -1408,9 +1408,12 @@ impl EntityKind {
EntityKind::LwPolyline(p) => {
let mut g: Vec<Vec2> = Vec::new();
let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed };
// Compute the segments once, not once per vertex: rebuilding the whole segment
// vector inside the loop is quadratic in the vertex count (CWE-407).
let segs = pl.segments();
for (i, v) in p.vertices.iter().enumerate() {
g.push(v.p);
if let Some(s) = pl.segments().get(i) {
if let Some(s) = segs.get(i) {
g.push(s.mid());
}
}
@@ -1468,3 +1471,21 @@ fn transform_text(t: &mut Text, m: &Mat3, s: f64, rot: f64, mirror: bool) {
// MIRRTEXT = 0 (the default): mirrored text keeps reading left to right.
t.rotation = cadcraft_geom::norm_angle(t.rotation + if mirror { 0.0 } else { rot });
}
#[cfg(test)]
mod grip_tests {
use super::*;
use cadcraft_geom::PolyVertex;
#[test]
fn polyline_grips_are_vertices_and_midpoints() {
// Grips are each vertex plus the midpoint of each segment; the segment vector is computed
// once rather than rebuilt per vertex (CWE-407, row 35).
let verts: Vec<PolyVertex> = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)].iter().map(|&(x, y)| PolyVertex::new(Vec2::new(x, y))).collect();
let e = EntityKind::LwPolyline(LwPolyline { vertices: verts, closed: false, const_width: 0.0, elevation: 0.0, plinegen: false });
let g = e.grips();
// 3 vertices + 2 segment midpoints.
assert_eq!(g.len(), 5, "{g:?}");
assert!(g.contains(&Vec2::new(0.0, 0.0)) && g.contains(&Vec2::new(5.0, 0.0)));
}
}
+15 -3
View File
@@ -165,9 +165,21 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<
if let Some(blk) = dm.block.as_ref().and_then(|n| d.block(n))
&& depth < MAX_BLOCK_DEPTH
{
for be in blk.entities.iter().filter(|be| in_block_visible(d, be)) {
b = b.union(&bounds_in(d, be, depth + 1, blocks));
}
// Reuse the shared block-bounds cache (as the Insert branch does) so a dimension
// block with branching or self-referencing children costs one pass per definition
// per depth, not one per reference (which grows exponentially) (CWE-400).
let inner = match blocks.get(&(blk.name.as_str(), depth)) {
Some(cached) => *cached,
None => {
let mut inner = Bounds2::EMPTY;
for be in blk.entities.iter().filter(|be| in_block_visible(d, be)) {
inner = inner.union(&bounds_in(d, be, depth + 1, blocks));
}
blocks.insert((blk.name.as_str(), depth), inner);
inner
}
};
b = b.union(&inner);
}
b
}
+3
View File
@@ -752,6 +752,8 @@ pub struct PageSetup {
pub landscape: bool,
/// "layout" | "extents" | "display" | "window" | "limits".
pub plot_area: String,
/// Plot window corners `[x1, y1, x2, y2]` (DXF plot layout groups 48, 49, 140, 141); used when `plot_area` is "window".
pub plot_window: [f64; 4],
pub scale_to_fit: bool,
/// Paper units per drawing unit.
pub scale: f64,
@@ -779,6 +781,7 @@ impl Default for PageSetup {
margins_mm: [6.35, 6.35, 6.35, 6.35],
landscape: true,
plot_area: "layout".into(),
plot_window: [0.0; 4],
scale_to_fit: false,
scale: 1.0,
plot_style_table: String::new(),
+27
View File
@@ -494,6 +494,16 @@ mod native {
}
}
/// The current text style (`$TEXTSTYLE`) by its name, the same way as [`current_dimstyle`]:
/// the handle read with the DXF needn't be the style's in the document, in which case the DWG
/// writer fell back to Standard and AutoCAD opened the drawing with Standard current (#1650).
fn current_text_style(doc: &mut acadrust::CadDocument) {
let name = doc.header.current_text_style_name.clone();
if let Some(h) = doc.text_styles.iter().find(|ts| ts.name.eq_ignore_ascii_case(&name)).map(|ts| ts.handle) {
doc.header.current_text_style_handle = h;
}
}
fn convert(dxf: &[u8], version: Option<acadrust::DxfVersion>) -> Result<Vec<u8>, String> {
let data = dxf.to_vec();
let mut doc = std::panic::catch_unwind(move || acadrust::DxfReader::from_reader(Cursor::new(data)).and_then(|r| r.read()))
@@ -512,6 +522,7 @@ mod native {
dimension_flags(&mut doc, true);
drop_unwritable(&mut doc);
current_dimstyle(&mut doc);
current_text_style(&mut doc);
std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DwgWriter::write_to_vec(&doc)))
.map_err(|_| "the DWG writer failed on this drawing".to_string())?
.map_err(|e| format!("DWG write: {e}"))
@@ -585,6 +596,22 @@ mod tests {
assert_eq!(version(b"AC1018xxxx").as_deref(), Some("AC1018"));
}
/// A DXF with ROMANS as `$TEXTSTYLE` gives a DWG whose current text style is ROMANS, not
/// Standard: the DXF names the style, and the handle read with it was Standard's (#1650).
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn current_text_style_reaches_the_dwg() {
let header = " 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 9\n$TEXTSTYLE\n 7\nROMANS\n 0\nENDSEC\n";
let style = " 0\nSTYLE\n 5\n1F0\n100\nAcDbSymbolTableRecord\n100\nAcDbTextStyleTableRecord\n 2\nROMANS\n 70\n0\n";
let style = format!("{style} 40\n0\n 41\n1\n 50\n0\n 71\n0\n 42\n2.5\n 3\nromans.shx\n 4\n\n");
let dxf = format!("{header} 0\nSECTION\n 2\nTABLES\n 0\nTABLE\n 2\nSTYLE\n 70\n1\n{style} 0\nENDTAB\n 0\nENDSEC\n 0\nEOF\n");
let dwg = dxf_to_dwg(dxf.as_bytes()).unwrap();
let doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(&dwg)).read().unwrap();
let romans = doc.text_styles.iter().find(|ts| ts.name == "ROMANS").map(|ts| ts.handle);
assert!(romans.is_some(), "ROMANS is missing from the DWG");
assert_eq!(Some(doc.header.current_text_style_handle), romans);
}
#[test]
fn garbage_is_an_error_not_a_crash() {
assert!(dwg_to_dxf(b"AC1032 this is not a dwg file at all").is_err());
+3
View File
@@ -24,6 +24,9 @@ log = { workspace = true }
# R-tree spatial index for picking, window selection and object snaps (MIT OR Apache-2.0).
rstar = { version = "0.12", default-features = false }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[dev-dependencies]
# Real DXF files for the external reference tests (io is a lower layer).
cadcraft-io = { workspace = true }
+163
View File
@@ -0,0 +1,163 @@
//! Safe, atomic file replacement.
//!
//! Save, export, plot, autosave and the audit log all write a file by first putting the bytes in
//! a sibling temporary file and then renaming it over the destination, so a failed or partial
//! write never truncates the real file. The temporary file must be created *exclusively*: opening
//! a predictable, pre-existing temporary path for a truncating write lets another local user plant
//! a symlink there and redirect the write onto a file the victim can write but the attacker chose
//! (CWE-59). [`write_atomic`] creates a fresh temporary file with `create_new` (`O_CREAT|O_EXCL`),
//! so an existing entry at that path — symlink or not — makes the create fail rather than follow
//! it. The handle is retained for the whole write, and only its own fresh file is renamed into
//! place.
#![cfg(not(target_arch = "wasm32"))]
use std::fs::{File, OpenOptions, Permissions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
/// Monotonic counter making temporary names unique within this process without a RNG dependency.
static SEQ: AtomicU64 = AtomicU64::new(0);
/// How many fresh temporary names to try before giving up (each collision is another local race).
const MAX_TRIES: u32 = 32;
/// Write `bytes` to `path`, replacing it atomically and without ever following a pre-existing
/// temporary symlink.
///
/// A fresh temporary file is created with exclusive semantics in `path`'s own directory, the bytes
/// are written through the retained handle and flushed, and the temporary is then renamed over
/// `path`. On any failure the temporary is removed and the original `path` is left untouched.
pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
let permissions = match std::fs::symlink_metadata(path) {
Ok(meta) if meta.is_file() => Some(meta.permissions()),
Ok(_) => None,
Err(e) if e.kind() == io::ErrorKind::NotFound => None,
Err(e) => return Err(e),
};
let dir = path.parent().filter(|p| !p.as_os_str().is_empty()).map_or_else(|| PathBuf::from("."), Path::to_path_buf);
let (mut file, tmp) = create_temp(&dir, permissions.as_ref())?;
let write = (|| {
if let Some(permissions) = permissions {
file.set_permissions(permissions)?;
}
file.write_all(bytes)?;
file.flush()?;
file.sync_all()
})();
if let Err(e) = write {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
drop(file);
if let Err(e) = std::fs::rename(&tmp, path) {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
Ok(())
}
/// Create a fresh, exclusively-owned temporary file inside `dir`, returning its handle and path.
///
/// `create_new(true)` opens with `O_CREAT | O_EXCL`, which refuses to follow or reuse an existing
/// entry (including a symlink), closing the plant-a-symlink race. Names are unpredictable enough to
/// avoid collisions in practice, and genuine collisions are retried with a new name.
fn create_temp(dir: &Path, permissions: Option<&Permissions>) -> io::Result<(File, PathBuf)> {
let pid = std::process::id();
let mut last_err = None;
for _ in 0..MAX_TRIES {
let seq = SEQ.fetch_add(1, Ordering::Relaxed);
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos()).unwrap_or(0);
let tmp = dir.join(format!(".cadcraft-tmp-{pid}-{seq}-{nanos}"));
match create_exclusive(&tmp, permissions) {
Ok(file) => return Ok((file, tmp)),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
last_err = Some(e);
continue;
}
Err(e) => return Err(e),
}
}
Err(last_err.unwrap_or_else(|| io::Error::new(io::ErrorKind::AlreadyExists, "could not create a unique temporary file")))
}
fn create_exclusive(path: &Path, permissions: Option<&Permissions>) -> io::Result<File> {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
if let Some(permissions) = permissions {
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
// Restrict access in the create syscall: chmod afterwards leaves a window in which a
// different user can open the inode and retain read access to bytes written later.
options.mode(permissions.mode() & 0o777);
}
#[cfg(not(unix))]
let _ = permissions;
options.open(path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn replaces_contents_atomically() {
let dir = std::env::temp_dir().join(format!("cadcraft-aw-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir_all(&dir).unwrap();
let dest = dir.join("out.bin");
std::fs::write(&dest, b"old").unwrap();
write_atomic(&dest, b"new bytes").unwrap();
assert_eq!(std::fs::read(&dest).unwrap(), b"new bytes");
// No temporary files left behind.
let leftovers: Vec<_> =
std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name()).filter(|n| n.to_string_lossy().starts_with(".cadcraft-tmp")).collect();
assert!(leftovers.is_empty(), "left temp files: {leftovers:?}");
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(unix)]
#[test]
fn does_not_follow_a_planted_temp_symlink() {
use std::os::unix::fs::symlink;
let dir = std::env::temp_dir().join(format!("cadcraft-aw-sym-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir_all(&dir).unwrap();
let victim = dir.join("victim");
std::fs::write(&victim, b"precious").unwrap();
let planted = dir.join(".cadcraft-tmp-plant");
symlink(&victim, &planted).unwrap();
assert_eq!(create_exclusive(&planted, None).unwrap_err().kind(), io::ErrorKind::AlreadyExists);
let dest = dir.join("drawing.dxf");
write_atomic(&dest, b"drawing bytes").unwrap();
assert_eq!(std::fs::read(&victim).unwrap(), b"precious", "victim file was overwritten");
assert_eq!(std::fs::read(&dest).unwrap(), b"drawing bytes");
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(unix)]
#[test]
fn replacement_preserves_private_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("cadcraft-aw-mode-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir_all(&dir).unwrap();
let dest = dir.join("private.adt");
std::fs::write(&dest, b"old").unwrap();
std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o600)).unwrap();
write_atomic(&dest, b"private report").unwrap();
assert_eq!(std::fs::metadata(&dest).unwrap().permissions().mode() & 0o777, 0o600);
assert_eq!(std::fs::read(&dest).unwrap(), b"private report");
std::fs::remove_dir_all(dir).unwrap();
}
#[cfg(unix)]
#[test]
fn private_temp_is_restricted_from_creation() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("cadcraft-aw-create-mode-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("private-temp");
let permissions = Permissions::from_mode(0o600);
let file = create_exclusive(&path, Some(&permissions)).unwrap();
assert_eq!(file.metadata().unwrap().permissions().mode() & 0o077, 0);
drop(file);
std::fs::remove_dir_all(dir).unwrap();
}
}
+3 -3
View File
@@ -339,9 +339,9 @@ mod native {
if let Some(dir) = Path::new(path).parent() {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
}
let tmp = format!("{path}.tmp");
std::fs::write(&tmp, bytes).map_err(|e| e.to_string())?;
std::fs::rename(&tmp, path).map_err(|e| e.to_string())
// Create a fresh temporary file exclusively and rename it into place, so a planted
// `<path>.tmp` symlink in a shared autosave directory cannot redirect the write (CWE-59).
crate::atomic_write::write_atomic(Path::new(path), &bytes).map_err(|e| e.to_string())
}
impl Autosaver {
+81
View File
@@ -0,0 +1,81 @@
//! Bounded reads of local files.
//!
//! Reading a path with [`std::fs::read`] buffers until EOF, so a path naming an endless readable
//! device (for example `/dev/urandom` or a FIFO) exhausts process memory before any size check
//! runs (CWE-400). [`read_capped`] opens the path once, refuses anything that is not a regular
//! file, and reads through a reader limited to `max + 1` bytes so oversized input is rejected
//! before it is fully buffered.
#![cfg(not(target_arch = "wasm32"))]
use std::fs::OpenOptions;
use std::io::{self, Read};
use std::path::Path;
/// Read at most `max` bytes from the regular file at `path`.
///
/// Returns an error if the path is not a regular file, cannot be opened, or holds more than `max`
/// bytes. At most `max + 1` bytes are ever buffered, so an endless device cannot exhaust memory.
pub fn read_capped(path: &Path, max: usize) -> io::Result<Vec<u8>> {
let mut options = OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NONBLOCK);
}
let file = options.open(path)?;
let meta = file.metadata()?;
if !meta.is_file() {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "not a regular file"));
}
// Read one byte past the limit so we can tell "exactly max" from "too big" without trusting
// the metadata length (which a device may misreport).
let mut buf = Vec::new();
let cap = (max as u64).saturating_add(1);
let read = file.take(cap).read_to_end(&mut buf)?;
if read as u64 > max as u64 {
return Err(io::Error::new(io::ErrorKind::InvalidData, format!("file is larger than the {max}-byte limit")));
}
Ok(buf)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_within_limit_and_rejects_oversize() {
let dir = std::env::temp_dir().join(format!("cadcraft-cr-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let p = dir.join("f.bin");
std::fs::write(&p, vec![7u8; 100]).unwrap();
assert_eq!(read_capped(&p, 100).unwrap().len(), 100);
assert_eq!(read_capped(&p, 200).unwrap().len(), 100);
assert!(read_capped(&p, 99).is_err());
assert_eq!(read_capped(&p, usize::MAX).unwrap().len(), 100);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(unix)]
#[test]
fn rejects_non_regular_file() {
// /dev/zero is an endless readable device; it must be rejected, not buffered.
let p = Path::new("/dev/zero");
if p.exists() {
assert!(read_capped(p, 8 << 20).is_err());
}
}
#[cfg(unix)]
#[test]
fn rejects_fifo_without_a_writer() {
let dir = std::env::temp_dir().join(format!("cadcraft-cr-fifo-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let fifo = dir.join("pipe");
assert!(std::process::Command::new("mkfifo").arg(&fifo).status().unwrap().success());
let started = std::time::Instant::now();
assert_eq!(read_capped(&fifo, 1024).unwrap_err().kind(), io::ErrorKind::InvalidInput);
assert!(started.elapsed() < std::time::Duration::from_secs(2));
std::fs::remove_dir_all(dir).unwrap();
}
}
+6 -6
View File
@@ -427,13 +427,13 @@ fn mleader_kind(s: &Session, pts: &[Vec2], text: &str, landing: bool) -> Option<
let d = s.doc().ok()?;
let cur = d.header.str("CMLEADERSTYLE", "Standard");
let st = d.mleader_styles.iter().find(|m| m.name.eq_ignore_ascii_case(&cur)).or(d.mleader_styles.first()).cloned().unwrap_or_default();
// An annotative style's sizes are paper sizes: the annotation scale applies when drawn.
let k = if st.annotative { 1.0 } else { d.header.f64("DIMSCALE", 1.0) };
let th = st.text_height * k;
// The style's sizes are the drawn sizes: a multileader style has no scale of its own and
// DIMSCALE (a dimension-style setting) does not apply, as in AutoCAD.
let th = st.text_height;
let dir = if arrow_pts.first().is_some_and(|a| a.x > landing_pt.x) { -1.0 } else { 1.0 };
let attach = if dir > 0.0 { 4 } else { 6 };
let dogleg = if landing { st.dogleg * k } else { 0.0 };
let tpos = *landing_pt + Vec2::new((dogleg + st.landing_gap * k) * dir, 0.0);
let dogleg = if landing { st.dogleg } else { 0.0 };
let tpos = *landing_pt + Vec2::new((dogleg + st.landing_gap) * dir, 0.0);
Some(EntityKind::MLeader(MLeader {
leaders: vec![arrow_pts.iter().map(|p| v3(*p)).collect()],
landing: v3(*landing_pt),
@@ -451,7 +451,7 @@ fn mleader_kind(s: &Session, pts: &[Vec2], text: &str, landing: bool) -> Option<
columns: None,
}),
style: st.name.clone(),
arrow_size: st.arrow_size * k,
arrow_size: st.arrow_size,
spline: false,
block: None,
}))
+4 -1
View File
@@ -142,7 +142,10 @@ fn write_audit_log(s: &Session, report: &crate::audit::Report) {
let Some(path) = &path_str else { return };
let adt_path = std::path::PathBuf::from(path).with_extension("adt");
let content = report.format_adt(&title, path_str.as_deref());
if let Err(e) = std::fs::write(&adt_path, content) {
// Write through an exclusively-created temporary and rename over the companion path, so a
// planted `.adt` symlink beside the drawing cannot redirect the report onto another file
// writable by the victim (CWE-59).
if let Err(e) = crate::atomic_write::write_atomic(&adt_path, content.as_bytes()) {
log::warn!("AUDITCTL: could not write audit log {}: {e}", adt_path.display());
}
}
+3 -4
View File
@@ -79,10 +79,9 @@ fn extension(path: &str) -> String {
fn write_file(cmd: &str, path: &str, bytes: &[u8]) -> Result<()> {
#[cfg(not(target_arch = "wasm32"))]
{
let tmp = format!("{path}.cadcraft-tmp");
std::fs::write(&tmp, bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
std::fs::rename(&tmp, path).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
Ok(())
// Exclusively create a fresh temporary file and rename it into place so a planted
// `<path>.cadcraft-tmp` symlink cannot redirect the export write (CWE-59).
crate::atomic_write::write_atomic(std::path::Path::new(path), bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
{
+18 -7
View File
@@ -19,6 +19,15 @@ pub use pgp::{MAX_ALIASES, alias_name, command_id};
pub(crate) use pgp::{specs as alias_specs, user_alias};
pub use template::{qnew_template, set_qnew_template};
/// Largest drawing accepted from a local path, matching the per-file xref limit. A path naming an
/// endless device must be rejected before it exhausts memory (CWE-400), so native opens read
/// through [`crate::capped_read`] rather than buffering to EOF.
#[cfg(not(target_arch = "wasm32"))]
const MAX_DRAWING_BYTES: usize = 512 << 20;
/// Largest `.scr` script accepted from a local path.
#[cfg(not(target_arch = "wasm32"))]
const MAX_SCRIPT_BYTES: usize = 64 << 20;
/// Plot a space to PDF bytes with JSON options (`{paper?, landscape?, fit?, lineweights?, …}`).
pub type PlotHook = fn(&Drawing, &cadcraft_doc::Space, &Value) -> std::result::Result<Vec<u8>, String>;
@@ -192,7 +201,8 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
let path = _path;
#[cfg(not(target_arch = "wasm32"))]
{
let bytes = std::fs::read(path).map_err(|e| bad("script", format!("{path}: {e}")))?;
let bytes =
crate::capped_read::read_capped(std::path::Path::new(path), MAX_SCRIPT_BYTES).map_err(|e| bad("script", format!("{path}: {e}")))?;
String::from_utf8_lossy(&bytes).into_owned()
}
#[cfg(target_arch = "wasm32")]
@@ -265,7 +275,8 @@ pub fn read_file(path: &str) -> std::result::Result<ReadFile, String> {
#[cfg(not(target_arch = "wasm32"))]
{
let r = std::panic::catch_unwind(|| {
let bytes = std::fs::read(path).map_err(|e| bad("open", format!("{path}: {e}")))?;
let bytes =
crate::capped_read::read_capped(std::path::Path::new(path), MAX_DRAWING_BYTES).map_err(|e| bad("open", format!("{path}: {e}")))?;
read_bytes(&bytes, path, Some(path.to_string()))
});
match r {
@@ -332,7 +343,8 @@ fn run_open(s: &mut Session, p: &Value) -> Result<Value> {
let path = _path;
#[cfg(not(target_arch = "wasm32"))]
{
let bytes = std::fs::read(path).map_err(|e| bad("open", format!("{path}: {e}")))?;
let bytes =
crate::capped_read::read_capped(std::path::Path::new(path), MAX_DRAWING_BYTES).map_err(|e| bad("open", format!("{path}: {e}")))?;
let i = open_bytes(s, &bytes, path, Some(path.to_string()))?;
super::blocks::xref::after_open(s, i);
return Ok(opened(s, i, true));
@@ -403,10 +415,9 @@ fn save_as(s: &mut Session, cmd: &str, path: &str, opts: &SaveAs) -> Result<usiz
fn store(path: &str, bytes: &[u8]) -> Result<()> {
#[cfg(not(target_arch = "wasm32"))]
{
let tmp = format!("{path}.cadcraft-tmp");
std::fs::write(&tmp, bytes).map_err(|e| bad("save", format!("{path}: {e}")))?;
std::fs::rename(&tmp, path).map_err(|e| bad("save", format!("{path}: {e}")))?;
Ok(())
// Exclusively create a fresh temporary file and rename it over `path`, so a planted
// `<path>.cadcraft-tmp` symlink cannot redirect the write onto another file (CWE-59).
crate::atomic_write::write_atomic(std::path::Path::new(path), bytes).map_err(|e| bad("save", format!("{path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
{
+3 -3
View File
@@ -826,9 +826,9 @@ fn run_plot(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
#[cfg(not(target_arch = "wasm32"))]
{
let path = _path;
let tmp = format!("{path}.cadcraft-tmp");
std::fs::write(&tmp, &bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
std::fs::rename(&tmp, path).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
// Exclusively create a fresh temporary file and rename it into place so a planted
// `<path>.cadcraft-tmp` symlink cannot redirect the plot write (CWE-59).
crate::atomic_write::write_atomic(std::path::Path::new(path), &bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
Ok(json!({ "path": path, "bytes": bytes.len(), "layout": layout, "pages": pages }))
}
#[cfg(target_arch = "wasm32")]
+4
View File
@@ -9,9 +9,13 @@
#![forbid(unsafe_code)]
pub mod assoc;
#[cfg(not(target_arch = "wasm32"))]
pub mod atomic_write;
pub mod audit;
pub mod autosave;
pub mod blockedit;
#[cfg(not(target_arch = "wasm32"))]
pub mod capped_read;
pub mod cmd;
pub mod doc_path;
mod finite;
+144 -27
View File
@@ -307,11 +307,32 @@ fn chain(a: &[Value], ok: fn(std::cmp::Ordering) -> bool) -> R {
}
Ok(Value::t())
}
/// `=` and `/=`: numbers and strings compare as [`compare`] does, and any other atom compares by
/// what it is, as `eq` does. AutoLISP answers `(= (type x) 'LIST)` -- the idiom for a type test,
/// since `type` returns a symbol -- where coercing that symbol to a number failed the whole
/// routine with "bad argument type" instead (#1597). A mismatch is `nil`, not an error, so
/// `(= 1 "1")` and `(= '(1) '(1))` answer too. The ordering comparisons stay numbers and strings
/// only, as AutoLISP has them.
fn eq_chain(a: &[Value], want_equal: bool) -> R {
arity(a, 1, usize::MAX)?;
for w in a.windows(2) {
let [x, y] = w else { continue };
let same = match (x, y) {
(Value::Str(_), Value::Str(_)) => compare(x, y)?.is_eq(),
_ if x.as_number().is_some() && y.as_number().is_some() => compare(x, y)?.is_eq(),
_ => eq(x, y),
};
if same != want_equal {
return Ok(Value::Nil);
}
}
Ok(Value::t())
}
fn num_eq(a: &[Value]) -> R {
chain(a, |o| o.is_eq())
eq_chain(a, true)
}
fn num_ne(a: &[Value]) -> R {
chain(a, |o| o.is_ne())
eq_chain(a, false)
}
fn lt(a: &[Value]) -> R {
chain(a, |o| o.is_lt())
@@ -658,19 +679,44 @@ fn wcmatch(a: &[Value]) -> R {
arity(a, 2, 2)?;
let (s, pat) = (string(&a[0])?, string(&a[1])?);
let text: Vec<char> = s.chars().collect();
Ok(Value::bool(split_patterns(&pat).iter().any(|p| match_pattern(&text, p))))
// Native wildcard matching runs as a single interpreter step, so its internal `*` backtracking
// must not explore exponentially many placements unchecked (CWE-400). Charge every glob step
// against a shared budget and fail the call if it is exhausted.
let mut budget: u64 = WCMATCH_BUDGET;
for p in split_patterns(&pat, &mut budget).map_err(|()| LispError::new("wcmatch: pattern too complex"))? {
match match_pattern(&text, &p, &mut budget) {
Ok(true) => return Ok(Value::bool(true)),
Ok(false) => {}
Err(()) => return Err(LispError::new("wcmatch: pattern too complex")),
}
}
Ok(Value::bool(false))
}
fn split_patterns(pat: &str) -> Vec<Vec<char>> {
/// Shared step budget for one `wcmatch` call's wildcard matching. Large enough for the patterns
/// AutoLISP routines legitimately use (including the pathological-but-bounded cases the tests
/// cover), but finite so truly exponential `*`-heavy patterns are rejected rather than hanging.
const WCMATCH_BUDGET: u64 = 200_000_000;
fn charge_match(budget: &mut u64) -> Result<(), ()> {
*budget = budget.checked_sub(1).ok_or(())?;
Ok(())
}
fn split_patterns(pat: &str, budget: &mut u64) -> Result<Vec<Vec<char>>, ()> {
let mut out = vec![Vec::new()];
let mut chars = pat.chars().peekable();
let mut in_set = false;
while let Some(c) = chars.next() {
charge_match(budget)?;
match c {
'`' => {
if let Some(cur) = out.last_mut() {
cur.push('`');
cur.extend(chars.next());
if let Some(escaped) = chars.next() {
charge_match(budget)?;
cur.push(escaped);
}
}
}
'[' => {
@@ -693,41 +739,93 @@ fn split_patterns(pat: &str) -> Vec<Vec<char>> {
}
}
}
out
Ok(out)
}
fn match_pattern(text: &[char], pat: &[char]) -> bool {
fn match_pattern(text: &[char], pat: &[char], budget: &mut u64) -> Result<bool, ()> {
if let Some(('~', rest)) = pat.split_first().map(|(c, r)| (*c, r))
&& !rest.is_empty()
{
return !glob(text, rest, 0);
return glob(text, rest, 0, budget).map(|m| !m);
}
glob(text, pat, 0)
glob(text, pat, 0, budget)
}
/// Iterative-enough glob with `*` backtracking; `depth` bounds the recursion on `*`.
fn glob(text: &[char], pat: &[char], depth: usize) -> bool {
/// Iterative-enough glob with `*` backtracking; `depth` bounds the recursion on `*`. Every step is
/// charged against `budget`; `Err(())` means the shared budget ran out (treated as too complex).
fn glob(text: &[char], pat: &[char], depth: usize, budget: &mut u64) -> Result<bool, ()> {
glob_cached(text, pat, depth, budget, &mut std::collections::HashSet::new())
}
fn glob_cached(
text: &[char],
pat: &[char],
depth: usize,
budget: &mut u64,
failed: &mut std::collections::HashSet<(usize, usize, usize)>,
) -> Result<bool, ()> {
charge_match(budget)?;
let key = (text.len(), pat.len(), depth);
if failed.contains(&key) {
return Ok(false);
}
let matched = glob_inner(text, pat, depth, budget, failed)?;
// Avoid revisiting the same failed suffix exponentially. Bound the memo table independently
// of the step limit so a very large pattern cannot turn the optimization into an allocation bomb.
if !matched && failed.len() < 100_000 {
failed.insert(key);
}
Ok(matched)
}
fn glob_inner(
text: &[char],
pat: &[char],
depth: usize,
budget: &mut u64,
failed: &mut std::collections::HashSet<(usize, usize, usize)>,
) -> Result<bool, ()> {
if *budget == 0 {
return Err(());
}
*budget -= 1;
if depth > 64 {
return false;
return Ok(false);
}
let (mut t, mut p) = (0usize, 0usize);
while p < pat.len() {
charge_match(budget)?;
match pat[p] {
'*' => {
let rest = &pat[p + 1..];
return (t..=text.len()).any(|i| glob(&text[i..], rest, depth + 1));
for i in t..=text.len() {
if glob_cached(&text[i..], rest, depth + 1, budget, failed)? {
return Ok(true);
}
}
return Ok(false);
}
'`' => {
let Some(&lit) = pat.get(p + 1) else { return false };
let Some(&lit) = pat.get(p + 1) else { return Ok(false) };
if text.get(t) != Some(&lit) {
return false;
return Ok(false);
}
t += 1;
p += 2;
}
'[' => {
let Some(close) = pat[p..].iter().position(|c| *c == ']').map(|i| p + i) else { return false };
let Some(&c) = text.get(t) else { return false };
let mut close = p;
while let Some(c) = pat.get(close) {
charge_match(budget)?;
if *c == ']' {
break;
}
close += 1;
}
if close == pat.len() {
return Ok(false);
}
let Some(&c) = text.get(t) else { return Ok(false) };
let set = &pat[p + 1..close];
let (neg, set) = match set.split_first() {
Some(('~', r)) if !r.is_empty() => (true, r),
@@ -736,6 +834,7 @@ fn glob(text: &[char], pat: &[char], depth: usize) -> bool {
let mut hit = false;
let mut i = 0;
while i < set.len() {
charge_match(budget)?;
if set.get(i + 1) == Some(&'-') && i + 2 < set.len() {
if set[i] <= c && c <= set[i + 2] {
hit = true;
@@ -749,13 +848,13 @@ fn glob(text: &[char], pat: &[char], depth: usize) -> bool {
}
}
if hit == neg {
return false;
return Ok(false);
}
t += 1;
p = close + 1;
}
pc => {
let Some(&c) = text.get(t) else { return false };
let Some(&c) = text.get(t) else { return Ok(false) };
let ok = match pc {
'#' => c.is_ascii_digit(),
'@' => c.is_alphabetic(),
@@ -764,14 +863,14 @@ fn glob(text: &[char], pat: &[char], depth: usize) -> bool {
other => other == c,
};
if !ok {
return false;
return Ok(false);
}
t += 1;
p += 1;
}
}
}
t == text.len()
Ok(t == text.len())
}
// ---------------- geometry ----------------
@@ -963,11 +1062,10 @@ fn findfile(a: &[Value]) -> R {
#[cfg(not(target_arch = "wasm32"))]
pub fn read_text_file(path: &str) -> Option<String> {
let bytes = std::fs::read(path).ok()?;
// Cap what a routine can pull into memory.
if bytes.len() > 64 * 1024 * 1024 {
return None;
}
// Read through a byte-limited, regular-file-only reader so a path naming an endless device
// cannot exhaust memory before the 64 MiB cap is enforced (CWE-400).
const MAX: usize = 64 * 1024 * 1024;
let bytes = crate::capped_read::read_capped(std::path::Path::new(path), MAX).ok()?;
Some(String::from_utf8_lossy(&bytes).into_owned())
}
#[cfg(target_arch = "wasm32")]
@@ -1482,5 +1580,24 @@ pub fn real(r: f64) -> String {
/// `wcmatch` for other callers (`ssget` filters).
pub fn wild_match(text: &str, pattern: &str) -> bool {
let t: Vec<char> = text.chars().collect();
split_patterns(pattern).iter().any(|p| match_pattern(&t, p))
let mut budget: u64 = WCMATCH_BUDGET;
split_patterns(pattern, &mut budget).is_ok_and(|patterns| patterns.iter().any(|p| match_pattern(&t, p, &mut budget) == Ok(true)))
}
#[cfg(test)]
mod wildcard_budget_tests {
use super::*;
#[test]
fn all_native_scans_consume_fuel() {
assert!(split_patterns("abcdefgh", &mut 4).is_err());
assert!(split_patterns("`a`b`c", &mut 4).is_err());
let text: Vec<_> = "abcdefgh".chars().collect();
assert!(glob(&text, &text, 0, &mut 4).is_err());
let pattern: Vec<_> = "[abcdefgh]".chars().collect();
assert!(glob(&['!'], &pattern, 0, &mut 6).is_err());
// Enough for finding the closing bracket, but not scanning the entire set again.
assert!(glob(&['!'], &pattern, 0, &mut 14).is_err());
assert_eq!(glob(&['a'], &pattern, 0, &mut 100), Ok(true));
}
}
+26 -6
View File
@@ -151,16 +151,32 @@ pub fn from_json(v: &serde_json::Value) -> Value {
}
}
/// A LISP value as JSON (for `setvar`).
pub fn to_json(v: &Value) -> serde_json::Value {
match v {
/// A LISP value as JSON (for `setvar`), bounded so a shared cons structure cannot expand into
/// exponentially many owned array elements before the setter can reject it (CWE-400).
///
/// Largest number of elements it may expand, and the deepest nesting it follows.
const TO_JSON_BUDGET: u64 = 2_000_000;
const TO_JSON_MAX_DEPTH: usize = 256;
fn to_json_bounded(v: &Value, depth: usize, budget: &mut u64) -> Option<serde_json::Value> {
if depth > TO_JSON_MAX_DEPTH || *budget == 0 {
return None;
}
*budget -= 1;
Some(match v {
Value::Nil => serde_json::Value::Null,
Value::Int(i) => serde_json::json!(i),
Value::Real(r) => serde_json::json!(r),
Value::Str(s) => serde_json::json!(&**s),
Value::Cons(_) => serde_json::Value::Array(v.iter().map(|x| to_json(&x)).collect()),
Value::Cons(_) => {
let mut arr = Vec::new();
for x in v.iter() {
arr.push(to_json_bounded(&x, depth + 1, budget)?);
}
serde_json::Value::Array(arr)
}
other => serde_json::json!(to_text(other, false)),
}
})
}
pub fn getvar(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
@@ -172,7 +188,11 @@ pub fn getvar(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
pub fn setvar(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
arity(&a, 2, 2)?;
let name = string(&a[0])?;
match crate::sysvars::set(ctx.s, &name, &to_json(&a[1])) {
let mut budget: u64 = TO_JSON_BUDGET;
let Some(json) = to_json_bounded(&a[1], 0, &mut budget) else {
return Err(LispError::new(format!("AutoCAD variable setting rejected: {} value too large", to_text(&a[0], true))));
};
match crate::sysvars::set(ctx.s, &name, &json) {
Ok(()) => Ok(Flow::Value(a[1].clone())),
Err(_) => Err(LispError::new(format!("AutoCAD variable setting rejected: {} {}", to_text(&a[0], true), to_text(&a[1], true)))),
}
+25 -5
View File
@@ -163,7 +163,8 @@ pub struct Ctx<'a> {
pub const MAX_STACK: usize = 100_000;
/// Nesting depth of synchronous callbacks (`vl-sort` comparing with a LISP function, …).
const MAX_SYNC_DEPTH: usize = 32;
/// Steps per slice while driving a synchronous callback ([`Ctx::call_sync`]).
const SYNC_SLICE: u64 = 400_000;
enum State {
Eval(Value),
Ret(Value),
@@ -645,12 +646,31 @@ impl Ctx<'_> {
super::value::charge_result(&v)?;
return Ok(v);
}
let cap = self.s.lisp.limits.callback_steps;
let mut m = Machine::calling(f.clone(), args);
let mut sub = Ctx { s: &mut *self.s, env: &mut *self.env, depth: self.depth + 1 };
match m.run(&mut sub, u64::MAX) {
Outcome::Done(v) => Ok(v),
Outcome::Error(e) => Err(e),
Outcome::Wait(_) => Err(LispError::new("this function can't ask for input here")),
// Drive the callback in bounded slices instead of granting it `u64::MAX` fuel. A `Yield`
// (the step budget ran out) is retried until a hard cap, so a constant-memory infinite
// loop inside a comparator or predicate terminates with an error rather than hanging the
// thread and bypassing the blocking execution cap (CWE-400).
let mut spent: u64 = 0;
loop {
let remaining = cap.saturating_sub(spent);
if remaining == 0 {
return Err(LispError::new(format!("callback stopped after {cap} steps")));
}
let slice = remaining.min(SYNC_SLICE);
match m.run(&mut sub, slice) {
Outcome::Done(v) => return Ok(v),
Outcome::Error(e) => return Err(e),
Outcome::Wait(Need::Yield) => {
spent = spent.saturating_add(slice);
if spent >= cap {
return Err(LispError::new(format!("callback stopped after {cap} steps")));
}
}
Outcome::Wait(_) => return Err(LispError::new("this function can't ask for input here")),
}
}
}
}
+6
View File
@@ -67,6 +67,12 @@ impl GetRequest {
let mut p = Prompt::new(message, accept).base_opt(base);
p.keywords = self.initget.keywords.clone();
p.raw = true;
// `entsel`/`nentsel` pick one object. The routine writes the message, so the prompt says
// so itself rather than being read for the word "Select" -- with a message of its own
// ("\nSelect the second line: ", or no mention of selecting at all) the pick box went
// missing, while the same call without one got it from the default "Select object: "
// (#1598).
p.picks_one = matches!(self.kind, GetKind::EntSel);
p
}
+3 -1
View File
@@ -126,11 +126,13 @@ pub(crate) struct Limits {
pub cells: usize,
/// Bytes of strings, printed text and files read per run.
pub string_bytes: u64,
/// Maximum interpreter steps per synchronous callback.
pub callback_steps: u64,
}
impl Default for Limits {
fn default() -> Self {
Limits { cells: value::MAX_CELLS, string_bytes: value::STRING_BUDGET }
Limits { cells: value::MAX_CELLS, string_bytes: value::STRING_BUDGET, callback_steps: 500_000_000 }
}
}
+122
View File
@@ -70,6 +70,39 @@ fn arithmetic_follows_autolisp() {
assert_eq!(eval(&mut s, "(+ 1 nil)"), Err("bad argument type: numberp: nil".into()));
}
/// `=` answers for the symbols `type` returns, which is how AutoLISP routines test a type:
/// `(= (type x) 'INT)`. Coercing the symbol to a number failed the whole routine with "bad
/// argument type" instead, so a routine written for AutoLISP stopped at its first type test
/// (#1597). The ordering comparisons are still numbers and strings, as AutoLISP has them.
#[test]
fn equality_compares_symbols_as_autolisp_does() {
let mut s = session();
assert_eq!(ok(&mut s, "(= (type 1) 'INT)"), "T");
assert_eq!(ok(&mut s, "(= (type 1.0) 'INT)"), "nil");
assert_eq!(ok(&mut s, "(= (type '(1 2)) 'LIST)"), "T");
assert_eq!(ok(&mut s, "(= (type \"a\") 'STR)"), "T");
assert_eq!(ok(&mut s, "(/= (type 1) 'REAL)"), "T");
assert_eq!(ok(&mut s, "(= 'a 'a 'a)"), "T");
assert_eq!(ok(&mut s, "(= 'a 'b)"), "nil");
// The routine from the report runs.
ok(&mut s, "(defun isint (a) (if (= (type a) 'INT) T nil))");
assert_eq!(ok(&mut s, "(isint 3)"), "T");
assert_eq!(ok(&mut s, "(isint 3.0)"), "nil");
assert_eq!(ok(&mut s, "(isint \"3\")"), "nil");
// A mismatch of kinds is nil, not an error; numbers and strings are unchanged.
assert_eq!(ok(&mut s, "(= 1 \"1\")"), "nil");
assert_eq!(ok(&mut s, "(= '(1) '(1))"), "nil", "`=` is not `equal`");
assert_eq!(ok(&mut s, "(= nil nil)"), "T");
assert_eq!(ok(&mut s, "(= 1 1.0)"), "T");
assert_eq!(ok(&mut s, "(= \"a\" \"a\" \"a\")"), "T");
assert_eq!(ok(&mut s, "(= 1 1 2)"), "nil");
// Ordering still refuses what AutoLISP refuses.
assert_eq!(eval(&mut s, "(< 'a 'b)"), Err("bad argument type: numberp: A".into()));
}
#[test]
fn lists_and_strings() {
let mut s = session();
@@ -188,6 +221,64 @@ fn runaway_routines_are_errors_not_crashes() {
assert_eq!(r, Ok("3000000".into()));
}
#[test]
fn synchronous_callback_loop_is_an_error_not_a_hang() {
// A comparator with an infinite loop used to run with u64::MAX fuel and hang the thread; it
// now terminates at the synchronous-callback step cap (CWE-400, finding row 2).
let mut s = session();
s.lisp.limits.callback_steps = 1_000;
assert!(eval(&mut s, "(vl-sort '(1 2) (function (lambda (a b) (while t))))").is_err());
// A well-behaved comparator still works afterwards.
assert_eq!(ok(&mut s, "(vl-sort '(3 1 2) '<)"), "(1 2 3)");
}
#[test]
fn a_zero_callback_budget_does_not_run_the_callback() {
let mut s = session();
s.lisp.limits.callback_steps = 0;
ok(&mut s, "(setq callback-ran nil)");
let result = eval(&mut s, "(vl-remove-if '(lambda (x) (setq callback-ran t)) '(1))");
assert_eq!(result, Err("callback stopped after 0 steps".into()));
assert_eq!(ok(&mut s, "callback-ran"), "nil");
}
#[test]
fn a_callback_must_fit_within_a_partial_slice() {
let mut s = session();
s.lisp.limits.callback_steps = 1_000;
ok(&mut s, "(setq callback-count 0)");
let result = eval(&mut s, "(vl-remove-if '(lambda (x) (repeat 1000 (setq callback-count (1+ callback-count)))) '(1))");
assert_eq!(result, Err("callback stopped after 1000 steps".into()));
assert_eq!(ok(&mut s, "(and (> callback-count 0) (< callback-count 1000))"), "T");
}
#[test]
fn reaching_a_callback_slice_boundary_does_not_grant_another_slice() {
let counts: Vec<u64> = [399_999, 400_000]
.into_iter()
.map(|cap| {
let mut s = session();
s.lisp.limits.callback_steps = cap;
ok(&mut s, "(setq callback-count 0)");
let result = eval(&mut s, "(vl-remove-if '(lambda (x) (while t (setq callback-count (1+ callback-count)))) '(1))");
assert_eq!(result, Err(format!("callback stopped after {cap} steps")));
ok(&mut s, "callback-count").parse().unwrap()
})
.collect();
// One additional VM step can perform at most one assignment, even at SYNC_SLICE's boundary.
assert!(counts[0] > 0);
assert!(counts[1] >= counts[0] && counts[1] - counts[0] <= 1, "{counts:?}");
}
#[test]
fn setvar_rejects_an_oversized_shared_structure() {
// A list repeatedly sharing its own contents expands exponentially; setvar's bounded JSON
// conversion rejects it instead of allocating gigabytes (CWE-400, finding row 39).
let mut s = session();
let build = "(setq x '(1)) (repeat 40 (setq x (list x x))) (setvar \"GRIDUNIT\" x)";
assert!(eval(&mut s, build).is_err());
}
#[test]
fn printing() {
let mut s = session();
@@ -452,6 +543,37 @@ fn hostile_lisp_never_panics() {
assert!(!s.lisp_running());
}
/// `entsel` and `nentsel` pick one object, so the cursor shows the pick box at their prompt. It
/// was read off the message's first word, and a routine's own message begins with a newline -- so
/// the same call showed the pick box without a message and lost it with one (#1598).
#[test]
fn an_entsel_prompt_picks_objects_whatever_its_message_says() {
let mut s = session();
// No message: the default "Select object: ".
s.cmdline("(nentsel)").unwrap();
assert!(s.current_prompt().unwrap().picks_objects(), "{}", s.prompt_text());
s.cancel();
// The reported case: the routine's own message, starting with a newline.
s.cmdline("(nentsel \"\nSelect second line or polyline: \")").unwrap();
assert!(s.current_prompt().unwrap().picks_objects(), "{}", s.prompt_text());
s.cancel();
// Wording that never says "select" picks an object just the same.
s.cmdline("(entsel \"\nPick a line: \")").unwrap();
assert!(s.current_prompt().unwrap().picks_objects(), "{}", s.prompt_text());
s.cancel();
// A point prompt is not a pick, and keeps its point modifiers -- including one whose own
// message happens to begin with the word the built-in commands are read by. `picks_one` says
// which calls pick, so the wording is only ever read where the engine wrote it (#1635 review).
for point in ["(getpoint \"\nSpecify a point: \")", "(getpoint \"\nSelect a point: \")", "(getpoint \"Select a point: \")"] {
s.cmdline(point).unwrap();
assert!(!s.current_prompt().unwrap().picks_objects(), "{point}: {}", s.prompt_text());
s.cancel();
}
}
#[test]
fn a_waiting_routine_is_a_prompt_with_a_rubber_band() {
let mut s = session();
+15 -4
View File
@@ -343,18 +343,29 @@ pub fn eq(a: &Value, b: &Value) -> bool {
/// `equal`: the same structure, numbers within `fuzz`.
pub fn equal(a: &Value, b: &Value, fuzz: f64) -> bool {
equal_depth(a, b, fuzz, 0)
// A shared cons DAG can expand into exponentially many node visits when compared as a tree.
// Bound the total comparisons so `equal` on adversarial shared structure cannot monopolise the
// thread (CWE-407); exhausting the budget reports "not equal" rather than looping.
let mut budget: u64 = EQUAL_BUDGET;
equal_depth(a, b, fuzz, 0, &mut budget)
}
fn equal_depth(a: &Value, b: &Value, fuzz: f64, depth: usize) -> bool {
if depth > MAX_DEPTH {
/// Largest number of node comparisons one `equal` call may perform.
const EQUAL_BUDGET: u64 = 50_000_000;
fn equal_depth(a: &Value, b: &Value, fuzz: f64, depth: usize, budget: &mut u64) -> bool {
if depth > MAX_DEPTH || *budget == 0 {
return false;
}
*budget -= 1;
let (mut a, mut b) = (a.clone(), b.clone());
loop {
if *budget == 0 {
return false;
}
match (&a, &b) {
(Value::Cons(x), Value::Cons(y)) => {
if !equal_depth(&x.car, &y.car, fuzz, depth + 1) {
if !equal_depth(&x.car, &y.car, fuzz, depth + 1, budget) {
return false;
}
let (na, nb) = (x.cdr.clone(), y.cdr.clone());
+11 -4
View File
@@ -62,6 +62,10 @@ pub struct Prompt {
/// pointer boxes.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub distance: bool,
/// The prompt picks one object whatever it says: an AutoLISP `entsel`/`nentsel`, whose message
/// the routine writes, so it cannot be read for the word "Select" (#1598).
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub picks_one: bool,
}
/// Parse `@distance<angle` (relative polar), returning `(distance, angle in radians)`.
@@ -128,11 +132,14 @@ impl Prompt {
self.default = Some(d.into());
self
}
/// The prompt picks objects: a "Select objects" prompt, or a point prompt whose click selects
/// one object ("Select object to offset", "Select first line"…, the commands that pick a
/// single object themselves). The UI shows the pick box there.
/// The prompt picks objects: a "Select objects" prompt, one that says it picks a single object
/// ([`Prompt::picks_one`]), or a point prompt whose click selects one ("Select object to
/// offset", "Select first line"…, the commands that pick a single object themselves). The UI
/// shows the pick box there. The wording is only read where the engine wrote it: a routine
/// writes its own message (`raw`), which may open with "Select" and still ask for a point,
/// and says through `picks_one` when it picks instead (#1635 review).
pub fn picks_objects(&self) -> bool {
self.accept.select || (self.accept.point && self.message.starts_with("Select"))
self.picks_one || self.accept.select || (self.accept.point && !self.raw && self.message.starts_with("Select"))
}
/// "Specify next point or [Undo/Close] <default>:".
pub fn display(&self) -> String {
+17
View File
@@ -121,3 +121,20 @@ fn json_form_takes_leader_type_and_block() {
assert!(s.execute("mleader", &bad).is_err(), "{bad}");
}
}
/// DIMSCALE belongs to dimension styles: a multileader keeps its style's text height, arrowhead
/// and dogleg whatever DIMSCALE is (#1648).
#[test]
fn mleader_sizes_ignore_dimscale() {
let mut s = Session::new();
s.execute("mleaderstyle", &json!({"name": "M5", "textHeight": 5, "arrowSize": 5, "current": true})).unwrap();
s.execute("setvar", &json!({"name": "DIMSCALE", "value": 20})).unwrap();
s.execute("mleader", &json!({"points": [[0, 0], [30, 20]], "text": "CADCRAFT"})).unwrap();
let m = last_mleader(&s);
assert_eq!(m.arrow_size, 5.0);
let st = s.doc().unwrap().mleader_styles.iter().find(|x| x.name == "M5").unwrap().clone();
assert_eq!(m.dogleg, st.dogleg);
let t = m.text.unwrap();
assert_eq!(t.height, 5.0);
assert!((t.insert.x - (30.0 + st.dogleg + st.landing_gap)).abs() < 1e-9, "{}", t.insert.x);
}
+23
View File
@@ -1,7 +1,9 @@
//! OFFSET Erase/Layer/Multiple/Undo and the MOVE/COPY Displacement option at the command line.
use cadcraft_engine::Input;
use cadcraft_engine::Session;
use cadcraft_engine::doc::EntityKind;
use cadcraft_engine::geom::Vec2;
use serde_json::json;
/// Every line as ((ax, ay), (bx, by), layer), sorted by the first y.
@@ -62,6 +64,27 @@ fn offset_multiple_and_undo() {
assert_eq!(lines(&s), vec![l((0.0, 0.0), (10.0, 0.0), "0"), l((0.0, 2.0), (10.0, 2.0), "0"), l((0.0, 4.0), (10.0, 4.0), "0")]);
}
// Regression test for #618: OFFSET's distance prompt took only a typed number; clicking two points
// (the distance between them) was rejected, and the second click had no distance guide.
#[test]
fn offset_distance_from_two_clicked_points() {
let mut s = one_line();
s.start("offset").unwrap();
s.input(Input::Point(Vec2::new(20.0, 20.0))).unwrap();
let p = s.current_prompt().unwrap();
assert!(p.message.contains("second point"), "{}", s.prompt_text());
assert!(p.base.is_some_and(|b| b.near(Vec2::new(20.0, 20.0), 1e-9)), "the second click is measured from the first");
// The guide from the first click to the cursor is the previewed distance.
let guide = s.preview(Vec2::new(20.0, 22.5));
assert!(matches!(guide.first().map(|e| &e.kind), Some(EntityKind::Line(g)) if g.a.xy().near(Vec2::new(20.0, 20.0), 1e-9)), "{guide:?}");
s.input(Input::Point(Vec2::new(20.0, 22.5))).unwrap();
assert!(s.current_prompt().unwrap().message.contains("Select object"), "{}", s.prompt_text());
assert_eq!(s.doc().unwrap().header.f64("OFFSETDIST", -1.0), 2.5);
s.script("5,0 5,1\n\n").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert_eq!(lines(&s), vec![l((0.0, 0.0), (10.0, 0.0), "0"), l((0.0, 2.5), (10.0, 2.5), "0")]);
}
#[test]
fn offset_json_erase_and_layer() {
let mut s = one_line();
+5 -5
View File
@@ -68,6 +68,7 @@ fn shape_chars(primary: Option<&Face>, chars: &[(char, bool, bool)], height: f64
let cs: Vec<char> = chars.iter().map(|c| c.0).collect();
let flags = |i: usize| chars.get(i).map(|c| (c.1, c.2)).unwrap_or((false, false));
let mut out = Shaped::default();
let mut points_left = ttf::MAX_SHAPED_POINTS;
let mut spans: Vec<(f64, f64, bool, bool)> = Vec::new();
let mut x = 0.0;
// Whether the rightmost glyph so far is a stroke-font glyph.
@@ -102,11 +103,10 @@ fn shape_chars(primary: Option<&Face>, chars: &[(char, bool, bool)], height: f64
let outline = ttf::glyph_by_id(face, g.gid);
let k = h / outline.cap * wf;
let dy = g.dy * h / outline.cap;
let mut contours = ttf::place_glyph(&outline, x + g.dx * k, h, wf, shear);
for p in contours.iter_mut().flatten() {
*p += Vec2::new(dy * shear, dy);
}
if !contours.is_empty() {
if let Some(mut contours) = ttf::place_glyph_budgeted(&outline, x + g.dx * k, h, wf, shear, &mut points_left) {
for p in contours.iter_mut().flatten() {
*p += Vec2::new(dy * shear, dy);
}
out.glyphs.push(contours);
}
let adv = g.advance * k;
+4
View File
@@ -334,7 +334,11 @@ pub struct TextGlyph {
impl Shaped {
/// Add a glyph outline and, when it is a font glyph, its [`TextGlyph`].
/// An empty outline is retained only when text metadata needs its slot.
pub(crate) fn push_glyph(&mut self, contours: Vec<Vec<Vec2>>, text: Option<TextGlyph>) {
if contours.is_empty() && text.is_none() {
return;
}
if let Some(t) = text {
self.text.push(TextGlyph { outline: self.glyphs.len(), ..t });
}
+18 -3
View File
@@ -501,6 +501,9 @@ struct Line {
width: f64,
max_h: f64,
text: String,
/// Running character count of `text`, kept incrementally so appending a segment need not
/// rescan the whole accumulated line (which is multiplicative on a long line) (CWE-407).
chars: usize,
/// Index of the paragraph the line belongs to.
para: usize,
}
@@ -580,9 +583,10 @@ pub fn layout_mtext_with(contents: &str, p: &MTextParams) -> MTextLayout {
finish(&mut lines, &mut line, fmt(last_fmt), para);
} else if !line.items.is_empty() || pending_space.is_some() {
if let Some(f) = pending_space {
let at = line.text.chars().count();
let at = line.chars;
line.items.push(Item { seg: None, fmt: f, x: line.width, w: sw, at });
line.text.push(' ');
line.chars += 1;
}
line.width += sw;
}
@@ -590,8 +594,10 @@ pub fn layout_mtext_with(contents: &str, p: &MTextParams) -> MTextLayout {
for (sg, f) in segs {
let fw = seg_width(sg, fmt(*f));
line.max_h = line.max_h.max(fmt(*f).height);
let at = line.text.chars().count();
line.text.push_str(&seg_plain(sg));
let at = line.chars;
let plain = seg_plain(sg);
line.chars += plain.chars().count();
line.text.push_str(&plain);
line.items.push(Item { seg: Some(sg.clone()), fmt: *f, x: line.width, w: fw, at });
line.width += fw;
last_fmt = *f;
@@ -679,6 +685,15 @@ pub fn layout_mtext_with(contents: &str, p: &MTextParams) -> MTextLayout {
mod tests {
use super::*;
#[test]
fn line_char_offsets_track_incrementally() {
// A multi-word line lays out correctly even though the per-line character count is now
// maintained incrementally rather than rescanned per segment (CWE-407, row 20).
let l = layout_mtext("alpha beta gamma", Vec2::ZERO, 1.0, 0.0, 1, 0.0, 1.0);
assert_eq!(l.lines.len(), 1, "no wrap width: one line");
assert_eq!(l.lines[0], "alpha beta gamma");
}
#[test]
fn strips_formatting() {
assert_eq!(plain_mtext("{\\fArial|b1;Bold}\\Pline 2"), "Bold\nline 2");
+124 -19
View File
@@ -107,6 +107,9 @@ const MAX_FIRST_WORD_FILES: usize = 24;
const MAX_LOADED_BYTES: usize = 256 << 20;
const MAX_CACHED_NAMES: usize = 4096;
const MAX_CACHED_CHARS: usize = 50_000;
/// Cumulative outline points one shaped line may retain, so an oversized expanded glyph repeated
/// across a string cannot grow the result without bound (CWE-400).
pub(crate) const MAX_SHAPED_POINTS: usize = 8_000_000;
/// Fonts known to cover many scripts (CJK first), tried first for missing glyphs. Matched as
/// substrings of the normalised file stem.
@@ -595,6 +598,7 @@ pub fn fallback_for(c: char) -> Option<Face> {
#[derive(Default)]
pub(crate) struct GlyphOutline {
pub(crate) contours: Arc<[Vec<Vec2>]>,
points: usize,
pub(crate) advance: f64,
pub(crate) cap: f64,
/// The font maps the character (false: the missing-glyph shape).
@@ -614,22 +618,46 @@ enum GlyphKey {
/// Cached glyph outlines keyed by (font bytes address, length, face, glyph). Fonts live for the
/// whole process in the font table, so the address identifies them. A read-write lock: display
/// lists are built on all cores and nearly every lookup is a hit, which a mutex serialized.
type GlyphCache = HashMap<(usize, usize, u32, GlyphKey), Arc<GlyphOutline>>;
#[derive(Default)]
struct GlyphCache {
glyphs: HashMap<(usize, usize, u32, GlyphKey), Arc<GlyphOutline>>,
points: usize,
}
fn glyph_cache() -> &'static RwLock<GlyphCache> {
static C: OnceLock<RwLock<GlyphCache>> = OnceLock::new();
C.get_or_init(|| RwLock::new(HashMap::new()))
C.get_or_init(|| RwLock::new(GlyphCache::default()))
}
const MAX_CACHED_GLYPHS: usize = 40_000;
const UPEM: f32 = 1000.0;
#[derive(Default)]
struct Pen {
contours: Vec<Vec<Vec2>>,
cur: Vec<Vec2>,
points: usize,
truncated: bool,
}
const MAX_OUTLINE_POINTS: usize = 65_536;
impl Pen {
fn push(&mut self, point: Vec2) {
if self.truncated {
return;
}
if self.points == MAX_OUTLINE_POINTS {
// An incomplete contour is not safe to fill; retain only the advance for this glyph.
self.truncated = true;
self.cur.clear();
self.contours.clear();
return;
}
self.points += 1;
self.cur.push(point);
}
fn pt(x: f32, y: f32) -> Vec2 {
Vec2::new(f64::from(x), f64::from(y))
}
@@ -638,9 +666,11 @@ impl Pen {
if let (Some(f), Some(l)) = (self.cur.first().copied(), self.cur.last().copied())
&& f != l
{
self.cur.push(f);
self.push(f);
}
if !self.truncated {
self.contours.push(std::mem::take(&mut self.cur));
}
self.contours.push(std::mem::take(&mut self.cur));
} else {
self.cur.clear();
}
@@ -650,10 +680,10 @@ impl Pen {
impl OutlinePen for Pen {
fn move_to(&mut self, x: f32, y: f32) {
self.flush();
self.cur.push(Self::pt(x, y));
self.push(Self::pt(x, y));
}
fn line_to(&mut self, x: f32, y: f32) {
self.cur.push(Self::pt(x, y));
self.push(Self::pt(x, y));
}
fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
let a = self.cur.last().copied().unwrap_or_default();
@@ -662,7 +692,7 @@ impl OutlinePen for Pen {
for i in 1..=6 {
let t = f64::from(i) / 6.0;
let u = 1.0 - t;
self.cur.push(a * (u * u) + c * (2.0 * u * t) + b * (t * t));
self.push(a * (u * u) + c * (2.0 * u * t) + b * (t * t));
}
}
fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
@@ -673,7 +703,7 @@ impl OutlinePen for Pen {
for i in 1..=8 {
let t = f64::from(i) / 8.0;
let u = 1.0 - t;
self.cur.push(a * (u * u * u) + c0 * (3.0 * u * u * t) + c1 * (3.0 * u * t * t) + b * (t * t * t));
self.push(a * (u * u * u) + c0 * (3.0 * u * u * t) + c1 * (3.0 * u * t * t) + b * (t * t * t));
}
}
fn close(&mut self) {
@@ -714,7 +744,7 @@ pub fn glyph_contours(face: &Face, gid: u16) -> Arc<[Vec<Vec2>]> {
fn outline(face: &Face, k: GlyphKey) -> Arc<GlyphOutline> {
let key = (face.bytes.as_ptr() as usize, face.bytes.len(), face.index, k);
if let Some(g) = glyph_cache().read().unwrap_or_else(PoisonError::into_inner).get(&key) {
if let Some(g) = glyph_cache().read().unwrap_or_else(PoisonError::into_inner).glyphs.get(&key) {
return g.clone();
}
let Some(f) = face.font() else { return Arc::new(GlyphOutline { cap: 1.0, ..Default::default() }) };
@@ -723,19 +753,24 @@ fn outline(face: &Face, k: GlyphKey) -> Arc<GlyphOutline> {
GlyphKey::Id(id) => Some(GlyphId::new(id)),
};
let gid = mapped.unwrap_or_default();
let mut pen = Pen { contours: Vec::new(), cur: Vec::new() };
let mut pen = Pen::default();
if let Some(g) = f.outline_glyphs().get(gid) {
let _ = g.draw(DrawSettings::unhinted(Size::new(UPEM), LocationRef::default()), &mut pen);
pen.flush();
}
let advance = f64::from(f.glyph_metrics(Size::new(UPEM), LocationRef::default()).advance_width(gid).unwrap_or(UPEM * 0.5));
let (cap, mapped, gid) = (cap_height(&f), mapped.is_some(), gid.to_u32());
let g = Arc::new(GlyphOutline { contours: pen.contours.into(), advance, cap, mapped, gid, face: Some(face.clone()) });
let points = pen.contours.iter().map(Vec::len).sum();
let g = Arc::new(GlyphOutline { points, contours: pen.contours.into(), advance, cap, mapped, gid, face: Some(face.clone()) });
let mut cache = glyph_cache().write().unwrap_or_else(PoisonError::into_inner);
if cache.len() > MAX_CACHED_GLYPHS {
cache.clear();
if cache.glyphs.len() >= MAX_CACHED_GLYPHS || cache.points.saturating_add(points) > MAX_SHAPED_POINTS {
cache.glyphs.clear();
cache.points = 0;
}
cache.insert(key, g.clone());
if let Some(old) = cache.glyphs.insert(key, g.clone()) {
cache.points -= old.points;
}
cache.points += points;
g
}
@@ -777,6 +812,11 @@ pub(crate) fn text_glyph_at(g: &GlyphOutline, c: char, origin: Vec2, scale: f64,
})
}
pub(crate) fn place_glyph_budgeted(g: &GlyphOutline, x: f64, h: f64, wf: f64, shear: f64, points_left: &mut usize) -> Option<Vec<Vec<Vec2>>> {
*points_left = points_left.checked_sub(g.points)?;
Some(place_glyph(g, x, h, wf, shear))
}
/// Contours of a glyph scaled so its font's cap height is `h`, at pen position `x`.
pub(crate) fn place_glyph(g: &GlyphOutline, x: f64, h: f64, wf: f64, shear: f64) -> Vec<Vec<Vec2>> {
place_glyph_at(g, Vec2::new(x, 0.0), h / g.cap, wf, shear)
@@ -814,16 +854,20 @@ pub fn shape(face: &Face, s: &str, height: f64, width_factor: f64, oblique: f64)
let mut out = Shaped::default();
let mut spans = Vec::new();
let mut x = 0.0;
// Cumulative retained-point budget: a compact CFF/OTF font can expand one glyph into a huge
// outline, and a short repeated string copies it per occurrence, so cap the total geometry
// kept in the Shaped result (CWE-400). Advances still accumulate so the width stays sensible.
let mut points_left: usize = MAX_SHAPED_POINTS;
for (c, under, over) in crate::decode_controls(s) {
if crate::bidi::is_format_control(c) {
continue;
}
let g = glyph_or_fallback(face, c);
if !c.is_whitespace() {
let contours = place_glyph(&g, x, h, wf, shear);
if !contours.is_empty() {
out.push_glyph(contours, text_glyph(&g, c, x, h, wf, shear));
}
if !c.is_whitespace()
&& let Some(contours) = place_glyph_budgeted(&g, x, h, wf, shear, &mut points_left)
{
// Even an empty mapped glyph needs a valid outline slot for text export.
out.push_glyph(contours, text_glyph(&g, c, x, h, wf, shear));
}
let adv = g.advance * h / g.cap * wf;
spans.push((x, x + adv, under, over));
@@ -878,3 +922,64 @@ mod family_tests {
assert!(family_candidates(&many, "gosttypea", "gost").len() <= MAX_FAMILY_FILES + MAX_FIRST_WORD_FILES);
}
}
#[cfg(test)]
mod geometry_budget_tests {
use super::*;
#[test]
fn placement_checks_source_size_and_accepts_empty_glyphs() {
let glyph = GlyphOutline { contours: vec![vec![Vec2::ZERO; 4]].into(), points: 4, cap: 1.0, ..Default::default() };
let mut budget = 3;
assert!(place_glyph_budgeted(&glyph, 0.0, 1.0, 1.0, 0.0, &mut budget).is_none());
assert_eq!(budget, 3);
let empty = GlyphOutline { cap: 1.0, ..Default::default() };
assert_eq!(place_glyph_budgeted(&empty, 0.0, 1.0, 1.0, 0.0, &mut 0), Some(Vec::new()));
let mut budget = 4;
assert!(place_glyph_budgeted(&glyph, 0.0, 1.0, 1.0, 0.0, &mut budget).is_some());
assert_eq!(budget, 0);
assert!(place_glyph_budgeted(&glyph, 0.0, 1.0, 1.0, 0.0, &mut budget).is_none());
}
#[test]
fn flattened_outlines_are_bounded_before_caching() {
let mut pen = Pen::default();
for _ in 0..MAX_OUTLINE_POINTS {
pen.line_to(0.0, 0.0);
}
pen.line_to(1.0, 1.0);
pen.close();
assert!(pen.truncated);
assert!(pen.contours.is_empty() && pen.cur.is_empty());
assert_eq!(pen.points, MAX_OUTLINE_POINTS);
}
#[test]
fn mapped_empty_glyph_keeps_text_and_a_valid_outline_slot() {
// Metadata placement does not parse the face; an empty face suffices to check the index contract.
let glyph = GlyphOutline { cap: 1.0, mapped: true, gid: 1, face: Some(Face { bytes: Arc::new(Vec::new()), index: 0 }), ..Default::default() };
let contours = place_glyph_budgeted(&glyph, 0.0, 1.0, 1.0, 0.0, &mut 0).unwrap();
let mut shaped = Shaped::default();
shaped.push_glyph(contours, text_glyph(&glyph, 'x', 0.0, 1.0, 1.0, 0.0));
assert_eq!(shaped.text.len(), 1);
assert_eq!(shaped.text[0].outline, 0);
assert!(shaped.glyphs[shaped.text[0].outline].is_empty());
}
#[test]
fn unmapped_empty_glyph_does_not_make_the_shape_nonempty() {
let glyph = GlyphOutline { cap: 1.0, ..Default::default() };
let contours = place_glyph_budgeted(&glyph, 0.0, 1.0, 1.0, 0.0, &mut 0).unwrap();
let mut shaped = Shaped::default();
shaped.push_glyph(contours, text_glyph(&glyph, 'x', 0.0, 1.0, 1.0, 0.0));
assert!(shaped.is_empty());
assert!(shaped.text.is_empty());
}
#[test]
fn an_unmapped_glyph_with_contours_still_draws() {
let mut shaped = Shaped::default();
let contours = vec![vec![Vec2::ZERO, Vec2::new(1.0, 1.0)]];
shaped.push_glyph(contours.clone(), None);
assert!(!shaped.is_empty());
assert_eq!(shaped.glyphs, vec![contours]);
assert!(shaped.text.is_empty());
}
}
+4
View File
@@ -437,6 +437,10 @@ pub fn bulge_to_arc(a: Vec2, b: Vec2, bulge: f64) -> Option<(Arc, bool)> {
return None;
}
let theta = 4.0 * bulge.atan(); // signed included angle
// A sweep at or below EPS would be read back as a full circle by `ccw_sweep`; it is a straight segment.
if theta.abs() <= EPS {
return None;
}
let chord = a.dist(b);
let r = chord / (2.0 * (theta / 2.0).sin().abs());
let mid = a.mid(b);
+44 -14
View File
@@ -158,7 +158,7 @@ fn seg_cross(a0: Vec2, a1: Vec2, b0: Vec2, b1: Vec2) -> Option<(f64, f64)> {
}
/// Crossings of two sampled polylines, as parameter seeds, with chunked box culling.
fn crossings(a: &[(f64, Vec2)], b: &[(f64, Vec2)], out: &mut Vec<(f64, f64)>) {
fn crossings(a: &[(f64, Vec2)], b: &[(f64, Vec2)], out: &mut Vec<(f64, f64)>, work: &mut usize) {
const CHUNK: usize = 16;
let boxes = |s: &[(f64, Vec2)]| -> Vec<(usize, Bounds2)> {
(0..s.len().saturating_sub(1))
@@ -169,12 +169,16 @@ fn crossings(a: &[(f64, Vec2)], b: &[(f64, Vec2)], out: &mut Vec<(f64, f64)>) {
let (ba, bb) = (boxes(a), boxes(b));
for (ia, xa) in &ba {
for (ib, xb) in &bb {
let Some(left) = work.checked_sub(1) else { return };
*work = left;
if !xa.intersects(xb) {
continue;
}
for i in *ia..(*ia + CHUNK).min(a.len().saturating_sub(1)) {
let (Some(&(ta0, a0)), Some(&(ta1, a1))) = (a.get(i), a.get(i + 1)) else { continue };
for j in *ib..(*ib + CHUNK).min(b.len().saturating_sub(1)) {
let Some(left) = work.checked_sub(1) else { return };
*work = left;
let (Some(&(tb0, b0)), Some(&(tb1, b1))) = (b.get(j), b.get(j + 1)) else { continue };
if let Some((u, v)) = seg_cross(a0, a1, b0, b1) {
out.push((ta0 + (ta1 - ta0) * u, tb0 + (tb1 - tb0) * v));
@@ -255,24 +259,35 @@ pub fn tangent_circles(c1: &TanCurve, c2: &TanCurve, r: f64, near: &[Vec2]) -> V
let (Some(r1), Some(r2)) = (c1.range(&win), c2.range(&win)) else { return Vec::new() };
let signs: &[(f64, f64)] = if r > 0.0 { &[(1.0, 1.0), (1.0, -1.0), (-1.0, 1.0), (-1.0, -1.0)] } else { &[(0.0, 0.0)] };
let mut out: Vec<CornerFit> = Vec::new();
let mut work = 16_000_000usize;
for (s1, s2) in signs {
let (d1, d2) = (s1 * r, s2 * r);
let (o1, o2) = (sample(c1, &r1, d1), sample(c2, &r2, d2));
let mut seeds = Vec::new();
// Refine each pair before visiting the next: raw duplicate seeds in an early run must
// not consume all candidate slots. A separate request-wide budget bounds actual work.
for a in &o1 {
for b in &o2 {
crossings(a, b, &mut seeds);
}
}
for seed in seeds {
let Some((t1, t2)) = refine(c1, &r1, d1, c2, &r2, d2, seed, tol) else { continue };
let (Some(center), p1, p2) = (c1.offset(t1, d1), c1.point(t1), c2.point(t2)) else { continue };
let near_dup = |f: &CornerFit| f.center.near(center, tol * 1e3) && f.p1.near(p1, tol * 1e3) && f.p2.near(p2, tol * 1e3);
if !out.iter().any(near_dup) {
out.push(CornerFit { t1, t2, p1, p2, center });
}
if out.len() >= MAX_SOLUTIONS {
return out;
let mut seeds = Vec::new();
crossings(a, b, &mut seeds, &mut work);
seeds.sort_by(|x, y| x.0.total_cmp(&y.0).then(x.1.total_cmp(&y.1)));
seeds.dedup_by(|x, y| (x.0 - y.0).abs() < 1e-9 && (x.1 - y.1).abs() < 1e-9);
for seed in seeds {
// Newton refinement has a fixed iteration cap and several curve evaluations.
let Some(left) = work.checked_sub(128) else { return out };
work = left;
let Some((t1, t2)) = refine(c1, &r1, d1, c2, &r2, d2, seed, tol) else { continue };
let (Some(center), p1, p2) = (c1.offset(t1, d1), c1.point(t1), c2.point(t2)) else { continue };
let near_dup = |f: &CornerFit| f.center.near(center, tol * 1e3) && f.p1.near(p1, tol * 1e3) && f.p2.near(p2, tol * 1e3);
if !out.iter().any(near_dup) {
out.push(CornerFit { t1, t2, p1, p2, center });
}
if out.len() >= MAX_SOLUTIONS {
return out;
}
}
if work == 0 {
return out;
}
}
}
}
@@ -353,3 +368,18 @@ mod tests {
assert!(tangent_circles(&bad, &s, 1.0, &[]).is_empty());
}
}
#[cfg(test)]
mod seed_budget_tests {
use super::*;
#[test]
fn crossing_visits_have_an_independent_work_limit() {
let a = [(0.0, Vec2::new(0.0, 0.0)), (1.0, Vec2::new(1.0, 1.0))];
let b = [(0.0, Vec2::new(0.0, 1.0)), (1.0, Vec2::new(1.0, 0.0))];
let mut seeds = Vec::new();
crossings(&a, &b, &mut seeds, &mut 0);
assert!(seeds.is_empty());
crossings(&a, &b, &mut seeds, &mut 10);
assert_eq!(seeds, vec![(0.5, 0.5)]);
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ pub use curve::{
pub use fillet::{CornerFit, TanCurve, tangent_circles};
pub use intersect::{circle_circle, intersect_ext, intersect_segments, line_circle, line_line, line_line_infinite};
pub use mat::{Mat3, Mat4};
pub use spline::Spline;
pub use spline::{MAX_DEGREE as MAX_SPLINE_DEGREE, Spline};
pub use spline_fit::{FitOptions, KnotParam};
pub use vec::{Vec2, Vec3};
+12 -3
View File
@@ -124,8 +124,15 @@ impl Spline {
if !self.is_valid() {
return Vec2::ZERO;
}
self.derivative_unchecked(t)
}
/// First derivative without re-validating the spline. The caller must have confirmed
/// [`Spline::is_valid`] already; integration calls this once per sample, and re-running the
/// O(knots) validity scan each time would make measuring a valid spline quadratic (CWE-407).
fn derivative_unchecked(&self, t: f64) -> Vec2 {
let p = self.degree;
let n = self.control.len() - 1;
let Some(n) = self.control.len().checked_sub(1) else { return Vec2::ZERO };
let (lo, hi) = self.domain();
let t = if t.is_finite() { t.clamp(lo, hi) } else { lo };
let span = find_span(n, p, t, &self.knots);
@@ -470,9 +477,11 @@ impl Spline {
out
}
/// Arc length of one integration panel.
/// Arc length of one integration panel. The spline is validated once by the public callers
/// ([`Spline::length_between`]/[`Spline::length`]) before any panel is measured, so the speed
/// closure uses the unchecked derivative to avoid re-scanning the knots per sample.
fn panel_length(&self, a: f64, b: f64) -> f64 {
let speed = |t: f64| self.derivative(t).len();
let speed = |t: f64| self.derivative_unchecked(t).len();
adaptive_gauss(a, b, gauss(a, b, speed), &speed, MAX_GAUSS_DEPTH)
}
+10
View File
@@ -114,9 +114,19 @@ fn build(polys: &[Vec<Vec2>], tol: f64) -> Option<Graph> {
}
let mut splits: Vec<Vec<f64>> = vec![vec![0.0, 1.0]; n];
let mut tested = std::collections::HashSet::new();
// Count every candidate-pair *visit*, including repeats of a pair that shares several grid
// cells, not only the distinct pairs actually tested. A long segment replicated across many
// cells makes the same pair reappear in each, so a budget on distinct pairs alone leaves the
// repeated hashing unbounded (CWE-407).
let mut visits: u64 = 0;
const MAX_PAIR_VISITS: u64 = 60_000_000;
for list in grid.values() {
for (k, &i) in list.iter().enumerate() {
for &j in list.iter().skip(k + 1) {
visits += 1;
if visits > MAX_PAIR_VISITS {
return None;
}
if i == j || !tested.insert((i.min(j), i.max(j))) {
continue;
}
+37 -1
View File
@@ -3,6 +3,12 @@ use serde::{Deserialize, Serialize};
use crate::spline_fit::FitOptions;
use crate::{Bounds2, Vec2};
/// Largest B-spline degree treated as valid. Basis evaluation is O(degree²) per sample and
/// tessellation takes O(degree) samples, so an unbounded degree (DXF/HATCH can declare up to
/// 100 000) turns a tiny spline into billions of iterations (CWE-407). Real splines are low
/// degree (AutoCAD caps at 11); above this a spline evaluates to its control polygon instead.
pub const MAX_DEGREE: usize = 64;
/// A (possibly rational) B-spline curve, DXF SPLINE style.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Spline {
@@ -57,7 +63,11 @@ impl Spline {
pub fn is_valid(&self) -> bool {
let n = self.control.len();
n > self.degree && self.degree >= 1 && self.knots.len() == n + self.degree + 1 && self.knots.windows(2).all(|w| w.first() <= w.get(1))
n > self.degree
&& self.degree >= 1
&& self.degree <= MAX_DEGREE
&& self.knots.len() == n + self.degree + 1
&& self.knots.windows(2).all(|w| w.first() <= w.get(1))
}
pub fn domain(&self) -> (f64, f64) {
@@ -192,3 +202,29 @@ pub(crate) fn solve(mut a: Vec<Vec<f64>>, mut b: Vec<f64>) -> Option<Vec<f64>> {
}
Some(x)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_huge_degree_spline_is_invalid_and_cheap_to_tessellate() {
// A HATCH/DXF spline edge can declare a very high degree with matching knots. Such a
// spline is treated as invalid, so eval/tessellate fall back to the control polygon
// instead of doing O(degree^2) work per sample (CWE-407, rows 18/28).
let degree = 50_000usize;
let control = vec![Vec2::ZERO; degree + 2];
let knots = clamped_uniform_knots(control.len(), degree);
let s = Spline { degree, knots, control: control.clone(), ..Spline::default() };
assert!(!s.is_valid(), "a degree-{degree} spline must be rejected as invalid");
// Returns the control polygon rather than sampling, so this is fast.
assert_eq!(s.tessellate(1e-3).len(), control.len());
}
#[test]
fn a_normal_low_degree_spline_is_valid() {
let s = Spline::from_control(vec![Vec2::new(0.0, 0.0), Vec2::new(1.0, 1.0), Vec2::new(2.0, 0.0), Vec2::new(3.0, 1.0)], 3);
assert!(s.is_valid());
assert!(s.tessellate(1e-3).len() > 4);
}
}
+29 -2
View File
@@ -172,8 +172,21 @@ impl Spline {
return true;
}
}
// Other writers use the parameters themselves as interior knots.
u.len() > 2 && u.iter().skip(1).take(u.len() - 2).all(|q| norm.iter().any(|k| close(*k, *q)))
// Other writers use the parameters themselves as interior knots. Both `u` and
// `norm` are ascending, so a single linear merge decides containment instead of
// re-scanning `norm` from the start for every parameter, which is quadratic on an
// otherwise valid spline (CWE-407).
if u.len() <= 2 {
return false;
}
let interior = u.get(1..u.len() - 1).unwrap_or(&[]);
let mut j = 0usize;
interior.iter().all(|q| {
while j < norm.len() && norm[j] < *q && !close(norm[j], *q) {
j += 1;
}
norm.get(j).is_some_and(|k| close(*k, *q))
})
})
.unwrap_or(KnotParam::Chord)
}
@@ -895,3 +908,17 @@ mod tests {
assert!(s.fit_opts.start_tangent.is_some_and(|t| t.near(Vec2::Y, 1e-12)));
}
}
#[cfg(test)]
mod knot_boundary_tests {
use super::*;
#[test]
fn uniform_knots_skip_the_strict_lower_tolerance_boundary() {
let q = 2.0 / 3.0;
let mut s = Spline::from_control(vec![Vec2::ZERO; 5], 1);
s.fit = vec![Vec2::new(0.0, 0.0), Vec2::new(1.0, 0.0), Vec2::new(4.0, 0.0), Vec2::new(9.0, 0.0)];
s.knots = vec![0.0, 0.0, 1.0 / 3.0, q - 1e-6, q, 1.0, 1.0];
assert!(s.is_valid());
assert_eq!(s.infer_knot_param(), KnotParam::Uniform);
}
}
+10
View File
@@ -74,6 +74,16 @@ fn bulge_quarter_arc() {
assert!(close(arc.sweep(), PI / 2.0));
}
#[test]
fn tiny_bulge_is_not_a_full_circle() {
let a = Vec2::new(0.0, 0.0);
let b = Vec2::new(10.0, 0.0);
let p = Polyline { vertices: vec![PolyVertex::with_bulge(a, 1e-10), PolyVertex::new(b)], closed: false };
assert!((p.len() - 10.0).abs() < 1e-6, "len={}", p.len());
let bb = p.bounds();
assert!(bb.max.x < 10.001 && bb.max.y < 0.001 && bb.min.y > -0.001, "bounds={bb:?}");
}
#[test]
fn polyline_area_and_length() {
let p = Polyline::from_points(&[Vec2::new(0.0, 0.0), Vec2::new(4.0, 0.0), Vec2::new(4.0, 3.0), Vec2::new(0.0, 3.0)], true);
+22 -7
View File
@@ -141,26 +141,36 @@ pub(crate) fn resolve(secs: &[Section], d: &mut Drawing) {
if by_image.is_empty() {
return;
}
fill(&mut d.model, &by_image);
// Aggregate budget on the image-definition strings copied into referring images, so an
// attacker cannot multiply a long path/name across many references into gigabytes (CWE-400).
const MAX_RESOLVED_STRING_BYTES: usize = 64 << 20;
let mut budget = MAX_RESOLVED_STRING_BYTES;
fill(&mut d.model, &by_image, &mut budget);
for l in &mut d.layouts {
fill(&mut l.entities, &by_image);
fill(&mut l.entities, &by_image, &mut budget);
}
for b in d.blocks.values_mut() {
if b.entities.iter().any(|e| matches!(e.kind, EntityKind::Image(_)) && by_image.contains_key(&e.handle)) {
fill(&mut Arc::make_mut(b).entities, &by_image);
fill(&mut Arc::make_mut(b).entities, &by_image, &mut budget);
}
}
}
fn fill(store: &mut EntityStore, by_image: &HashMap<Handle, &Def>) {
fn fill(store: &mut EntityStore, by_image: &HashMap<Handle, &Def>, budget: &mut usize) {
let hs: Vec<Handle> =
store.iter().filter(|e| matches!(e.kind, EntityKind::Image(_)) && by_image.contains_key(&e.handle)).map(|e| e.handle).collect();
for h in hs {
let Some(def) = by_image.get(&h) else { continue };
// Charge the bytes this copy adds; once the shared budget is spent, stop copying strings
// (geometry fields are still applied) so retained memory stays bounded.
let cost = def.path.len() + def.name.len();
let copy_strings = budget.checked_sub(cost).map(|left| *budget = left).is_some();
store.modify(h, |e| {
if let EntityKind::Image(i) = &mut e.kind {
i.path = def.path.clone();
i.name = def.name.clone();
if copy_strings {
i.path = def.path.clone();
i.name = def.name.clone();
}
if let Some(p) = def.pixel_size {
i.pixel_size = p;
}
@@ -215,6 +225,10 @@ pub(crate) fn plan<'a>(entities: impl Iterator<Item = &'a cadcraft_doc::Entity>,
let mut p = Plan::default();
let mut index: HashMap<(String, String), usize> = HashMap::new();
let mut used: HashSet<String> = HashSet::new();
// Next suffix to try for each normalised base name, so allocating a unique name resumes where
// the previous collision left off instead of re-probing `_2, _3, …` from the start — otherwise
// many images sharing a base name make name allocation quadratic (CWE-407).
let mut next_suffix: HashMap<String, usize> = HashMap::new();
for e in entities {
let EntityKind::Image(i) = &e.kind else { continue };
if p.by_entity.contains_key(&e.handle) {
@@ -227,11 +241,12 @@ pub(crate) fn plan<'a>(entities: impl Iterator<Item = &'a cadcraft_doc::Entity>,
// Dictionary keys are unique regardless of case.
let base = def_name(i);
let mut name = base.clone();
let mut n = 2usize;
let mut n = next_suffix.get(&key(&base)).copied().unwrap_or(2);
while !used.insert(key(&name)) {
name = format!("{base}_{n}");
n = n.saturating_add(1);
}
next_suffix.insert(key(&base), n);
p.defs.push(PlannedDef {
handle: next(),
name,
+3 -4
View File
@@ -51,8 +51,6 @@ const MAX_OBJECTS: usize = 1_000_000;
const MAX_XDATA_GROUPS: usize = 16_384;
/// Longest ownership chain followed.
const MAX_DEPTH: usize = 64;
/// Handles above this are not believed (hostile files); references to them are dropped.
const MAX_HANDLE: u64 = 1 << 48;
/// Record types that structure a file: never written as an entity of another type.
const STRUCTURAL: &[&str] = &["", "SECTION", "ENDSEC", "EOF", "TABLE", "ENDTAB", "BLOCK", "ENDBLK", "SEQEND", "VERTEX", "ATTRIB", "CLASS"];
@@ -85,9 +83,10 @@ fn tag(r: &RawTag) -> Tag {
Tag::new(r.code, value.unwrap_or_else(|| Value::Str(r.value.clone())))
}
/// A handle value we believe: hexadecimal, not null, not absurdly large.
/// A handle value we believe: hexadecimal and not null. DXF handles are up to 16 hex digits and
/// AutoCAD writes some above 2^48 (e.g. 0xDAD1…), so the whole 64-bit range is believed.
fn handle(s: &str) -> Option<Handle> {
Handle::parse_hex(s).filter(|h| h.0 != 0 && h.0 <= MAX_HANDLE)
Handle::parse_hex(s).filter(|h| h.0 != 0)
}
/// Group codes whose values are handles of other objects (DXF Reference, group code ranges).
+78 -13
View File
@@ -245,6 +245,10 @@ fn mirror_text(t: &mut Text, align: Option<Vec3>, insert: Vec3) {
/// Parse one entity record. POLYLINE/INSERT followers are handled by the caller.
fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> {
entity_with_table_budget(kind, tags, &mut 4_000_000)
}
fn entity_with_table_budget(kind: &str, tags: &[Tag], table_cells_left: &mut usize) -> Option<(Common, EntityKind)> {
// The paper-space viewport our writer adds (dxf_write::paper_view) stays implicit.
if kind == "VIEWPORT" && crate::dxf_ext::is_paper_view(tags) {
return None;
@@ -480,7 +484,7 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> {
}
k
}
"ACAD_TABLE" => EntityKind::Table(acad_table(tags)),
"ACAD_TABLE" => EntityKind::Table(acad_table(tags, table_cells_left)),
"VIEWPORT" => EntityKind::Viewport(Viewport {
center: t.p(10),
width: t.fd(40, 1.0),
@@ -525,7 +529,7 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> {
/// An ACAD_TABLE entity: sizes, cell texts and merges (DXF Reference), plus CADCraft's
/// title/header flags, text height and style name from xdata when present.
fn acad_table(tags: &[Tag]) -> Table {
fn acad_table(tags: &[Tag], table_cells_left: &mut usize) -> Table {
const MAX_CELLS: usize = 1_000_000;
let start = tags.iter().position(|x| x.code == 100 && x.str() == "AcDbTable").unwrap_or(0);
let tail = tags.get(start..).unwrap_or(&[]);
@@ -540,6 +544,12 @@ fn acad_table(tags: &[Tag]) -> Table {
rows = 0;
cols = 0;
}
// Charge before any cell vector is materialized, including discarded tables.
let count = rows.saturating_mul(cols);
match table_cells_left.checked_sub(count) {
Some(left) => *table_cells_left = left,
None => return Table { insert: T(tags).p(10), ..Table::default() },
}
let size = |v: f64| if v.is_finite() && v > 0.0 { v } else { 0.27 };
let mut row_heights: Vec<f64> = head.all_f(141).into_iter().take(rows).map(size).collect();
row_heights.resize(rows, 0.27);
@@ -1098,7 +1108,13 @@ fn entity_extras(kind: &str, tags: &[Tag], h: Handle, k: &mut EntityKind, rx: &m
/// Parse a run of entity records (ENTITIES section or a block's body) into entities. Records that
/// can't be placed are counted in `skipped`.
fn parse_entities(recs: &[(String, Vec<Tag>)], d: &mut Drawing, rx: &mut Rx, skipped: &mut usize) -> Vec<(Option<String>, bool, Entity)> {
fn parse_entities(
recs: &[(String, Vec<Tag>)],
d: &mut Drawing,
rx: &mut Rx,
skipped: &mut usize,
table_cells_left: &mut usize,
) -> Vec<(Option<String>, bool, Entity)> {
let mut out = Vec::new();
let mut i = 0;
while let Some((kind, tags)) = recs.get(i) {
@@ -1106,7 +1122,7 @@ fn parse_entities(recs: &[(String, Vec<Tag>)], d: &mut Drawing, rx: &mut Rx, ski
let owner = crate::dxf_ext::owner(tags);
let paper = t.i(67) == Some(1);
let handle = t.s(5).and_then(|h| Handle::parse_hex(&h));
let mut kindent = entity(kind, tags);
let mut kindent = entity_with_table_budget(kind, tags, table_cells_left);
if let Some((c, _)) = kindent.as_mut()
&& let Some(n) = entity_plot_style(tags, rx)
{
@@ -1420,12 +1436,16 @@ fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {
}
}
let mut active_vport = false;
// Index of layers already appended, by lower-cased name, so each LAYER record updates or
// appends in O(1) instead of scanning the growing layer list — otherwise many distinct layer
// names make import quadratic (CWE-407).
let mut layer_index: HashMap<String, usize> = d.layers.iter().enumerate().map(|(i, l)| (l.name.to_ascii_lowercase(), i)).collect();
for (kind, tg) in recs {
let t = T(&tg);
match kind.as_str() {
"LAYER" => {
let name = t.s(2).unwrap_or_default();
if name.is_empty() {
if name.is_empty() || d.layers.len() >= MAX_OBJECTS {
continue;
}
let aci = t.i(62).unwrap_or(7);
@@ -1455,14 +1475,21 @@ fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {
if let Some(desc) = crate::dxf_ext::xdata(&tg, crate::dxf_ext::LAYER_DESCRIPTION_APP).iter().filter(|x| x.code == 1000).nth(1) {
l.description = desc.str();
}
match d.layer_mut(&name) {
Some(x) => *x = l,
None => d.layers.push(l),
match layer_index.get(&name.to_ascii_lowercase()) {
Some(&i) => {
if let Some(x) = d.layers.get_mut(i) {
*x = l;
}
}
None => {
layer_index.insert(name.to_ascii_lowercase(), d.layers.len());
d.layers.push(l);
}
}
}
"LTYPE" => {
let name = t.s(2).unwrap_or_default();
if name.is_empty() || d.linetype(&name).is_some() {
if name.is_empty() || d.linetype(&name).is_some() || d.linetypes.len() >= MAX_OBJECTS {
continue;
}
let pattern = ltype_pattern(&tg, &ltype_styles);
@@ -1470,7 +1497,7 @@ fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {
}
"STYLE" => {
let name = t.s(2).unwrap_or_default();
if name.is_empty() {
if name.is_empty() || d.text_styles.len() >= MAX_OBJECTS {
continue;
}
// 70 bit 4: vertical; 71 bits 2/4: backwards / upside down.
@@ -1552,7 +1579,7 @@ fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {
}
"DIMSTYLE" => {
let name = t.s(2).unwrap_or_default();
if name.is_empty() {
if name.is_empty() || d.dim_styles.len() >= MAX_OBJECTS {
continue;
}
// Every variable with a DIMSTYLE group code; values the style can't take are
@@ -1651,6 +1678,10 @@ fn ltype_pattern(tags: &[Tag], styles: &HashMap<String, String>) -> Vec<DashElem
/// Read a DXF file into a drawing.
pub fn read(bytes: &[u8]) -> Result<Drawing> {
read_budgeted(bytes, 4_000_000)
}
fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result<Drawing> {
let tags = cadcraft_dxf::parse(bytes).map_err(|e| IoError::Format(e.to_string()))?;
let secs = sections(&tags);
if secs.is_empty() {
@@ -1705,7 +1736,7 @@ pub fn read(bytes: &[u8]) -> Result<Drawing> {
j += 1;
}
let body = recs.get(i + 1..j).unwrap_or(&[]);
let ents = parse_entities(body, &mut d, &mut rx, &mut n);
let ents = parse_entities(body, &mut d, &mut rx, &mut n, &mut table_cells_left);
blocks.push((name, base, description, ents, xref));
i = j + 1;
} else {
@@ -1721,7 +1752,7 @@ pub fn read(bytes: &[u8]) -> Result<Drawing> {
// A damaged file may split its entities over several ENTITIES sections (#865).
"ENTITIES" => {
let mut n = 0;
entities.extend(parse_entities(&records(&s.tags), &mut d, &mut rx, &mut n));
entities.extend(parse_entities(&records(&s.tags), &mut d, &mut rx, &mut n, &mut table_cells_left));
count_skipped(&mut skipped, "ENTITIES", n);
}
"OBJECTS" => {
@@ -2248,6 +2279,7 @@ fn page_setup(t: &T) -> PageSetup {
_ => "layout",
}
.into();
p.plot_window = [t.fd(48, 0.0), t.fd(49, 0.0), t.fd(140, 0.0), t.fd(141, 0.0)].map(|v| if v.is_finite() { v } else { 0.0 });
p.plot_style_table = t.s(7).unwrap_or_default();
p
}
@@ -2309,3 +2341,36 @@ fn helix(tags: &[Tag]) -> Helix {
pub fn entity_from_groups(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> {
entity(&kind.to_ascii_uppercase(), tags)
}
#[cfg(test)]
mod table_budget_tests {
use super::*;
fn table_tags(rows: i64, cols: i64) -> Vec<Tag> {
vec![Tag::s(100, "AcDbTable"), Tag::i(91, rows), Tag::i(92, cols), Tag::i(171, 1), Tag::s(1, "cell")]
}
#[test]
fn exhausted_budget_rejects_before_materializing_cells() {
let mut left = 3;
let table = acad_table(&table_tags(2, 2), &mut left);
assert!(table.cells.is_empty() && table.row_heights.is_empty() && table.col_widths.is_empty());
assert_eq!(left, 3);
let table = acad_table(&table_tags(1, 2), &mut left);
assert_eq!(table.cells[0].len(), 2);
assert_eq!(left, 1);
}
#[test]
fn blocks_and_entity_sections_share_one_budget() {
let table = "0\nACAD_TABLE\n100\nAcDbTable\n91\n1\n92\n2\n171\n1\n1\ncell\n";
let dxf = format!(
"0\nSECTION\n2\nBLOCKS\n0\nBLOCK\n2\nTEST\n10\n0\n20\n0\n{table}0\nENDBLK\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n{table}0\nENDSEC\n0\nSECTION\n2\nENTITIES\n{table}0\nENDSEC\n0\nEOF\n"
);
let d = read_budgeted(dxf.as_bytes(), 2).unwrap();
let block = d.block("TEST").unwrap();
let count = |e: &Entity| match &e.kind {
EntityKind::Table(t) => t.cells.iter().map(Vec::len).sum::<usize>(),
_ => 0,
};
assert_eq!(block.entities.iter().map(|e| count(e)).sum::<usize>(), 2);
assert_eq!(d.model.iter().map(|e| count(e)).sum::<usize>(), 0);
}
}
+9 -6
View File
@@ -1441,8 +1441,11 @@ fn table_size(t: &Table) -> (usize, usize) {
/// The anonymous `*T` block of a table: cell fills, borders and texts as its cell styles draw
/// them, for readers that only draw the block.
fn table_block_entities(d: &Drawing, t: &Table, layer: &str) -> Vec<Entity> {
let g = cadcraft_render::table_geometry(d, t);
///
/// The block is relative to its own origin: the ACAD_TABLE reference adds the insertion point.
pub(crate) fn table_block_entities(d: &Drawing, t: &Table, layer: &str) -> Vec<Entity> {
let at_origin = Table { insert: Vec3::ZERO, ..t.clone() };
let g = cadcraft_render::table_geometry(d, &at_origin);
let c = Common { layer: layer.into(), color: Color::ByBlock, ..Common::default() };
let mut out = Vec::new();
for f in &g.fills {
@@ -2565,10 +2568,10 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec<Tag> {
(45, page.height_mm),
(46, 0.0),
(47, 0.0),
(48, 0.0),
(49, 0.0),
(140, 0.0),
(141, 0.0),
(48, page.plot_window[0]),
(49, page.plot_window[1]),
(140, page.plot_window[2]),
(141, page.plot_window[3]),
(142, scale),
(143, 1.0),
] {
+4 -1
View File
@@ -511,9 +511,12 @@ fn links(list: &cadcraft_render::DisplayList, map: &Map, clip_pt: &Bounds2, link
if links.is_empty() {
return Vec::new();
}
// Membership by handle, built once, so scanning primitives is O(primitives) rather than
// O(primitives × links): otherwise the two counts multiply into excessive work (CWE-407).
let linked: std::collections::HashSet<Handle> = links.iter().map(|(h, _)| *h).collect();
let mut areas: std::collections::HashMap<Handle, Bounds2> = std::collections::HashMap::new();
for p in &list.prims {
if matches!(p.kind, Kind::Infinite { .. }) || !links.iter().any(|(h, _)| *h == p.handle) {
if matches!(p.kind, Kind::Infinite { .. }) || !linked.contains(&p.handle) {
continue;
}
let b = areas.entry(p.handle).or_insert(Bounds2::EMPTY);
+16
View File
@@ -38,6 +38,9 @@ const MAX_LAYERS: usize = 256;
const MAX_TRANSFORM_DEPTH: usize = 64;
/// Flattened chords per elliptical arc under a non-uniform transform.
const MAX_ARC_CHORDS: usize = 10_000;
/// Generated vertices retained across the whole document (all paths and shapes), so repeated
/// arc/curve flattening cannot amplify a compact input into unbounded geometry.
const MAX_TOTAL_VERTS: usize = 8_000_000;
/// Elements whose whole subtree is skipped (paint servers, text, raster images, metadata).
const SKIP: &[&[u8]] = &[
@@ -126,6 +129,10 @@ struct Parser {
layers: HashSet<String>,
layer_color: u8,
elements: usize,
/// Generated vertices still allowed across the whole document. Arc/curve flattening can turn
/// a compact path into many samples, and each flushed run is retained, so without a shared
/// budget repeated arcs amplify a small input into gigabytes of geometry (CWE-400).
verts_left: usize,
/// > 0 while inside a skipped subtree; counts the starts seen there.
skip: usize,
/// What was left out, by kind ([`LeftOut`]).
@@ -173,6 +180,7 @@ impl Parser {
layers: HashSet::from(["0".to_string()]),
layer_color: 1,
elements: 0,
verts_left: MAX_TOTAL_VERTS,
skip: 0,
left_out: BTreeMap::new(),
}
@@ -331,6 +339,14 @@ impl Parser {
if self.elements > MAX_ELEMENTS {
return Err(IoError::Format(format!("SVG: more than {MAX_ELEMENTS} elements")));
}
// Charge the vertices this entity retains against the shared document budget, so repeated
// flattened arcs/curves cannot amplify a compact path into unbounded geometry (CWE-400).
let verts = match &kind {
EntityKind::LwPolyline(p) => p.vertices.len(),
EntityKind::Spline(s) => s.control.len(),
_ => 1,
};
self.verts_left = self.verts_left.checked_sub(verts).ok_or_else(|| IoError::Format("SVG: too much generated geometry".into()))?;
let common = Common { layer: self.layer.clone(), ..Common::default() };
self.drawing.add(&Space::Model, common, kind).map(|_| ()).map_err(parse_err)
}
+33
View File
@@ -349,6 +349,22 @@ fn hostile_dxf_does_not_panic() {
}
}
// Regression test for #1336: the LAYER table of a DXF had no record cap, so a hostile file could
// grow the layer list without bound (its VIEW/UCS/VPORT peers stop at one million records).
#[test]
fn dxf_layer_table_stops_at_the_record_cap() {
use std::fmt::Write as _;
let records = 1_000_100;
let mut text = String::from("0\nSECTION\n2\nTABLES\n0\nTABLE\n2\nLAYER\n");
for i in 0..records {
let _ = write!(text, "0\nLAYER\n2\nL{i}\n");
}
text.push_str("0\nENDTAB\n0\nENDSEC\n0\nEOF\n");
let d = read(text.as_bytes(), "layers.dxf").unwrap();
assert!(d.layers.len() <= 1_000_000, "{} layers read from {records} records", d.layers.len());
assert!(d.layers.len() > 900_000, "the records below the cap are kept: {}", d.layers.len());
}
#[test]
fn svg_and_png_export() {
let d = sample();
@@ -1343,6 +1359,23 @@ fn tables_roundtrip_with_title_header_and_merges() {
assert_eq!(again.blocks.len(), back.blocks.len());
}
#[test]
fn table_block_is_relative_to_the_insertion_point() {
let d = Drawing::new_imperial();
let t = table_sample();
let ents = crate::dxf_write::table_block_entities(&d, &t, "0");
let mut xs = Vec::new();
for e in &ents {
if let EntityKind::Line(l) = &e.kind {
xs.extend([l.a.x, l.b.x]);
}
}
assert!(!xs.is_empty());
let (min, max) = xs.iter().fold((f64::MAX, f64::MIN), |(lo, hi), &x| (lo.min(x), hi.max(x)));
// Columns total 8 wide; the block starts at its own origin, not at the insert x of 5.
assert!(min.abs() < 1e-9 && (max - 8.0).abs() < 1e-9, "block x range {min}..{max}");
}
#[test]
fn text_style_flags_roundtrip() {
let mut d = Drawing::new_imperial();
+26
View File
@@ -536,3 +536,29 @@ fn dragging_a_distance_grip_changes_the_distance() {
assert!(drag_grip(&mut d, h("30"), 2, GripPart::End, Vec2::new(130.0, 0.0)).is_err(), "not a reference");
assert_eq!(d, before);
}
/// The DOOR drawing with every handle moved above 2^48: AutoCAD writes such handles (seen in
/// drawings whose handles start at 0xDAD1…), and the dynamic block's objects must still be kept.
fn far_handles(src: &str) -> String {
const SHIFT: u64 = 0x000D_AD10_0000_0000;
let is_handle = |code: i32| matches!(code, 5 | 105 | 320..=369 | 390..=399 | 480 | 481 | 1005);
let mut out = String::new();
for line in src.lines() {
let (code, value) = line.trim().split_once(' ').unwrap_or((line.trim(), ""));
let moved = match (code.parse::<i32>(), u64::from_str_radix(value, 16)) {
(Ok(c), Ok(h)) if is_handle(c) && h != 0 => format!("{:X}", h + SHIFT),
_ => value.to_string(),
};
out.push_str(&format!("{code} {moved}\n"));
}
out
}
#[test]
fn a_dynamic_block_with_handles_above_2_pow_48_is_kept() {
let src = far_handles(&fixture());
let d = read_dxf(dxf(&src).as_bytes()).unwrap();
let before = dynamic_block(&d, "Door").expect("the dynamic block with far handles");
let again = read_dxf(write_dxf(&d).as_bytes()).unwrap();
assert_eq!(by_node(dynamic_block(&again, "Door").unwrap()), by_node(before));
}
+25
View File
@@ -0,0 +1,25 @@
//! #1667: a layout whose page setup plots a window (DXF plot type 4) lost the window corners
//! (groups 48, 49, 140, 141) on save: they were written as zero.
use cadcraft_doc::{Drawing, Layout, PageSetup};
use cadcraft_io::{read, write_dxf};
#[test]
fn plot_window_corners_round_trip_through_dxf() {
let mut d = Drawing::new_metric();
let mut l = Layout::new("WIN", 3);
l.page = PageSetup {
width_mm: 297.0,
height_mm: 420.0,
margins_mm: [0.0; 4],
plot_area: "window".into(),
plot_window: [0.0, -297.0, 420.0, 0.0],
..PageSetup::default()
};
d.layouts.push(l);
let back = read(write_dxf(&d).as_bytes(), "win.dxf").unwrap();
let page = &back.layouts.iter().find(|l| l.name == "WIN").unwrap().page;
assert_eq!(page.plot_area, "window");
assert_eq!(page.plot_window, [0.0, -297.0, 420.0, 0.0]);
}
+32
View File
@@ -0,0 +1,32 @@
//! Reading an SVG whose paths flatten into a huge number of retained vertices must be rejected by
//! the document-wide generated-geometry budget, not amplified into gigabytes (CWE-400, row 31).
use cadcraft_io::read;
/// A path with many elliptical-arc subpaths under a non-uniform group transform; each subpath
/// flushes to its own retained polyline, so enough of them must exceed the document vertex budget.
fn arc_bomb(arcs: usize) -> Vec<u8> {
// `scale(1 2)` makes the transform non-uniform, forcing per-sample flattening rather than a
// single bulge arc. Each `M … a …` is a separate subpath that is flushed and retained.
let mut d = String::new();
for i in 0..arcs {
let y = i as i32;
d.push_str(&format!("M 0 {y} a 1000 500 0 1 1 1 1 "));
}
let svg = format!(r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><g transform="scale(1 2)"><path d="{d}"/></g></svg>"#);
svg.into_bytes()
}
#[test]
fn a_modest_arc_svg_still_reads() {
// A handful of arcs is well within budget and parses fine.
let d = read(&arc_bomb(4), "a.svg").expect("a small arc SVG should read");
assert!(d.model.iter().count() >= 1, "expected some geometry");
}
#[test]
fn an_arc_heavy_svg_is_rejected_by_the_vertex_budget() {
// ~1000 arcs x up to 10_000 chords each would far exceed the 8M vertex budget.
let err = read(&arc_bomb(30000), "a.svg");
assert!(err.unwrap_err().to_string().contains("SVG: too much generated geometry"));
}
+2
View File
@@ -14,3 +14,5 @@ cadcraft-render = { workspace = true }
cadcraft-io = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
# Secure randomness for the per-session control token (MIT OR Apache-2.0).
getrandom = "0.3"
+106 -13
View File
@@ -23,6 +23,14 @@ pub struct Remote {
next_id: u64,
/// Open the app when nothing answers (see [`Remote::lazy`]).
launch: bool,
/// The per-session control token for this port, read from the owner-only token file on first
/// use so requests can authenticate (CWE-306).
token: Option<String>,
}
/// The port component of a `HOST:PORT` address, for locating the token file.
fn port_of(addr: &str) -> Option<u16> {
addr.rsplit(':').next().and_then(|p| p.parse::<u16>().ok())
}
/// How long a launched app has to open its control port.
@@ -30,7 +38,7 @@ const LAUNCH_WAIT: Duration = Duration::from_secs(30);
impl Remote {
pub fn connect(addr: &str) -> std::io::Result<Self> {
let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1, launch: false };
let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1, launch: false, token: None };
r.reconnect()?;
Ok(r)
}
@@ -38,7 +46,20 @@ impl Remote {
/// the server before the app is open. With `launch`, a call that finds nothing at `addr`
/// opens the app with that control port first (macOS) and waits for it.
pub fn lazy(addr: &str, launch: bool) -> Self {
Self { addr: addr.to_string(), conn: None, next_id: 1, launch }
Self { addr: addr.to_string(), conn: None, next_id: 1, launch, token: None }
}
/// The per-session token for this port, read (and cached) from the owner-only token file. A
/// missing file is not fatal here: the request is sent without a token and the server replies
/// with an authentication error the user can act on.
fn token(&mut self) -> Option<&str> {
if self.token.is_none()
&& let Some(port) = port_of(&self.addr)
&& let Ok(t) = crate::control_token::read(port)
{
self.token = Some(t);
}
self.token.as_deref()
}
/// Connect, opening the app first when asked to and nothing answers.
fn reach(&mut self) -> std::io::Result<()> {
@@ -91,7 +112,9 @@ impl Remote {
let sent = writer.write_all(line.as_bytes()).and_then(|()| writer.write_all(b"\n")).and_then(|()| writer.flush());
if let Err(e) = sent {
self.conn = None;
return Err((e, false));
// write_all can fail after partial delivery. The server may have received the full
// JSON before the newline write failed, so a mutation must not be retried blindly.
return Err((e, true));
}
let mut reply = String::new();
match reader.read_line(&mut reply) {
@@ -162,17 +185,35 @@ impl Backend for Remote {
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
let id = self.next_id;
self.next_id += 1;
let line = json!({"id": id, "method": method, "params": params}).to_string();
let reply = match self.roundtrip(&line) {
let token = self.token().map(str::to_string);
let mut req = json!({"id": id, "method": method, "params": params});
if let (Some(obj), Some(token)) = (req.as_object_mut(), token) {
obj.insert("token".into(), Value::String(token));
}
let reply = match self.roundtrip(&req.to_string()) {
Ok(r) => r,
// Resend only when the request cannot have been applied. After a sent mutation with no reply the outcome is unknown, and
// resending could apply it twice (for example two circles from one call), so report that instead.
Err((_, sent)) if !sent || is_read_only(method) => self.roundtrip(&line).map_err(|(e, _)| format!("CADCraft at {}: {e}", self.addr))?,
Err((e, _)) => {
return Err(format!(
"CADCraft at {}: {e}; the reply was lost, so `{method}` may or may not have been applied. Inspect the drawing first",
self.addr
));
Err((e, sent)) => {
self.token = None;
self.conn = None;
// A mutation whose reply was lost must never be sent twice.
if sent && !is_read_only(method) {
return Err(format!(
"CADCraft at {}: {e}; the reply was lost, so `{method}` may or may not have been applied. Inspect the drawing first",
self.addr
));
}
// A restarted server may have rotated its token before dropping the old socket.
if let Some(obj) = req.as_object_mut() {
obj.remove("token");
if let Some(token) = self.token() {
obj.insert("token".into(), Value::String(token.to_owned()));
}
}
self.roundtrip(&req.to_string()).map_err(|(e, _)| {
self.token = None;
self.conn = None;
format!("CADCraft at {}: {e}", self.addr)
})?
}
};
let v: Value = serde_json::from_str(&reply).map_err(|e| format!("bad reply: {e}"))?;
@@ -180,6 +221,10 @@ impl Backend for Remote {
Ok(v.get("result").cloned().unwrap_or(Value::Null))
} else {
let e = v.get("error").and_then(Value::as_str).unwrap_or("error");
if e.starts_with("unauthorized") {
self.token = None;
self.conn = None;
}
// A timeout says whether the request can still have run (#345).
Err(match v.get("state").and_then(Value::as_str) {
Some("not-run") => format!("{e}: CADCraft did not start `{method}` and won't; it is safe to send again"),
@@ -375,9 +420,12 @@ mod tests {
requests
});
let mut remote = Remote::connect(&addr).unwrap();
remote.token = Some("stale-token".into());
let err = remote.call("engine.execute", json!({"command": "circle", "params": {}})).unwrap_err();
assert!(err.contains("may or may not have been applied"), "{err}");
assert_eq!(server.join().unwrap(), 1);
assert!(remote.token.is_none());
assert!(remote.conn.is_none());
}
/// The app's timeout reply says whether the request can still run; the error passes that on (#345).
@@ -399,4 +447,49 @@ mod tests {
server.join().unwrap();
}
}
#[test]
fn safe_retry_rereads_a_rotated_token_after_a_lost_reply() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let old = crate::control_token::issue(addr.port()).unwrap();
let server = std::thread::spawn(move || {
let (conn, _) = listener.accept().unwrap();
let mut line = String::new();
let mut reader = BufReader::new(conn);
reader.read_line(&mut line).unwrap();
let first: Value = serde_json::from_str(&line).unwrap();
assert!(first["token"].as_str() == Some(old.as_str()));
let fresh = crate::control_token::issue(addr.port()).unwrap();
drop(reader);
let (mut conn, _) = listener.accept().unwrap();
line.clear();
BufReader::new(conn.try_clone().unwrap()).read_line(&mut line).unwrap();
let retry: Value = serde_json::from_str(&line).unwrap();
assert!(retry["token"].as_str() == Some(fresh.as_str()));
writeln!(conn, "{}", json!({"ok": true, "result": "pong"})).unwrap();
crate::control_token::revoke(addr.port());
});
let mut remote = Remote::connect(&addr.to_string()).unwrap();
assert_eq!(remote.call("ping", json!({})).unwrap(), json!("pong"));
server.join().unwrap();
}
#[test]
fn failed_mutation_write_is_not_resent() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap().to_string();
let mut remote = Remote::connect(&addr).unwrap();
remote.conn.as_ref().unwrap().1.shutdown(std::net::Shutdown::Write).unwrap();
let server = std::thread::spawn(move || {
let (conn, _) = listener.accept().unwrap();
let mut line = String::new();
assert_eq!(BufReader::new(conn).read_line(&mut line).unwrap(), 0);
listener.set_nonblocking(true).unwrap();
std::thread::sleep(Duration::from_millis(300));
listener.accept().is_ok()
});
let error = remote.call("engine.execute", json!({"command": "circle", "params": {}})).unwrap_err();
assert!(error.contains("may or may not have been applied"));
assert!(!server.join().unwrap(), "a failed mutation write was retried");
assert!(remote.token.is_none());
}
}
+180
View File
@@ -0,0 +1,180 @@
//! A per-session credential for the loopback control channel.
//!
//! The control server listens on `127.0.0.1`, but loopback TCP does not distinguish operating
//! system users: any local account can connect and drive the desktop user's command and file
//! authority (CWE-306). To authenticate the peer without changing the transport, the app issues an
//! unguessable token at startup and writes it to a file only the desktop user can read. A client
//! (the MCP bridge) reads the token from that file — which requires the same user's filesystem
//! access — and sends it with every request. A different local user cannot read the token, so
//! cannot authenticate.
use std::io;
use std::path::PathBuf;
/// Length of the token in bytes (256 bits), rendered as lowercase hex.
const TOKEN_BYTES: usize = 32;
/// The file holding the control token for `port`, inside a per-user private directory.
///
/// On Unix the runtime dir (`XDG_RUNTIME_DIR`, already `0700` and per-user) is preferred, else the
/// user's home; on Windows the local app-data directory. The file itself is created with
/// owner-only permissions regardless.
pub fn token_path(port: u16) -> io::Result<PathBuf> {
Ok(private_dir()?.join(format!("cadcraft-control-{port}.token")))
}
#[cfg(unix)]
fn private_dir() -> io::Result<PathBuf> {
if let Some(d) = std::env::var_os("XDG_RUNTIME_DIR").filter(|v| !v.is_empty()) {
return Ok(PathBuf::from(d));
}
if let Some(h) = std::env::var_os("HOME").filter(|v| !v.is_empty()) {
return Ok(PathBuf::from(h).join(".cache"));
}
Ok(std::env::temp_dir())
}
#[cfg(windows)]
fn private_dir() -> io::Result<PathBuf> {
windows_private_dir(std::env::var_os("LOCALAPPDATA"))
}
#[cfg(any(windows, test))]
fn windows_private_dir(local: Option<std::ffi::OsString>) -> io::Result<PathBuf> {
local
.filter(|p| !p.is_empty())
.map(PathBuf::from)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "LOCALAPPDATA is required for a private control token"))
}
#[cfg(not(any(unix, windows)))]
fn private_dir() -> io::Result<PathBuf> {
Err(io::Error::new(io::ErrorKind::Unsupported, "no private control token directory on this platform"))
}
/// Generate a fresh token and write it to [`token_path`] with owner-only permissions, returning
/// the token. The file is replaced if it already exists.
pub fn issue(port: u16) -> io::Result<String> {
let token = random_hex()?;
let path = token_path(port)?;
issue_at(&path, token.as_bytes())?;
Ok(token)
}
fn issue_at(path: &std::path::Path, bytes: &[u8]) -> io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
write_private(path, bytes)
}
/// Read the token for `port`, trimming trailing whitespace. Used by clients (the MCP bridge).
pub fn read(port: u16) -> io::Result<String> {
let raw = std::fs::read_to_string(token_path(port)?)?;
let token = raw.trim().to_string();
if token.is_empty() {
return Err(io::Error::new(io::ErrorKind::InvalidData, "empty control token"));
}
Ok(token)
}
/// Remove the token file (best effort), called when the server stops.
pub fn revoke(port: u16) {
if let Ok(path) = token_path(port) {
let _ = std::fs::remove_file(path);
}
}
/// Constant-time comparison of two tokens, so a wrong token cannot be guessed byte by byte.
pub fn matches(expected: &str, given: &str) -> bool {
let (a, b) = (expected.as_bytes(), given.as_bytes());
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
fn random_hex() -> io::Result<String> {
let mut bytes = [0u8; TOKEN_BYTES];
getrandom::fill(&mut bytes).map_err(|e| io::Error::other(format!("no secure randomness: {e}")))?;
let mut s = String::with_capacity(TOKEN_BYTES * 2);
for b in bytes {
use std::fmt::Write as _;
let _ = write!(s, "{b:02x}");
}
Ok(s)
}
#[cfg(unix)]
fn write_private(path: &std::path::Path, bytes: &[u8]) -> io::Result<()> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
// Replace any stale file, then create fresh with mode 0600 so no other user can read the token.
let _ = std::fs::remove_file(path);
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(path)?;
f.write_all(bytes)?;
f.flush()
}
#[cfg(not(unix))]
fn write_private(path: &std::path::Path, bytes: &[u8]) -> io::Result<()> {
use std::io::Write;
// On Windows the per-user LOCALAPPDATA directory is already ACL-protected to the user.
let _ = std::fs::remove_file(path);
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(path)?;
f.write_all(bytes)?;
f.flush()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn issue_then_read_round_trips() {
let port = 50000 + (std::process::id() % 10000) as u16;
let token = issue(port).unwrap();
assert_eq!(token.len(), TOKEN_BYTES * 2);
assert_eq!(read(port).unwrap(), token);
revoke(port);
assert!(read(port).is_err());
}
#[test]
fn matches_is_exact() {
assert!(matches("abc", "abc"));
assert!(!matches("abc", "abd"));
assert!(!matches("abc", "ab"));
assert!(!matches("", "x"));
}
#[cfg(unix)]
#[test]
fn token_file_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let port = 40000 + (std::process::id() % 10000) as u16;
issue(port).unwrap();
let mode = std::fs::metadata(token_path(port).unwrap()).unwrap().permissions().mode();
assert_eq!(mode & 0o077, 0, "token file is readable by group/other: {:o}", mode);
revoke(port);
}
#[test]
fn windows_requires_local_app_data() {
assert!(windows_private_dir(None).is_err());
assert!(windows_private_dir(Some("".into())).is_err());
assert_eq!(windows_private_dir(Some("private-app-data".into())).unwrap(), PathBuf::from("private-app-data"));
}
#[test]
fn issuance_creates_missing_parent() {
let dir = std::env::temp_dir().join(format!("cadcraft-token-parent-{}", std::process::id()));
let path = dir.join("missing/cache/session.token");
issue_at(&path, b"secret").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"secret");
std::fs::remove_dir_all(dir).unwrap();
}
}
+1
View File
@@ -9,6 +9,7 @@
#![forbid(unsafe_code)]
mod backend;
pub mod control_token;
mod server;
mod tools;
+1 -1
View File
@@ -215,7 +215,7 @@ pub fn boundary_polygon(k: &EntityKind) -> Option<Vec<Vec2>> {
g.tessellate(r * e.ratio.min(1.0) * 2e-3, &mut pts);
}
EntityKind::Spline(s) => {
if s.control.len() > 100_000 || s.knots.len() > 100_000 {
if s.control.len() > 100_000 || s.knots.len() > 100_000 || s.degree > cadcraft_geom::MAX_SPLINE_DEGREE {
return None;
}
pts = s.tessellate(tol_for(Bounds2::from_points(s.control.iter().copied())));
+57 -2
View File
@@ -414,7 +414,14 @@ fn tol_height_code(st: &DimStyle) -> String {
}
fn post(template: &str, meas: &str) -> String {
if template.contains("<>") { template.replace("<>", meas) } else { format!("{meas}{template}") }
if template.contains("<>") {
bounded_replace(template, meas)
} else {
let mut out = String::new();
append_dim_text(&mut out, meas);
append_dim_text(&mut out, template);
out
}
}
fn apply_override(base: &str, d: &Dimension) -> String {
@@ -423,10 +430,40 @@ fn apply_override(base: &str, d: &Dimension) -> String {
} else if d.text.trim().is_empty() {
String::new()
} else {
d.text.replace("<>", base)
bounded_replace(&d.text, base)
}
}
/// Largest dimension text produced by substituting the measurement into style/override text. Two
/// modest strings can otherwise expand into gigabytes when the template holds many `<>` markers
/// (CWE-789); past this the output is truncated rather than allocated unbounded.
const MAX_DIM_TEXT: usize = 64 * 1024;
/// Replace every `<>` in `template` with `value`, with the expanded length checked against
/// [`MAX_DIM_TEXT`] before allocating so a many-placeholder template cannot blow up memory.
fn bounded_replace(template: &str, value: &str) -> String {
let mut out = String::new();
let mut rest = template;
while let Some(i) = rest.find("<>") {
if !append_dim_text(&mut out, &rest[..i]) || !append_dim_text(&mut out, value) {
return out;
}
rest = &rest[i + 2..];
}
append_dim_text(&mut out, rest);
out
}
// Returns false when truncation consumed the remaining UTF-8-safe space.
fn append_dim_text(out: &mut String, text: &str) -> bool {
let mut end = text.len().min(MAX_DIM_TEXT.saturating_sub(out.len()));
while !text.is_char_boundary(end) {
end -= 1;
}
out.push_str(&text[..end]);
end == text.len()
}
fn ext_line(origin: Vec2, foot: Vec2, st: &DimStyle, k: f64) -> Option<Vec<Vec2>> {
let v = foot - origin;
let len = v.len();
@@ -1322,3 +1359,21 @@ mod tests {
assert!(g.lines.is_empty() && g.fills.is_empty(), "{:?}", g.lines);
}
}
#[cfg(test)]
mod text_budget_tests {
use super::*;
#[test]
fn bounds_post_without_markers() {
let text = post(&"x".repeat(MAX_DIM_TEXT * 2), "12");
assert_eq!(text.len(), MAX_DIM_TEXT);
assert!(text.starts_with("12x"));
}
#[test]
fn oversized_value_is_substituted_and_truncated_at_a_character_boundary() {
let text = bounded_replace("prefix <> mm", &"é".repeat(MAX_DIM_TEXT));
assert!(text.starts_with("prefix é"));
assert!(!text.contains("<>"));
assert!(text.len() <= MAX_DIM_TEXT && text.len() >= MAX_DIM_TEXT - 1);
}
}
+65 -8
View File
@@ -80,18 +80,47 @@ fn same_polygon(a: &[Vec2], b: &[Vec2]) -> bool {
}
fn dedup_polygons(loops: &mut Vec<Vec<Vec2>>) {
// `same_polygon` is O(n²) in a polygon's vertex count (every cyclic alignment, both windings),
// and each loop is compared against every unique one kept so far, so adversarial boundary
// geometry could force quadratic-on-quadratic work. Charge every vertex comparison against a
// shared budget; once it is spent, stop deduplicating and keep the remaining loops as-is
// (CWE-407).
let mut budget: u64 = MAX_DEDUP_COMPARISONS;
let mut unique: Vec<Vec<Vec2>> = Vec::with_capacity(loops.len());
for l in loops.drain(..) {
if l.len() < 3 {
continue;
}
if !unique.iter().any(|u| same_polygon(u, &l)) {
let mut dup = false;
for u in &unique {
if u.len() != l.len() {
continue;
}
// Each `same_polygon` call costs about 2·len² comparisons; stop if that would overrun.
let cost = 2u64.saturating_mul((u.len() as u64).saturating_mul(l.len() as u64));
match budget.checked_sub(cost) {
Some(left) => budget = left,
None => {
// Out of budget: keep `l` without further comparison.
budget = 0;
break;
}
}
if same_polygon(u, &l) {
dup = true;
break;
}
}
if !dup {
unique.push(l);
}
}
*loops = unique;
}
/// Vertex comparisons `dedup_polygons` may perform before it stops deduplicating.
const MAX_DEDUP_COMPARISONS: u64 = 50_000_000;
/// The tessellated boundary loops that take part in the fill under the hatch's island
/// detection style (DXF group 75), filled with odd parity: 0 normal keeps every loop (islands
/// within islands alternate), 1 outer keeps the outermost loops and their first islands, 2
@@ -261,6 +290,9 @@ const MAX_PATTERN_LINES: usize = 1024;
/// (a hostile or mis-scaled pattern can't exhaust memory). A view-limited build makes at most the
/// runs of a full build, so it never leaves out runs that a full build draws.
const MAX_RUNS: usize = 1_000_000;
/// Shared budget on dash-processing steps across all of a hatch's families and spans, counted
/// independently of emitted geometry so gap-only patterns cannot spin without bound (CWE-400).
const MAX_HATCH_WORK: u64 = 50_000_000;
/// Upper bound on the lines one family sweeps over the whole hatch; past it the family is left
/// out (a full-detail build of a pattern far too fine for its area).
const MAX_FAMILY_LINES: i64 = 20_000;
@@ -351,7 +383,16 @@ pub(crate) fn pattern_lines_until(
let scale = scale_of(h);
let px = if px.is_finite() && px > 0.0 { px } else { 0.0 };
let mut out = Vec::new();
// Shared work budget across all families and spans, counting dash-processing steps even when
// they emit no geometry: gap-only families produce no runs, so `out.len()` alone cannot bound
// their cost (CWE-400).
let mut work: u64 = MAX_HATCH_WORK;
for fam in &lines {
// A family with dashes but no positive (ink) element can never draw: skip it so its gaps
// are not swept at all.
if !fam.dashes.is_empty() && !fam.dashes.iter().any(|d| *d >= 0.0) {
continue;
}
let ang = fam.angle.to_radians() + h.angle;
let origin = h.origin + Vec2::new(fam.origin.0, fam.origin.1).rotate(h.angle) * scale;
let delta = Vec2::new(fam.delta.0, fam.delta.1).rotate(ang) * scale;
@@ -360,8 +401,8 @@ pub(crate) fn pattern_lines_until(
continue;
}
let dashes: Vec<f64> = fam.dashes.iter().map(|d| d * scale).collect();
Family { origin, ang, delta, spacing, dashes: &dashes }.sweep(loops, px, view, cancel, &mut out);
if out.len() >= MAX_RUNS || is_set(cancel) {
Family { origin, ang, delta, spacing, dashes: &dashes }.sweep(loops, px, view, cancel, &mut out, &mut work);
if out.len() >= MAX_RUNS || work == 0 || is_set(cancel) {
break;
}
}
@@ -422,7 +463,7 @@ impl Family<'_> {
self.origin + p.rotate(self.ang)
}
fn sweep(&self, loops: &[Vec<Vec2>], px: f64, view: Option<&Bounds2>, cancel: Option<&AtomicBool>, out: &mut Vec<Vec<Vec2>>) {
fn sweep(&self, loops: &[Vec<Vec2>], px: f64, view: Option<&Bounds2>, cancel: Option<&AtomicBool>, out: &mut Vec<Vec<Vec2>>, work: &mut u64) {
let local: Vec<Vec<Vec2>> = loops.iter().map(|l| l.iter().map(|p| self.to_local(*p)).collect()).collect();
let bb = Bounds2::from_points(local.iter().flatten().copied());
if bb.is_empty() {
@@ -456,7 +497,7 @@ impl Family<'_> {
let draws = self.dashes.iter().any(|d| *d >= 0.0);
let solid_line = self.dashes.is_empty() || pat_len < 1e-12 || (px > 0.0 && pat_len < 2.0 * px && draws);
for k in k0..=k1 {
if out.len() >= MAX_RUNS || is_set(cancel) {
if out.len() >= MAX_RUNS || *work == 0 || is_set(cancel) {
return;
}
let y = k as f64 * self.spacing;
@@ -484,21 +525,22 @@ impl Family<'_> {
if solid_line {
out.push(vec![self.to_world(Vec2::new(x0, y)), self.to_world(Vec2::new(x1, y))]);
} else {
self.dash(x0, x1, y, offset, pat_len, out);
self.dash(x0, x1, y, offset, pat_len, out, work);
}
}
}
}
/// The dashes and dots of the span `x0..x1` of the line at `y`, phase anchored at x = `offset`.
fn dash(&self, x0: f64, x1: f64, y: f64, offset: f64, pat_len: f64, out: &mut Vec<Vec<Vec2>>) {
fn dash(&self, x0: f64, x1: f64, y: f64, offset: f64, pat_len: f64, out: &mut Vec<Vec<Vec2>>, work: &mut u64) {
let start_rep = ((x0 - offset) / pat_len).floor();
let mut x = offset + start_rep * pat_len;
let mut guard = 0;
'outer: while x < x1 {
for d in self.dashes {
guard += 1;
if guard > 100_000 || out.len() >= MAX_RUNS {
*work = work.saturating_sub(1);
if guard > 100_000 || out.len() >= MAX_RUNS || *work == 0 {
break 'outer;
}
let len = d.abs();
@@ -560,3 +602,18 @@ mod tests {
assert!((area - 100.0).abs() < 1e-6, "triangulated area must be 100.0, not zero");
}
}
#[cfg(test)]
mod dedup_budget_tests {
use super::*;
#[test]
fn unequal_loops_do_not_spend_the_comparison_budget() {
// The old estimate exhausted the budget on this unequal pair before seeing the triangle.
let a = vec![Vec2::ZERO; 5_000];
let b = vec![Vec2::ZERO; 5_001];
let tri = vec![Vec2::ZERO, Vec2::new(1.0, 0.0), Vec2::new(0.0, 1.0)];
let mut loops = vec![a, b, tri.clone(), tri];
dedup_polygons(&mut loops);
assert_eq!(loops.len(), 3);
}
}
+5 -2
View File
@@ -735,13 +735,17 @@ fn contents(b: &mut Builder, d: &Drawing, vp: &cadcraft_doc::Viewport, area: Bou
let eb = cadcraft_doc::entity_bounds(d, me, 0);
eb.is_empty() || eb.intersects(&win)
};
// One expansion budget for the whole build: seed it with what the enclosing list has already
// spent so a viewport's contents cannot start over with a fresh allowance and exceed the
// documented limit (CWE-400). Both the serial sub-builder and the parallel chunks share it.
let budget = std::sync::atomic::AtomicUsize::new(b.list.expanded);
let mut sub = Builder {
list: DisplayList::default(),
opts: b.opts,
plotting: b.plotting,
styles: b.styles,
named: None,
shared_budget: None,
shared_budget: Some(&budget),
transparency: 0,
};
#[cfg(not(target_arch = "wasm32"))]
@@ -754,7 +758,6 @@ fn contents(b: &mut Builder, d: &Drawing, vp: &cadcraft_doc::Viewport, area: Bou
// As `build_parallel`: chunks built side by side, joined in drawing order.
use rayon::prelude::*;
let ents: Vec<&std::sync::Arc<Entity>> = d.model.iter().collect();
let budget = std::sync::atomic::AtomicUsize::new(0);
let opts = b.opts;
let parts: Vec<DisplayList> = ents
.par_chunks(PARALLEL_CHUNK)
+16 -1
View File
@@ -94,8 +94,16 @@ pub fn ranges(len: f64, lt: &Linetype, scale: f64, min_pattern: f64) -> Option<V
}
let mut out = Vec::new();
let mut pos = 0.0;
// Gap-only patterns draw nothing, matching `apply`, without walking every repeat.
if !lt.pattern.iter().any(|d| d.length >= 0.0) {
return Some(Vec::new());
}
// Bound the pattern elements visited, independent of emitted ranges, so a pattern that emits
// little still cannot cycle hundreds of millions of times.
let mut visits: u64 = 0;
for d in lt.pattern.iter().map(|d| d.length * scale).cycle() {
if pos >= len || out.len() > 2_000_000 {
visits += 1;
if pos >= len || out.len() > 2_000_000 || visits > 4_000_000 {
break;
}
if d >= 0.0 {
@@ -110,6 +118,13 @@ pub fn ranges(len: f64, lt: &Linetype, scale: f64, min_pattern: f64) -> Option<V
mod tests {
use super::*;
#[test]
fn an_all_gap_pattern_draws_nothing_without_spinning() {
let lt = Linetype::simple("GAPS", "", &[-0.001, -0.002]);
assert_eq!(ranges(1.0, &lt, 1.0, 0.0), Some(Vec::new()));
assert!(apply(&[Vec2::ZERO, Vec2::new(1.0, 0.0)], &lt, 1.0, 0.0).is_empty());
}
#[test]
fn dashes_split_line() {
let lt = Linetype::simple("DASHED", "", &[0.5, -0.25]);
+70 -16
View File
@@ -53,17 +53,30 @@ pub struct TableGeom {
/// Which cells are covered by a merge (and so not drawn on their own).
pub fn table_covered(t: &Table) -> Vec<Vec<bool>> {
coverage(t).unwrap_or_else(|partial| partial)
}
// No partial coverage may reach owner assignment: it would revisit unbounded merge rectangles.
fn coverage(t: &Table) -> Result<Vec<Vec<bool>>, Vec<Vec<bool>>> {
let rows = t.row_heights.len().min(10_000);
let cols = t.col_widths.len().min(10_000);
if rows.saturating_mul(cols) > MAX_LAYOUT_CELLS {
return vec![Vec::new(); rows];
return Err(vec![Vec::new(); rows]);
}
let mut cov = vec![vec![false; cols]; rows];
// Coverage marking visits each merge's whole rectangle. Overlapping merges re-mark cells that
// are already covered, so bound the total marking steps to a small multiple of the cell count
// rather than letting overlapping declarations cause quadratic work (CWE-407).
let mut budget: u64 = (rows as u64).saturating_mul(cols as u64).saturating_mul(4).saturating_add(1024);
for (r, row) in t.cells.iter().enumerate().take(rows) {
for (c, cell) in row.iter().enumerate().take(cols) {
if let Some((rs, cs)) = cell.merged {
for rr in r..(r + rs.max(1) as usize).min(rows) {
for cc in c..(c + cs.max(1) as usize).min(cols) {
budget = budget.saturating_sub(1);
if budget == 0 {
return Err(cov);
}
if (rr, cc) != (r, c)
&& let Some(x) = cov.get_mut(rr).and_then(|v| v.get_mut(cc))
{
@@ -74,7 +87,7 @@ pub fn table_covered(t: &Table) -> Vec<Vec<bool>> {
}
}
}
cov
Ok(cov)
}
/// The table style a table uses (Standard defaults when missing).
@@ -134,19 +147,25 @@ pub fn table_geometry(d: &Drawing, t: &Table) -> TableGeom {
/// One part of a broken table as a table of its own: its rows (with their cell styles) at
/// `origin`; merges are cut at the part's edge.
fn part_table(t: &Table, rows: &[usize], origin: Vec2) -> Table {
let cols = t.col_widths.len();
let mut cells: Vec<Vec<cadcraft_doc::TableCell>> = rows.iter().map(|r| t.cells.get(*r).cloned().unwrap_or_default()).collect();
for (i, row) in cells.iter_mut().enumerate() {
row.resize_with(cols, Default::default);
for cell in row.iter_mut() {
if let Some((rs, cs)) = cell.merged {
// Rows of the merge that follow in this part.
let start = rows.get(i).copied().unwrap_or(0);
let mut n = 1u32;
while n < rs && rows.get(i + n as usize).is_some_and(|r| *r == start + n as usize) {
n += 1;
let cols = t.col_widths.len().min(10_000);
let mut cells: Vec<Vec<cadcraft_doc::TableCell>> =
rows.iter().take(10_000).map(|r| t.cells.get(*r).map(|row| row.iter().take(cols).cloned().collect()).unwrap_or_default()).collect();
// Only expand sparse rows to the full column count when detailed layout is actually permitted.
// Otherwise `layout` renders an outline, so materialising millions of empty cells here would be
// wasted work an attacker can trigger with a compact dimension array (CWE-789).
if rows.len().min(10_000).saturating_mul(cols) <= MAX_LAYOUT_CELLS {
for (i, row) in cells.iter_mut().enumerate() {
row.resize_with(cols, Default::default);
for cell in row.iter_mut() {
if let Some((rs, cs)) = cell.merged {
// Rows of the merge that follow in this part.
let start = rows.get(i).copied().unwrap_or(0);
let mut n = 1u32;
while n < rs && rows.get(i + n as usize).is_some_and(|r| *r == start + n as usize) {
n += 1;
}
cell.merged = (n > 1 || cs > 1).then_some((n, cs));
}
cell.merged = (n > 1 || cs > 1).then_some((n, cs));
}
}
}
@@ -180,7 +199,8 @@ fn layout(d: &Drawing, t: &Table) -> TableGeom {
if rows == 0 || cols == 0 {
return g;
}
if rows.saturating_mul(cols) > MAX_LAYOUT_CELLS {
let cov = coverage(t);
if rows.saturating_mul(cols) > MAX_LAYOUT_CELLS || cov.is_err() {
let (w, h) = (x_at(cols), y_at(rows));
let corners = [p(0.0, 0.0), p(w, 0.0), p(w, h), p(0.0, h), p(0.0, 0.0)];
for s in corners.windows(2) {
@@ -209,7 +229,7 @@ fn layout(d: &Drawing, t: &Table) -> TableGeom {
i
};
// Anchors and the anchor owning each grid cell.
let cov = table_covered(t);
let cov = cov.unwrap_or_default();
let mut anchors: Vec<Anchor> = Vec::new();
let mut owner = vec![usize::MAX; rows * cols];
for r in 0..rows {
@@ -369,3 +389,37 @@ pub(crate) fn draw(b: &mut Builder, ctx: &Ctx, t: &Table, rgb: Ink, lw: f32) {
}
}
}
#[cfg(test)]
mod budget_tests {
use super::*;
use cadcraft_doc::TableCell;
#[test]
fn overlapping_merges_use_outline_fallback() {
let t = Table {
row_heights: vec![1.0; 40],
col_widths: vec![1.0; 40],
cells: vec![vec![TableCell { merged: Some((40, 40)), ..Default::default() }; 40]; 40],
..Default::default()
};
assert!(coverage(&t).is_err());
let g = layout(&Drawing::default(), &t);
assert_eq!(g.lines.len(), 4);
assert!(g.fills.is_empty() && g.texts.is_empty());
}
#[test]
fn part_clips_merges_using_displayed_column_count() {
let t = Table {
row_heights: vec![1.0; 102],
col_widths: vec![1.0; 10_001],
cells: vec![vec![TableCell { merged: Some((100, 1)), ..Default::default() }]],
..Default::default()
};
let mut rows: Vec<_> = (0..99).collect();
rows.push(101);
let part = part_table(&t, &rows, Vec2::ZERO);
assert_eq!(part.cells[0][0].merged, Some((99, 1)));
}
}
+25
View File
@@ -297,6 +297,31 @@ fn truetype_text_is_filled_or_outlined() {
assert!(l.prims.len() >= 3, "O has two contours, H one");
}
// Regression test for #651: filled TrueType text was triangulated at full outline detail at every
// zoom, so a page of small text on screen produced millions of triangles and froze the canvas.
#[test]
fn small_filled_text_on_screen_is_simplified_but_plots_keep_full_detail() {
let Some(font) = system_font() else { return };
let d = text_doc(font, "OOOO");
let tris = |l: &DisplayList| l.prims.iter().filter(|p| p.kind == Kind::Tris).map(|p| l.points(p).len() / 3).sum::<usize>();
let plot = build_plot(&d, &Space::Model, &Options::default());
let full = tris(&plot);
assert!(full > 100, "full-detail O glyphs are many triangles: {full}");
// One pixel is a tenth of the cap height: the outlines are simplified, the shapes stay.
let reduced = build(&d, &Space::Model, &screen(0.1, None));
assert!(tris(&reduced) < full / 2, "{} triangles at 10 px per cap height, {full} in a plot", tris(&reduced));
assert!(tris(&reduced) > 0 && reduced.bounds.width() > plot.bounds.width() * 0.9, "{:?} vs {:?}", reduced.bounds, plot.bounds);
// An em under two pixels is drawn as the ink's box: two triangles a glyph, same extent.
let tiny = build(&d, &Space::Model, &screen(1.0, None));
assert_eq!(tris(&tiny), 2 * 4, "one box per glyph");
assert!(
tiny.bounds.width() > plot.bounds.width() * 0.9 && tiny.bounds.height() > plot.bounds.height() * 0.9,
"{:?} vs {:?}",
tiny.bounds,
plot.bounds
);
}
#[test]
fn missing_font_falls_back_to_stroke() {
let d = text_doc("NoSuchFontAnywhere.ttf", "OH");
+1
View File
@@ -23,6 +23,7 @@ egui-wgpu = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
chrono = { workspace = true }
image = { workspace = true }
[dev-dependencies]
+29 -12
View File
@@ -111,23 +111,36 @@ impl CadApp {
}
}
/// `secs` since 1970 as `YYYY-MM-DD HH:MM` (UTC).
/// `secs` since 1970 as `YYYY-MM-DD HH:MM` (UTC). Past chrono's year 262143 range the proleptic
/// Gregorian date is kept via the 400-year cycle rather than falling back to the epoch.
fn utc(secs: u64) -> String {
let days = (secs / 86_400) as i64;
let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60);
// Civil date from days since 1970-01-01 (Howard Hinnant's algorithm).
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + i64::from(mo <= 2);
let (y, mo, d) = civil_from_days(secs / 86_400);
// `{y:04}` rather than `%Y`, which writes a `+` before years past 9999.
format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02}")
}
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day).
///
/// A recovery file's mtime can in principle land past `NaiveDate`'s ±262143-year range; the old
/// hand-rolled converter still produced a date there, so falling back to the epoch would change
/// the shown time. The Gregorian calendar repeats every 400 years (146_097 days), so chrono
/// converts the day within one cycle and whole cycles go back into the year (an `i128`, then
/// saturated into `i64`), keeping chrono as the only calendar logic and reproducing the old
/// result exactly. 1970-01-01 is `num_days_from_ce` 719_163.
fn civil_from_days(days: u64) -> (i64, u32, u32) {
use chrono::Datelike;
let (cycles, rest) = (days / 146_097, days % 146_097);
// `rest + 719_163` is 719_163..865_259, always a valid CE day.
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
Some(d) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day())
}
None => (1970, 1, 1), // unreachable
}
}
/// The Drawing Recovery window, while autosave files from a crash are waiting.
pub fn show(app: &mut CadApp, ctx: &egui::Context) {
if app.recovery.items.is_empty() {
@@ -233,5 +246,9 @@ mod tests {
assert_eq!(super::utc(0), "1970-01-01 00:00");
assert_eq!(super::utc(951_782_400 + 3_660), "2000-02-29 01:01");
assert_eq!(super::utc(1_791_676_800), "2026-10-11 00:00");
assert_eq!(super::utc(253_402_300_800), "10000-01-01 00:00");
// Past chrono's range the proleptic Gregorian date is kept, not the epoch (old output).
assert_eq!(super::utc(10_000_000_000_000), "318857-05-20 17:46");
assert_eq!(super::utc(u64::MAX), "584554051223-11-09 07:00");
}
}
+83
View File
@@ -0,0 +1,83 @@
//! OFFSET on the canvas: the distance can be clicked, and picking the source is not moved by the
//! grid snap (#618).
use cadcraft_engine::Session;
use cadcraft_engine::doc::{Common, Drawing, EntityKind, Line, Space};
use cadcraft_engine::geom::Vec2;
use cadcraft_ui_egui::{CadApp, Services};
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
use egui_kittest::Harness;
use serde_json::json;
fn harness() -> Harness<'static, CadApp> {
let mut d = Drawing::new_imperial();
d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec2::new(0.0, 2.0).to3(0.0), b: Vec2::new(10.0, 2.0).to3(0.0) })).unwrap();
let mut session = Session::empty();
session.open_drawing(d, "Offset clicks", None);
session.execute("view.set", &json!({"center":[5,5],"height":20})).unwrap();
session.settings.osmode = 0;
session.settings.snapmode = true;
session.execute("setvar", &json!({"name":"SNAPUNIT","value":[5,5]})).unwrap();
session.settings.orthomode = false;
let mut app = CadApp::new(session, Services::default());
app.canvas.zoom_pending = false;
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(
|ui, app: &mut CadApp| {
app.logic(ui.ctx());
app.ui(ui);
},
app,
);
h.run_steps(5);
h
}
fn click(h: &mut Harness<'_, CadApp>, world: Vec2) {
let pos: Pos2 = h.state().canvas.xf.unwrap().to_screen(world);
h.event(Event::PointerMoved(pos));
h.run_steps(2);
for pressed in [true, false] {
h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.run_steps(2);
}
}
// Regression test for #618: OFFSET's distance could not be clicked, and with grid snap on a click
// meant to pick the source object was moved to the nearest grid point, missing the object.
#[test]
fn offset_takes_a_clicked_distance_and_picks_its_source_under_grid_snap() {
let mut h = harness();
h.state_mut().start("offset");
h.run_steps(2);
// Two clicks, snapped to the grid points (5, 5) and (10, 5): a distance of 5.
click(&mut h, Vec2::new(5.1, 4.9));
assert!(h.state().session.prompt_text().contains("second point"), "{}", h.state().session.prompt_text());
click(&mut h, Vec2::new(9.9, 5.1));
assert_eq!(h.state().session.doc().unwrap().header.f64("OFFSETDIST", -1.0), 5.0);
assert!(h.state().session.prompt_text().contains("Select object"), "{}", h.state().session.prompt_text());
// The source line is at y = 2, two units from the grid point (5, 0) the click would snap to.
click(&mut h, Vec2::new(7.0, 2.04));
let prompt = h.state().session.prompt_text();
assert!(prompt.contains("side to offset"), "the line was not picked: {prompt}");
// A side click above the line (snapped to (5, 5)) makes the copy 5 units up, at y = 7.
click(&mut h, Vec2::new(7.0, 7.0));
let lines: Vec<(f64, f64)> = h
.state()
.session
.doc()
.unwrap()
.model
.iter()
.filter_map(|e| match &e.kind {
EntityKind::Line(line) => Some((line.a.y, line.b.y)),
_ => None,
})
.collect();
assert_eq!(lines.len(), 2, "OFFSET added no line: {lines:?}");
assert!(lines.iter().any(|&(a, b)| (a - 7.0).abs() < 1e-9 && (b - 7.0).abs() < 1e-9), "no copy at y = 7: {lines:?}");
// OFFSET asks for the next object; Enter ends it.
assert!(h.state().session.prompt_text().contains("Select object to offset"), "{}", h.state().session.prompt_text());
h.key_press(egui::Key::Enter);
h.run_steps(2);
assert_eq!(h.state().session.prompt_text(), "Command:", "Enter ends OFFSET");
}
+9 -1
View File
@@ -5,10 +5,18 @@ Start the desktop app with `--control PORT` (or `CADCRAFT_CONTROL_PORT=PORT`). I
`--control` wins over the variable; a bare `--control` keeps the variable's port.
```json
{"id": 1, "method": "cmdline.input", "params": {"text": "circle 0,0 5"}}
{"id": 1, "method": "cmdline.input", "params": {"text": "circle 0,0 5"}, "token": "<session token>"}
{"id": 1, "ok": true, "result": {"prompt": "Command:", "output": ["Command: CIRCLE"], ...}}
```
Every request must carry the session `token`. Because loopback TCP cannot tell local operating
system users apart, the app writes an unguessable per-session token to an owner-only file when the
control server starts, and refuses (`unauthorized`) any request without a matching token. The file
is `cadcraft-control-<port>.token` in the per-user runtime directory (`$XDG_RUNTIME_DIR`, else
`~/.cache`; `%LOCALAPPDATA%` on Windows); its path is logged at startup. A client reads the token
from that file — which requires the desktop user's own filesystem access — and includes it on each
line. The MCP bridge (`cadcraft-cli mcp --connect`) does this automatically.
Errors come back as `{"id": …, "ok": false, "error": "message"}`. An unknown method's error points
to `control.methods`, which lists the methods the server answers.
+7 -2
View File
@@ -18,11 +18,16 @@ if [ -z "${APPLE_CERTIFICATE:-}" ]; then
fi
TMP="${RUNNER_TEMP:-${TMPDIR:-/tmp}}"
# Create a private per-run directory (mode 0700 via the umask above) so the decoded signing
# container cannot be read by, or pre-created by, another local user in a shared temp directory.
CERT_DIR="$(mktemp -d "$TMP/cadcraft-signing.XXXXXXXX")"
KEYCHAIN="$TMP/cadcraft-signing.keychain-db"
CERT="$TMP/cadcraft-signing.p12"
CERT="$CERT_DIR/cadcraft-signing.p12"
KC_PASS="${KEYCHAIN_PASSWORD:-$(openssl rand -hex 24)}"
trap 'rm -f "$CERT"' EXIT
trap 'rm -f "$CERT"; rm -rf "$CERT_DIR"' EXIT
# Create the certificate file privately before writing, refusing to follow a pre-existing entry.
( set -C; : >"$CERT" )
printf '%s' "$APPLE_CERTIFICATE" | base64 --decode >"$CERT"
security create-keychain -p "$KC_PASS" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
+1
View File
@@ -7,3 +7,4 @@ publish = false
[dependencies]
serde_json = { workspace = true }
chrono = { workspace = true }
+40 -18
View File
@@ -28,25 +28,39 @@ pub fn leaves(catalog: &str) -> Vec<String> {
/// Where the generated checklist goes.
pub const OUT: &str = "docs/parity-checklist.md";
/// Today's UTC date as `YYYY-MM-DD` (from the system clock; no date crate needed).
/// Today's UTC date as `YYYY-MM-DD` (from the system clock).
fn today() -> String {
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
let (y, m, d) = civil_from_days((secs / 86_400) as i64);
date(std::time::SystemTime::now())
}
/// `t` as a UTC `YYYY-MM-DD`. A clock before 1970 reads as 1970-01-01 (the `unwrap_or(0)` clamp);
/// past chrono's year 262143 range the proleptic Gregorian date is kept via the 400-year cycle
/// rather than falling back to the epoch.
fn date(t: std::time::SystemTime) -> String {
let secs = t.duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
let (y, m, d) = civil_from_days(secs / 86_400);
// `{y:04}` rather than `%Y`, which writes a `+` before years past 9999.
format!("{y:04}-{m:02}-{d:02}")
}
/// Days since 1970-01-01 → (year, month, day) in the proleptic Gregorian calendar.
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
let y = yoe + era * 400 + i64::from(m <= 2);
(y, m, d)
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day).
///
/// `date` clamps a pre-1970 clock to the epoch, so `days` is an unsigned count that can run past
/// `NaiveDate`'s ±262143-year range. The Gregorian calendar repeats every 400 years (146_097
/// days), so chrono converts the day within one cycle and whole cycles go back into the year (an
/// `i128`, then saturated into `i64`), keeping chrono as the only calendar logic and reproducing
/// the old `civil_from_days` result exactly. 1970-01-01 is `num_days_from_ce` 719_163.
fn civil_from_days(days: u64) -> (i64, u32, u32) {
use chrono::Datelike;
let (cycles, rest) = (days / 146_097, days % 146_097);
// `rest + 719_163` is 719_163..865_259, always a valid CE day.
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
Some(d) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day())
}
None => (1970, 1, 1), // unreachable
}
}
pub fn run(root: &Path) -> Result<(), String> {
@@ -116,9 +130,17 @@ mod tests {
#[test]
fn civil_dates() {
assert_eq!(civil_from_days(0), (1970, 1, 1));
assert_eq!(civil_from_days(11_016), (2000, 2, 29));
assert_eq!(civil_from_days(20_736), (2026, 10, 10));
let day = |d: u64| date(std::time::UNIX_EPOCH + std::time::Duration::from_secs(d * 86_400));
assert_eq!(day(0), "1970-01-01");
assert_eq!(day(11_016), "2000-02-29");
assert_eq!(day(20_736), "2026-10-10");
assert_eq!(date(std::time::UNIX_EPOCH - std::time::Duration::from_secs(86_400)), "1970-01-01");
// Past chrono's range keeps the proleptic Gregorian date, not the epoch (old `civil_from_days`).
assert_eq!(day(115_740_740), "318857-05-20");
// Windows' SystemTime can't hold u64::MAX / 2 seconds, so it is only checked where it exists.
if let Some(far) = std::time::UNIX_EPOCH.checked_add(std::time::Duration::from_secs(u64::MAX / 2)) {
assert_eq!(date(far), "292277026596-12-04");
}
}
#[test]
+58 -7
View File
@@ -10,20 +10,41 @@ struct Counts {
proptests: usize,
}
fn walk(dir: &Path, c: &mut Counts) {
/// Upper bound on directory entries visited in one `walk`, so even a crafted tree of aliases can
/// never make the stats command run unbounded work.
const MAX_ENTRIES: usize = 2_000_000;
fn walk(dir: &Path, c: &mut Counts) -> Result<(), String> {
let mut budget = MAX_ENTRIES;
walk_inner(dir, c, &mut budget)
}
fn walk_inner(dir: &Path, c: &mut Counts, budget: &mut usize) -> Result<(), String> {
let Ok(rd) = std::fs::read_dir(dir) else {
return;
return Ok(());
};
for e in rd.flatten() {
if *budget == 0 {
return Err(format!("stats traversal budget exhausted at {}", dir.display()));
}
*budget -= 1;
// Use the entry's own type and never follow symlinks: a directory symlink would otherwise
// be re-scanned as a fresh directory, so a small graph of aliases could cause exponential
// repeated enumeration and reads (CWE-400).
let Ok(ft) = e.file_type() else { continue };
if ft.is_symlink() {
continue;
}
let p = e.path();
let name = e.file_name();
let name = name.to_string_lossy();
if p.is_dir() {
if ft.is_dir() {
// Nested fuzz workspaces and build output are not part of the crate.
if name != "target" && name != "fuzz" && !name.starts_with('.') {
walk(&p, c);
walk_inner(&p, c, budget)?;
}
} else if name.ends_with(".rs")
} else if ft.is_file()
&& name.ends_with(".rs")
&& let Ok(s) = std::fs::read_to_string(&p)
{
c.files += 1;
@@ -33,6 +54,7 @@ fn walk(dir: &Path, c: &mut Counts) {
c.proptests += pt;
}
}
Ok(())
}
/// `#[test]` attributes and `proptest!` blocks (each block may hold many
@@ -71,7 +93,7 @@ pub fn run(_root: &Path, exact: bool) -> Result<(), String> {
continue;
};
let mut c = Counts::default();
walk(&dir, &mut c);
walk(&dir, &mut c)?;
let ex = if exact { exact_tests(&name) } else { None };
rows.push((name, c, ex));
}
@@ -108,11 +130,40 @@ pub fn run(_root: &Path, exact: bool) -> Result<(), String> {
#[cfg(test)]
mod tests {
use super::count_tests;
use super::{Counts, count_tests, walk};
#[test]
fn counts_attributes_and_proptest_blocks() {
let src = "#[test]\nfn a() {}\n// #[test] commented\n #[test]\nfn b() {}\nproptest! {\n}\n";
assert_eq!(count_tests(src), (2, 1));
}
#[cfg(unix)]
#[test]
fn walk_does_not_follow_directory_symlinks() {
use std::os::unix::fs::symlink;
let base = std::env::temp_dir().join(format!("cadcraft-xtask-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let real = base.join("real");
std::fs::create_dir_all(&real).unwrap();
std::fs::write(real.join("a.rs"), "fn a() {}\n").unwrap();
// A directory symlink pointing back at `real` would be re-scanned if followed.
symlink(&real, base.join("alias")).unwrap();
let mut c = Counts::default();
walk(&base, &mut c).unwrap();
assert_eq!(c.files, 1, "the aliased directory was followed and counted twice");
std::fs::remove_dir_all(&base).ok();
}
#[test]
fn walk_reports_budget_exhaustion() {
let base = std::env::temp_dir().join(format!("cadcraft-stats-budget-{}", std::process::id()));
std::fs::create_dir_all(&base).unwrap();
std::fs::write(base.join("a.rs"), "fn a() {}\n").unwrap();
let error = super::walk_inner(&base, &mut Counts::default(), &mut 0).unwrap_err();
assert!(error.contains("budget exhausted"));
let mut c = Counts::default();
super::walk_inner(&base, &mut c, &mut 1).unwrap();
assert_eq!(c.files, 1);
std::fs::remove_dir_all(base).unwrap();
}
}