* Build: line tables only in dev, no debug info for dependencies
* Tests: one integration-test binary per crate
Each crate's tests/*.rs becomes a module of tests/it/main.rs. Files that
install process-wide state (the file io hooks, user aliases, SAVETIME and
SAVEFILEPATH, user linetype and pattern files, registered fonts) stay their
own test binaries.
* xtask ci: nextest, tests without examples, one wasm check call
All flagged scripts run under bash (shebang or '# shellcheck shell=bash'), so
[[ ]] is safe. Positional parameters in helper functions are copied into named
locals, and helper functions end with a bare 'return', which keeps the exit
status of the last command (no behaviour change, notably for sha256 under
pipefail). packaging/linux/postinst.sh is POSIX sh and is left alone.
user-scalable=no stops people with low vision from zooming the page. The
canvas keeps its own pinch/pan handling: eframe already prevent_default()s
touch and gesture events on the canvas, so dropping the flag only restores
browser zoom (and accessibility zoom) outside of the app's gestures.
The optional `CRAFT_FONTS_DIR` build input lets a font manifest select the file
each face is embedded from. `build.rs` canonicalised that file with no
containment check, so an absolute path, a `..` traversal, or a symlink inside
the checkout could make the generated `include_bytes!` read an arbitrary file
on the build host and expose its bytes through `CRAFT_FONTS` (CWE-22).
It also printed the resolved path straight into Cargo's newline-delimited
build-script protocol (`cargo::rerun-if-changed=…`). A path containing an
embedded newline — reachable through a crafted symlink target — could inject a
second directive such as `cargo::rustc-env=OUT_DIR=…`, redirecting the
generated-source include to attacker-supplied Rust (CWE-74).
- `resolve_font` rejects absolute and `..` manifest paths and requires the
canonicalised result to stay inside the canonicalised checkout root.
- `rerun_if_changed` refuses any path containing CR/LF before emitting the
directive; `one_line` collapses CR/LF in checkout-derived diagnostics.
Addresses findings rows 5 (CWE-22) and 14 (CWE-74).
The External References palette's XATTACH handler passed the file picker's
result to `app.cmdline(&path)`. `cmdline` interprets command-line syntax, so a
selected filename beginning with an apostrophe (for example `'closeall`) was run
as an engine command rather than naming the file — closing every drawing without
the UI's unsaved-change confirmation.
The picked path is attacker-controlled (the picker returns whatever the user
selected, and `relative_to_host` can reduce it to a bare basename). After
starting XATTACH, submit it through `Session::input(Input::Text(path))` so it is
delivered to the command's prompt as literal text and never interpreted as a
command, matching how other picker/dialog bridges already behave.
Addresses finding row 26.
The native DWG bridge wraps the third-party `acadrust` decoder, which allocates
tables and section buffers from sizes a file declares and cannot be interrupted
once it starts. The DWG->DXF read path already rejects oversized input before
decoding, caps the DXF output while writing, and runs inside `catch_unwind`. The
DXF->DWG `convert` path had no input-size guard, so a very large DXF went
straight into the dependency's reader.
- `convert` now rejects DXF input larger than `Limits::max_dxf_bytes` before
calling `DxfReader::read`, symmetric with the read path, and keeps the existing
`catch_unwind` around the decoder and writer (rows 23, 3, 27, large-input
variant).
- Documented the residual risk: a small file that declares a huge internal table
or section can still make the dependency allocate before any size is known.
Fully fixing that needs an upstream fix in `acadrust` (validate declared sizes
against a decoded-memory budget) or a separate worker with an OS memory limit;
the in-crate limits keep the blast radius bounded until then.
Addresses findings rows 3, 23, 27 (bounds the reachable input/output size;
the deeper allocation fix belongs in the unmodified dependency).
* 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 <26588250+skylartaylor@users.noreply.github.com>
* 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 <26588250+skylartaylor@users.noreply.github.com>
* 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 <26588250+skylartaylor@users.noreply.github.com>
* 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 <26588250+skylartaylor@users.noreply.github.com>
* 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 <26588250+skylartaylor@users.noreply.github.com>
* 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 <26588250+skylartaylor@users.noreply.github.com>
* 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 <26588250+skylartaylor@users.noreply.github.com>
* 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 <26588250+skylartaylor@users.noreply.github.com>
* 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 <26588250+skylartaylor@users.noreply.github.com>
* 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 <26588250+skylartaylor@users.noreply.github.com>
* 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.
Calls storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
with secrets: inherit. Adds SonarCloud badge to README where missing and
removes the redundant sonar-project.properties (config now passed by the
reusable workflow).