Compare commits

..
Author SHA1 Message Date
skylartaylor 0931c8b286 docs: describe complex shaping and its empty-glyph regression 2026-10-23 01:08:03 +02:00
skylartaylor 3a2d3608d3 fix: filter empty outlines in complex shaping 2026-10-23 00:19:26 +02: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 <26588250+skylartaylor@users.noreply.github.com>
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 <26588250+skylartaylor@users.noreply.github.com>
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 <26588250+skylartaylor@users.noreply.github.com>
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 <26588250+skylartaylor@users.noreply.github.com>
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 <26588250+skylartaylor@users.noreply.github.com>
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 <26588250+skylartaylor@users.noreply.github.com>
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 <26588250+skylartaylor@users.noreply.github.com>
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 <26588250+skylartaylor@users.noreply.github.com>
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 <26588250+skylartaylor@users.noreply.github.com>
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 <26588250+skylartaylor@users.noreply.github.com>
2026-10-22 17:03:46 -04:00
57 changed files with 1917 additions and 205 deletions
Generated
+3
View File
@@ -551,6 +551,7 @@ version = "0.11.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
"cadcraft-mcp",
"cadcraft-ui-egui",
"chrono",
"eframe",
@@ -636,6 +637,7 @@ dependencies = [
"cadcraft-io",
"cadcraft-render",
"craft-docpath",
"libc",
"log",
"rstar",
"serde",
@@ -687,6 +689,7 @@ dependencies = [
"cadcraft-engine",
"cadcraft-io",
"cadcraft-render",
"getrandom 0.3.4",
"log",
"serde_json",
]
+2
View File
@@ -14,6 +14,8 @@ 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 }
+74 -12
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}");
@@ -72,9 +85,9 @@ fn accepted<T>(incoming: impl Iterator<Item = std::io::Result<T>>, mut wait: imp
}
/// 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
}
@@ -88,7 +101,7 @@ 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 accepted(listener.incoming(), || std::thread::sleep(ACCEPT_RETRY)) {
let _ = stream.set_write_timeout(Some(limits.write_timeout));
@@ -100,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);
});
}
}
@@ -141,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.
@@ -172,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),
}
}
@@ -182,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;
}
@@ -200,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(()) => {
@@ -277,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.
@@ -295,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();
@@ -354,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();
@@ -377,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();
@@ -387,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();
+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
@@ -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();
}
}
+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;
+121 -25
View File
@@ -679,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);
}
}
}
'[' => {
@@ -714,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),
@@ -757,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;
@@ -770,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(),
@@ -785,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 ----------------
@@ -984,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")]
@@ -1503,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")),
}
}
}
}
+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 }
}
}
+58
View File
@@ -221,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();
+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());
+60 -6
View File
@@ -60,6 +60,7 @@ pub(crate) fn shape(primary: Option<&Face>, s: &str, height: f64, width_factor:
catch_unwind(AssertUnwindSafe(|| shape_chars(primary, &chars, height, width_factor, oblique))).ok()
}
/// Lay out decoded directional runs, keeping advances and decoration spans for empty glyphs.
fn shape_chars(primary: Option<&Face>, chars: &[(char, bool, bool)], height: f64, width_factor: f64, oblique: f64) -> Shaped {
let h = if height.is_finite() && height > 0.0 { height } else { 1.0 };
let wf = if width_factor.is_finite() && width_factor.abs() > 1e-6 { width_factor } else { 1.0 };
@@ -68,6 +69,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,12 +104,11 @@ 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() {
out.glyphs.push(contours);
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.push_glyph(contours, None);
}
let adv = g.advance * k;
let (u, o) = flags(g.cluster);
@@ -217,6 +218,59 @@ fn shape_run(face: &Face, cs: &[char], r: Range<usize>, rtl: bool) -> Vec<Glyph>
mod tests {
use super::*;
/// A single empty glyph with a positive advance, without relying on installed fonts.
fn empty_glyph_face() -> Face {
let mut head = vec![0_u8; 54];
head[..4].copy_from_slice(&0x0001_0000_u32.to_be_bytes());
head[18..20].copy_from_slice(&1000_u16.to_be_bytes());
let mut hhea = vec![0_u8; 36];
hhea[..4].copy_from_slice(&0x0001_0000_u32.to_be_bytes());
hhea[4..6].copy_from_slice(&800_i16.to_be_bytes());
hhea[6..8].copy_from_slice(&(-200_i16).to_be_bytes());
hhea[34..36].copy_from_slice(&1_u16.to_be_bytes());
let tables = [
(*b"head", head),
(*b"hhea", hhea),
(*b"hmtx", vec![1, 244, 0, 0]), // Advance 500, left side bearing 0.
(*b"maxp", vec![0, 0, 80, 0, 0, 1]), // Version 0.5, one glyph.
];
let mut bytes = vec![0_u8; 12 + tables.len() * 16];
bytes[..4].copy_from_slice(&0x0001_0000_u32.to_be_bytes());
bytes[4..6].copy_from_slice(&4_u16.to_be_bytes());
bytes[6..8].copy_from_slice(&64_u16.to_be_bytes());
bytes[8..10].copy_from_slice(&2_u16.to_be_bytes());
for (i, (tag, data)) in tables.into_iter().enumerate() {
while !bytes.len().is_multiple_of(4) {
bytes.push(0);
}
let record = 12 + i * 16;
let offset = u32::try_from(bytes.len()).unwrap();
let len = u32::try_from(data.len()).unwrap();
bytes[record..record + 4].copy_from_slice(&tag);
bytes[record + 8..record + 12].copy_from_slice(&offset.to_be_bytes());
bytes[record + 12..record + 16].copy_from_slice(&len.to_be_bytes());
bytes.extend(data);
}
Face::new(std::sync::Arc::new(bytes), 0).expect("synthetic font parses")
}
/// Empty complex glyphs retain spacing and underlines without adding geometry or text slots.
#[test]
fn empty_complex_glyph_keeps_advance_without_geometry() {
let face = empty_glyph_face();
let shaped = shape(Some(&face), " ", 1.0, 1.0, 0.0).expect("shapes empty glyph");
assert!(shaped.width > 0.0, "the font's advance is retained");
assert!(shaped.is_empty());
assert!(shaped.glyphs.is_empty());
assert!(shaped.text.is_empty());
let underlined = shape(Some(&face), "%%u ", 1.0, 1.0, 0.0).expect("shapes decorated empty glyph");
assert_eq!(underlined.width, shaped.width);
assert!(underlined.glyphs.is_empty());
assert!(underlined.text.is_empty());
assert!(!underlined.strokes.is_empty(), "decoration spans survive an empty outline");
}
#[test]
fn stroke_font_rtl_lines_keep_latin_and_digits_in_the_stroke_font() {
// With or without installed fonts: the Persian words are fallback glyphs or
+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());
}
}
+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);
}
}
+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,
+73 -9
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,6 +1436,10 @@ 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() {
@@ -1455,9 +1475,16 @@ 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" => {
@@ -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" => {
@@ -2310,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);
}
}
+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)
}
+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)));
}
}
+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"
+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();
}
}