mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-24 10:56:51 -06:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0931c8b286 | ||
|
|
3a2d3608d3 | ||
|
|
dc4bbe86f6 | ||
|
|
953a6b3b01 | ||
|
|
8085745a22 | ||
|
|
ca578a8fb7 | ||
|
|
916be26ed3 | ||
|
|
2dbc7699a0 | ||
|
|
7c72ec45a9 | ||
|
|
0158712bfd | ||
|
|
683eb5e6e7 | ||
|
|
345fb27a69 |
Generated
+3
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>| {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(§ions(&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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
{
|
||||
|
||||
@@ -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")]
|
||||
{
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)))),
|
||||
}
|
||||
|
||||
@@ -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")),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 +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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod backend;
|
||||
pub mod control_token;
|
||||
mod server;
|
||||
mod tools;
|
||||
|
||||
|
||||
@@ -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())));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, <, 1.0, 0.0), Some(Vec::new()));
|
||||
assert!(apply(&[Vec2::ZERO, Vec2::new(1.0, 0.0)], <, 1.0, 0.0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dashes_split_line() {
|
||||
let lt = Linetype::simple("DASHED", "", &[0.5, -0.25]);
|
||||
|
||||
+70
-16
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user