Compare commits

...
Author SHA1 Message Date
Dylan M. Taylor c5617d3d7b address review: index placeable array replacements, search past zero-length DIVIDE runs, per-part broken-table outlines 2026-11-10 21:32:52 -05:00
Dylan M. Taylor e9aed3afac fix: index or bound array replacements, DIM field spellings, path samples, hatch refills and broken tables 2026-11-10 21:32:52 -05:00
Dylan M. Taylor 0843770389 fix(engine): read local files for RECOVER, INSERT, IMPORT, COMPARE, NEW, DATALINK and STYLESMANAGER through the capped reader (#2687)
A path naming an endless device or a FIFO was buffered to EOF before any size check ran, so one short command could exhaust memory or block. These paths now go through capped_read::read_capped, which refuses non-regular files and enforces the byte limit while reading.
2026-11-10 21:29:52 -05:00
Dylan M. Taylor 36f5ad64f9 fix(engine): move LISP output to the command history in one pass (#2714)
lisp_flush drained each line from the front of the output buffer, shifting everything after it every time, so newline-dense PRINC output cost quadratic time outside the interpreter's step budget. It now walks the buffer once and drops the printed prefix once.
2026-11-10 21:29:48 -05:00
Dylan M. Taylor 7804e94ee0 fix(geom): bound the points one polyline tessellation samples (#2723)
* fix(geom): bound the points one polyline tessellation samples

Polyline::tessellate sampled every arc segment (up to 4096 points each) with no total limit, and hatch boundary preparation did this for every loop, so compact curved vertices expanded into gigabytes. tessellate_within takes a point budget; once spent, remaining arcs are drawn as chords. Hatch loops share one budget.

* address review: correct tessellate_within doc
2026-11-10 21:29:40 -05:00
Dylan M. Taylor bc9ad6b059 fix: index or bound four small lookups (DOF selection, xref palette, QSELECT colours, log lines) (#2728)
* fix: index or bound four small lookups (DOF selection, xref palette, QSELECT colours, log lines)

DOF scanned the selection list per slot; the xref palette scanned the xref list per INSERT; QSELECT parsed a possibly long layer name as a colour for every entity; and a log message could carry CR/LF or other control characters that started apparent extra records. Each now uses a set or map, a length bound, or escaping, with the same results for ordinary input.

* address review: trim colour text before length limit
2026-11-10 21:29:35 -05:00
29 changed files with 329 additions and 58 deletions
+22 -3
View File
@@ -148,7 +148,20 @@ pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
let message = message.strip_suffix('\n').unwrap_or(message);
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
// A message can carry text from a file (a code page name, a path), so it must not be able to
// start what looks like another record: control characters other than tab and line feed are
// shown escaped, and every continuation line is indented, so only this function's own
// timestamp is ever at the start of a line.
let mut safe = String::with_capacity(message.len());
for c in message.chars() {
match c {
'\n' => safe.push_str("\n "),
'\t' => safe.push('\t'),
c if c.is_control() => safe.extend(c.escape_default()),
c => safe.push(c),
}
}
format!("{ts} {level:<5} [{thread}] {target}: {safe}\n")
}
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
@@ -441,8 +454,14 @@ mod tests {
format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "cadcraft", "app icon: invalid PNG signature"),
"2026-10-08T07:59:17.728Z WARN [main] cadcraft: app icon: invalid PNG signature\n"
);
// A multi-line message keeps its lines; the record still ends in exactly one newline.
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
// A multi-line message keeps its lines, indented so they can't pass for records; the
// record still ends in exactly one newline.
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\n b\n"));
// Control characters in a message (from a file) can't forge a record.
let forged = format_line("t", log::Level::Warn, "w", "x", "page\r\n2026-01-01T00:00:00Z ERROR [main] x: fake\u{1b}[0m");
assert_eq!(forged.lines().count(), 2);
assert!(forged.lines().skip(1).all(|l| l.starts_with(" ")));
assert!(!forged.contains('\r') && !forged.contains('\u{1b}'));
}
#[test]
+3 -1
View File
@@ -138,7 +138,9 @@ pub fn dof(d: &Drawing, handles: &[Handle]) -> Result<DofReport, SolveError> {
let cons: Vec<&Constraint> = d.constraints.iter().take(MAX_CONSTRAINTS).collect();
let mut prep = Prep::new(crate::model::MAX_RIGID_WORK);
let (comps, _) = build_with(d, &cons, &SolveOptions::default(), &mut prep)?;
let want = |h: Handle| handles.is_empty() || handles.contains(&h);
// The selection as a set, built once: a scan of the list per slot multiplied the two sizes.
let selected: HashSet<Handle> = handles.iter().copied().collect();
let want = |h: Handle| handles.is_empty() || selected.contains(&h);
let mut objects = Vec::new();
let mut seen = HashSet::new();
let mut total = Some(0usize);
+31 -1
View File
@@ -204,6 +204,23 @@ impl ArrayParams {
self.replaced.iter().rev().find(|r| (r.i, r.j, r.level) == (i, j, level)).map_or(self.source.as_str(), |r| r.block.as_str())
}
/// [`block_for`](Self::block_for) for many items: the replacements are indexed once (the last
/// entry for a place wins, as there), so each lookup is O(1) instead of a scan of `replaced`.
pub fn block_lookup<'a>(&'a self) -> impl Fn(u32, u32, u32) -> &'a str + 'a {
// Only places `items()` generates are indexed, so stray out-of-range records cost nothing.
let (cols, rows) = match &self.kind {
ArrayKind::Rect(r) => (r.cols, r.rows),
ArrayKind::Polar(p) => (p.items, p.rows),
};
let index: std::collections::HashMap<(u32, u32, u32), &str> = self
.replaced
.iter()
.filter(|r| r.i < cols && r.j < rows && r.level < self.levels)
.map(|r| ((r.i, r.j, r.level), r.block.as_str()))
.collect();
move |i, j, level| index.get(&(i, j, level)).copied().unwrap_or(self.source.as_str())
}
/// The block references that make up the array block: one per item, on layer 0 with ByBlock
/// properties so they take the array's. Handles are allocated from `d`. `None` when the
/// parameters are not [valid](Self::is_valid).
@@ -217,9 +234,10 @@ impl ArrayParams {
..Common::default()
};
let mut out = Vec::with_capacity(items.len());
let block_for = self.block_lookup();
for it in items {
let ins = Insert {
block: self.block_for(it.i, it.j, it.level).to_string(),
block: block_for(it.i, it.j, it.level).to_string(),
insert: it.insert,
scale: Vec3::new(1.0, 1.0, 1.0),
rotation: it.rotation,
@@ -301,6 +319,18 @@ mod tests {
}
}
#[test]
fn block_lookup_agrees_with_block_for_and_the_last_replacement_wins() {
let mut p = rect(3, 2);
let r = |i, j, level, b: &str| ReplacedItem { i, j, level, block: b.into() };
p.replaced = vec![r(1, 0, 0, "A"), r(1, 0, 0, "B"), r(2, 1, 1, "C")];
let look = p.block_lookup();
for (i, j, level) in [(0, 0, 0), (1, 0, 0), (2, 1, 1), (2, 1, 0)] {
assert_eq!(look(i, j, level), p.block_for(i, j, level));
}
assert_eq!((look(1, 0, 0), look(0, 0, 0)), ("B", "*U1"));
}
#[test]
fn rect_items_go_column_row_level() {
let items = rect(3, 2).items().unwrap_or_default();
+6
View File
@@ -674,6 +674,10 @@ fn apply(
/// Associative hatches of block `b` whose loop is one closed polyline of the block take its
/// outline again (it may have been stretched).
fn refill_hatches(b: &mut Block) {
// Each loop gets its own copy of the outline, so many small loops on one big outline multiply it;
// past this many vertices in all, the remaining loops keep the outline they have.
const MAX_REFILLED_VERTICES: usize = 4_000_000;
let mut budget = MAX_REFILLED_VERTICES;
let outlines: HashMap<Handle, Vec<cadcraft_geom::PolyVertex>> = b
.entities
.iter()
@@ -689,7 +693,9 @@ fn refill_hatches(b: &mut Block) {
for l in &mut hh.loops {
if let [s] = l.sources.as_slice()
&& let Some(vs) = outlines.get(s)
&& let Some(left) = budget.checked_sub(vs.len())
{
budget = left;
l.vertices.clone_from(vs);
}
}
+7
View File
@@ -491,11 +491,18 @@ impl DimStyle {
use serde_json::Value;
let mut rejected = Vec::new();
let Ok(mut cur) = serde_json::to_value(&*self) else { return src.keys().cloned().collect() };
// Spellings of one field (`DIMPOST`, `dimpost`, `textPrefix`…): only the last is applied, so
// the style is checked once per field, not once per spelling (each check copies its strings).
let last_spelling: std::collections::HashMap<&str, &String> =
src.keys().filter_map(|k| DimStyle::field_name(k).filter(|f| *f != "name").map(|f| (f, k))).collect();
for (k, v) in src {
let Some(field) = DimStyle::field_name(k).filter(|f| *f != "name") else {
rejected.push(k.clone());
continue;
};
if last_spelling.get(field) != Some(&k) {
continue;
}
let Some(obj) = cur.as_object_mut() else { break };
let Some(old) = obj.get(field).cloned() else {
rejected.push(k.clone());
+11
View File
@@ -714,3 +714,14 @@ fn space_names_its_tab() {
assert_eq!(p.layout(), Some("Sheet A"));
assert_eq!(p.tab_name(), "Sheet A");
}
/// Several spellings of one DIM field apply once, the last in the map's order, and none is rejected.
#[test]
fn dim_style_spellings_of_one_field_apply_once() {
let mut st = DimStyle::default();
let m = serde_json::json!({ "textHeight": 2.5, "TEXTHEIGHT": 3.5, "DIMTXT": 4.5 });
let m = m.as_object().unwrap();
let want = m.values().next_back().and_then(serde_json::Value::as_f64).unwrap();
assert!(st.apply_fields(m).is_empty());
assert_eq!(st.text_height, want);
}
+2 -1
View File
@@ -188,8 +188,9 @@ fn item_at(d: &Drawing, h: Handle, at: Vec2, tol: f64) -> Option<(u32, u32, u32)
let mut best: Option<((u32, u32, u32), f64)> = None;
// Each block's own extents, measured once.
let mut extents: HashMap<String, (Vec2, Bounds2)> = HashMap::new();
let block_for = p.block_lookup();
for it in p.items()? {
let name = p.block_for(it.i, it.j, it.level);
let name = block_for(it.i, it.j, it.level);
let (base, local) = match extents.get(name) {
Some(x) => *x,
None => {
+1 -1
View File
@@ -157,7 +157,7 @@ fn write_audit_log(_: &Session, _: &crate::audit::Report) {}
#[cfg(not(target_arch = "wasm32"))]
fn read_path(cmd: &str, path: &str) -> Result<Vec<u8>> {
std::fs::read(path).map_err(|e| bad(cmd, format!("{path}: {e}")))
crate::capped_read::read_capped(std::path::Path::new(path), file::MAX_DRAWING_BYTES).map_err(|e| bad(cmd, format!("{path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
+2 -1
View File
@@ -85,7 +85,8 @@ fn read_drawing(path: &str) -> Result<Drawing> {
fn read_file(_path: &str) -> Result<Vec<u8>> {
#[cfg(not(target_arch = "wasm32"))]
{
std::fs::read(_path).map_err(|e| bad("insert", format!("{_path}: {e}")))
crate::capped_read::read_capped(std::path::Path::new(_path), crate::cmd::file::MAX_DRAWING_BYTES)
.map_err(|e| bad("insert", format!("{_path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
Err(bad("insert", "paths are not available on the web"))
+54 -8
View File
@@ -453,14 +453,32 @@ impl Chain {
}
/// Point and unit tangent at arc length `d` from the start (clamped).
pub fn at_length(&self, d: f64) -> Option<(Vec2, Vec2)> {
let mut acc = 0.0;
for s in &self.segs {
let l = s.len();
if d <= acc + l + 1e-12 && l > 0.0 {
let t = ((d - acc) / l).clamp(0.0, 1.0);
return Some((s.at(t), s.tangent(t)));
}
acc += l;
self.at_length_in(&self.end_lengths(), d)
}
/// The arc length from the chain's start to the end of each segment, for [`Self::at_length_in`].
pub fn end_lengths(&self) -> Vec<f64> {
self.segs
.iter()
.scan(0.0, |acc, s| {
*acc += s.len();
Some(*acc)
})
.collect()
}
/// [`Self::at_length`] with the segment end lengths computed once (`ends` from
/// [`Self::end_lengths`]), so many samples along one chain each cost a binary search.
pub fn at_length_in(&self, ends: &[f64], d: f64) -> Option<(Vec2, Vec2)> {
let first = ends.partition_point(|e| d > e + 1e-12);
// A segment of no length can't hold the point; the next one that has length does.
// `ends` never decreases, so the first segment past `first` with length is found by search
// (a run of zero-length segments can be long) instead of a scan.
let base = first.checked_sub(1).and_then(|p| ends.get(p)).copied().unwrap_or(0.0);
let i = first + ends.get(first..).unwrap_or_default().partition_point(|e| *e <= base);
let i = (i < self.segs.len()).then_some(i);
if let Some(s) = i.and_then(|i| self.segs.get(i)) {
let start = i.and_then(|i| i.checked_sub(1)).and_then(|p| ends.get(p)).copied().unwrap_or(0.0);
let t = ((d - start) / s.len()).clamp(0.0, 1.0);
return Some((s.at(t), s.tangent(t)));
}
self.segs.last().map(|s| (s.end(), s.tangent(1.0)))
}
@@ -640,6 +658,34 @@ pub fn reverse_vertices(vs: &[PolyVertex], closed: bool) -> Vec<PolyVertex> {
mod tests {
use super::*;
/// The indexed lookup gives the point and tangent a scan from the start gives, also past the
/// ends and over a segment of no length.
#[test]
fn at_length_in_matches_a_scan() {
let l = |a: (f64, f64), b: (f64, f64)| Segment::Line(Line::new(Vec2::new(a.0, a.1), Vec2::new(b.0, b.1)));
let c = Chain {
segs: vec![l((0.0, 0.0), (0.0, 0.0)), l((0.0, 0.0), (4.0, 0.0)), l((4.0, 0.0), (4.0, 0.0)), l((4.0, 0.0), (4.0, 3.0))],
closed: false,
};
let ends = c.end_lengths();
for d in [-1.0, 0.0, 1.5, 4.0, 4.0 + 1e-13, 5.0, 7.0, 9.0] {
// The scan this replaced.
let mut acc = 0.0;
let mut want = c.segs.last().map(|s| (s.end(), s.tangent(1.0)));
for s in &c.segs {
let len = s.len();
if d <= acc + len + 1e-12 && len > 0.0 {
let t = ((d - acc) / len).clamp(0.0, 1.0);
want = Some((s.at(t), s.tangent(t)));
break;
}
acc += len;
}
assert_eq!(c.at_length_in(&ends, d), want, "d = {d}");
assert_eq!(c.at_length(d), want);
}
}
#[test]
fn arc_builders() {
let a = arc_sca(Vec2::new(1.0, 0.0), Vec2::ZERO, std::f64::consts::FRAC_PI_2).unwrap();
+4 -2
View File
@@ -36,16 +36,18 @@ fn check_block(s: &Session, b: Option<&str>) -> Result<()> {
pub(crate) fn divide_points(c: &Chain, n: usize) -> Vec<(Vec2, Vec2)> {
let l = c.len();
let range = if c.closed { 0..n } else { 1..n };
range.filter_map(|i| c.at_length(l * i as f64 / n as f64)).collect()
let ends = c.end_lengths();
range.filter_map(|i| c.at_length_in(&ends, l * i as f64 / n as f64)).collect()
}
pub(crate) fn measure_points(c: &Chain, step: f64) -> Vec<(Vec2, Vec2)> {
let l = c.len();
let ends = c.end_lengths();
let mut out = Vec::new();
let mut k = 1usize;
while (k as f64) * step < l - 1e-9 && out.len() < MAX_GEN {
let d = k as f64 * step;
out.extend(c.at_length(d));
out.extend(c.at_length_in(&ends, d));
k += 1;
}
out
+2 -1
View File
@@ -58,8 +58,9 @@ pub(crate) fn cloud_vertices(boundary: &[Vec2], arc_len: f64, per_edge: bool, re
closed: true,
};
let k = ((perim / arc_len).round() as usize).clamp(3, MAX_GEN);
let ends = chain.end_lengths();
for j in 0..k {
out.extend(chain.at_length(perim * j as f64 / k as f64).map(|x| x.0));
out.extend(chain.at_length_in(&ends, perim * j as f64 / k as f64).map(|x| x.0));
}
}
let b = if reverse { -0.5 } else { 0.5 };
+2 -1
View File
@@ -204,7 +204,8 @@ fn import_drawing(s: &mut Session, src: &Drawing, at: Vec2, scale: f64, file: &s
fn read_file(_path: &str) -> Result<Vec<u8>> {
#[cfg(not(target_arch = "wasm32"))]
{
std::fs::read(_path).map_err(|e| bad("import", format!("{_path}: {e}")))
crate::capped_read::read_capped(std::path::Path::new(_path), crate::cmd::file::MAX_DRAWING_BYTES)
.map_err(|e| bad("import", format!("{_path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
Err(bad("import", "paths are not available on the web; pass `data`"))
+1 -1
View File
@@ -24,7 +24,7 @@ pub use template::{qnew_template, set_qnew_template};
/// 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;
pub(crate) 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;
+1 -1
View File
@@ -110,7 +110,7 @@ fn other_drawing(s: &Session, p: &Value) -> Result<(Arc<Drawing>, String)> {
fn read_path(_path: &str) -> Result<Vec<u8>> {
#[cfg(not(target_arch = "wasm32"))]
{
std::fs::read(_path).map_err(|e| bad("compare", format!("{_path}: {e}")))
crate::capped_read::read_capped(std::path::Path::new(_path), super::MAX_DRAWING_BYTES).map_err(|e| bad("compare", format!("{_path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
Err(bad("compare", "paths are not available on the web: open the other drawing and compare with it by name (`document`)"))
+1 -1
View File
@@ -74,7 +74,7 @@ fn read_template(path: &str) -> Result<Drawing> {
#[cfg(not(target_arch = "wasm32"))]
fn read_bytes(path: &str) -> Result<Vec<u8>> {
std::fs::read(path).map_err(|e| bad("new", format!("template {path}: {e}")))
crate::capped_read::read_capped(std::path::Path::new(path), super::MAX_DRAWING_BYTES).map_err(|e| bad("new", format!("template {path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
+3 -5
View File
@@ -303,11 +303,9 @@ fn run_stylesmanager(s: &mut Session, p: &Value) -> Result<Value> {
(None, Some(_path)) => {
#[cfg(not(target_arch = "wasm32"))]
{
let meta = std::fs::metadata(_path).map_err(|e| bad(cmd, format!("{_path}: {e}")))?;
if meta.len() > cadcraft_doc::MAX_TABLE_FILE as u64 {
return Err(bad(cmd, format!("{_path}: the plot style table file is too large")));
}
std::fs::read_to_string(_path).map_err(|e| bad(cmd, format!("{_path}: {e}")))?
let bytes = crate::capped_read::read_capped(std::path::Path::new(_path), cadcraft_doc::MAX_TABLE_FILE)
.map_err(|e| bad(cmd, format!("{_path}: {e}")))?;
String::from_utf8(bytes).map_err(|e| bad(cmd, format!("{_path}: {e}")))?
}
#[cfg(target_arch = "wasm32")]
return Err(bad(cmd, "files can't be read here: pass `text`"));
+4 -2
View File
@@ -34,10 +34,12 @@ pub(crate) fn arraypath(
_ => (6, if c.closed { l / 6.0 } else { l / 5.0 }),
};
crate::cmd::array::check_size(s, n as u64, objs.len())?;
let Some((p0, t0)) = c.at_length(0.0) else { return Ok(Vec::new()) };
// Segment end lengths once: each sample is then a binary search, not a scan from the start.
let ends = c.end_lengths();
let Some((p0, t0)) = c.at_length_in(&ends, 0.0) else { return Ok(Vec::new()) };
let mut mats = Vec::new();
for i in 1..n {
let Some((p, t)) = c.at_length(step * i as f64) else { continue };
let Some((p, t)) = c.at_length_in(&ends, step * i as f64) else { continue };
let mut m = Mat3::translate(p - p0);
if align {
m = Mat3::rotate_about(p, t.angle() - t0.angle()).then_before(m);
+31 -2
View File
@@ -175,8 +175,14 @@ pub fn compare(prop: &Value, op: Op, value: &Value) -> bool {
if ptxt.eq_ignore_ascii_case(vtxt.trim()) {
return true;
}
// Colours: `red` = `1` = `Red`; `bylayer` = `ByLayer`.
match (Color::parse(&ptxt), Color::parse(vtxt.trim())) {
// Colours: `red` = `1` = `Red`; `bylayer` = `ByLayer`. No colour spelling is long, so a
// long text (a layer name from a file) isn't parsed, copied and split per entity.
const MAX_COLOR_TEXT: usize = 64;
let (pc, vc) = (ptxt.trim(), vtxt.trim());
if pc.len() > MAX_COLOR_TEXT || vc.len() > MAX_COLOR_TEXT {
return false;
}
match (Color::parse(pc), Color::parse(vc)) {
(Some(a), Some(b)) => a == b,
_ => false,
}
@@ -400,3 +406,26 @@ mod tests {
assert!(!wildcard(&"*a".repeat(500), &"b".repeat(4000)));
}
}
#[cfg(test)]
mod long_text_tests {
use super::*;
/// A long string property is compared as text only, and a colour name still equals its index.
#[test]
fn long_values_skip_colour_parsing() {
let long = Value::String("x".repeat(100_000));
assert!(compare(&long, Op::Eq, &long));
assert!(!compare(&long, Op::Eq, &Value::String("red".into())));
assert!(compare(&Value::String("red".into()), Op::Eq, &Value::String("1".into())));
}
/// Padding is trimmed before the length limit, so a padded colour still matches.
#[test]
fn padded_colours_still_match() {
let padded = |c: &str| Value::String(format!("{}{c}{}", " ".repeat(100), " ".repeat(100)));
assert!(compare(&padded("red"), Op::Eq, &Value::String("1".into())));
assert!(compare(&Value::String("red".into()), Op::Eq, &padded("1")));
assert!(!compare(&padded("red"), Op::Ne, &padded("1")));
}
}
+14 -6
View File
@@ -12,7 +12,7 @@ use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
/// Largest linked file read (bytes).
#[cfg(not(target_arch = "wasm32"))]
const MAX_LINK_BYTES: u64 = 8 << 20;
const MAX_LINK_BYTES: usize = 8 << 20;
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -238,11 +238,9 @@ fn read_link(id: &str, file: &std::path::Path) -> Result<String> {
let path = file.display();
#[cfg(not(target_arch = "wasm32"))]
{
let meta = std::fs::metadata(file).map_err(|e| bad(id, format!("{path}: {e}")))?;
if meta.len() > MAX_LINK_BYTES {
return Err(bad(id, format!("{path}: larger than {} MB", MAX_LINK_BYTES >> 20)));
}
let bytes = std::fs::read(file).map_err(|e| bad(id, format!("{path}: {e}")))?;
// The limit is enforced on the bytes read, not on reported metadata, so a device or FIFO
// that misreports its length can't be buffered to EOF.
let bytes = crate::capped_read::read_capped(file, MAX_LINK_BYTES).map_err(|e| bad(id, format!("{path}: {e}")))?;
let text = String::from_utf8_lossy(&bytes);
Ok(text.strip_prefix('\u{feff}').unwrap_or(&text).to_owned())
}
@@ -370,6 +368,16 @@ fn run_update(s: &mut Session, p: &Value) -> Result<Value> {
mod tests {
use super::*;
/// A linked path naming an endless device is refused instead of buffered to EOF.
#[cfg(all(unix, not(target_arch = "wasm32")))]
#[test]
fn link_to_endless_device_is_rejected() {
let dev = std::path::Path::new("/dev/zero");
if dev.exists() {
assert!(read_link("datalink", dev).is_err());
}
}
#[test]
fn csv_parsing() {
assert_eq!(parse_csv("a,b\r\n\"c,d\",\"e\"\"f\"\n", ','), vec![vec!["a", "b"], vec!["c,d", "e\"f"]]);
+7 -3
View File
@@ -415,9 +415,12 @@ impl Session {
/// Move printed text to the command history: complete lines, and with `all` the rest too.
fn lisp_flush(&mut self, env: &mut Env, all: bool) {
while let Some(i) = env.out.find('\n') {
let line: String = env.out.drain(..=i).collect();
let line = line.trim_end_matches(['\n', '\r']).to_string();
// One pass over the buffer, dropping the printed lines once at the end: draining each line
// from the front shifted the rest every time, quadratic in newline-dense output.
let mut done = 0;
while let Some(i) = env.out.get(done..).and_then(|rest| rest.find('\n')) {
let line = env.out.get(done..done + i).unwrap_or_default().trim_end_matches('\r').to_string();
done += i + 1;
// `(princ "\nText")` after a prompt or a command's echo ends that line instead of
// leaving an empty one; after LISP's own line it does print an empty line.
if line.is_empty() && env.printed_at != Some(self.log.len()) {
@@ -427,6 +430,7 @@ impl Session {
self.echo(line);
env.printed_at = Some(self.log.len());
}
env.out.drain(..done);
if all && !env.out.is_empty() {
let line = std::mem::take(&mut env.out);
self.echo(line);
+10
View File
@@ -307,6 +307,16 @@ fn printing() {
assert_eq!(ok(&mut s, "(rtos 12.5 2 2)"), "\"12.50\"");
}
/// Output with many lines moves to the history in one pass, not one buffer shift per line.
#[test]
fn printing_many_lines_is_linear() {
let mut s = session();
let started = std::time::Instant::now();
ok(&mut s, "(setq x \"a\\r\\n\") (repeat 17 (setq x (strcat x x))) (princ x) (princ)");
assert!(started.elapsed() < std::time::Duration::from_secs(10), "took {:?}", started.elapsed());
assert_eq!(s.log.last().map(String::as_str), Some("a"));
}
#[test]
fn command_draws_and_one_routine_is_one_undo_step() {
let mut s = session();
+18 -1
View File
@@ -484,6 +484,9 @@ pub struct Polyline {
pub closed: bool,
}
/// Sampled points one [`Polyline::tessellate`] call produces before arcs become chords.
pub const MAX_TESSELLATED_POINTS: usize = 4_000_000;
impl Polyline {
pub fn from_points(pts: &[Vec2], closed: bool) -> Self {
Polyline { vertices: pts.iter().map(|p| PolyVertex::new(*p)).collect(), closed }
@@ -516,10 +519,24 @@ impl Polyline {
b
}
pub fn tessellate(&self, tol: f64) -> Vec<Vec2> {
let mut budget = MAX_TESSELLATED_POINTS;
self.tessellate_within(tol, &mut budget)
}
/// [`Polyline::tessellate`] spending about `budget` sampled points (shared by the caller
/// across many polylines). The budget is checked before each segment, so the last segment
/// started may overshoot it by that one segment's samples. Once it is spent, the remaining arcs are drawn as chords, so the
/// output stays proportional to the input vertices. Each arc can sample thousands of points,
/// so a compact polyline could otherwise expand into gigabytes (CWE-400).
pub fn tessellate_within(&self, tol: f64, budget: &mut usize) -> Vec<Vec2> {
let mut out: Vec<Vec2> = Vec::new();
for s in self.segments() {
let mut pts = Vec::new();
s.tessellate(tol, &mut pts);
if *budget > 0 {
s.tessellate(tol, &mut pts);
*budget = budget.saturating_sub(pts.len());
} else {
pts.extend([s.start(), s.end()]);
}
let skip = usize::from(out.last().is_some_and(|l| pts.first().is_some_and(|f| l.near(*f, 1e-9))));
out.extend(pts.into_iter().skip(skip));
}
+2 -1
View File
@@ -20,7 +20,8 @@ mod vec;
pub use bounds::Bounds2;
pub use curve::{
Arc, Circle, Ellipse, Line, PolyVertex, Polyline, Segment, arc_to_bulge, bulge_to_arc, point_in_polygon, polygon_centroid, shoelace,
Arc, Circle, Ellipse, Line, MAX_TESSELLATED_POINTS, PolyVertex, Polyline, Segment, arc_to_bulge, bulge_to_arc, point_in_polygon,
polygon_centroid, shoelace,
};
pub use fillet::{CornerFit, TanCurve, tangent_circles};
pub use intersect::{circle_circle, intersect_ext, intersect_segments, line_circle, line_line, line_line_infinite};
+12
View File
@@ -221,6 +221,18 @@ fn segment_offset() {
assert!(s.offset(6.0).is_none());
}
/// A polyline of many fine arcs samples at most the point budget, then draws chords.
#[test]
fn polyline_tessellation_is_bounded() {
let vertices: Vec<PolyVertex> = (0..3000).map(|i| PolyVertex { bulge: 1.0, ..PolyVertex::new(Vec2::new(f64::from(i) * 1e6, 0.0)) }).collect();
let pl = Polyline { vertices, closed: false };
let pts = pl.tessellate(1e-9);
assert!(pts.len() <= MAX_TESSELLATED_POINTS + 2 * 3000 + 4096, "{}", pts.len());
assert!(pts.len() > 3000);
let mut budget = 10;
assert!(pl.tessellate_within(1e-9, &mut budget).len() <= 4096 + 2 * 3000);
}
#[test]
fn arc_segments_bounded() {
assert_eq!(arc_segments(0.0, 1.0, 0.1), 1);
+4 -1
View File
@@ -128,7 +128,10 @@ const MAX_DEDUP_COMPARISONS: u64 = 50_000_000;
/// within islands alternate), 1 outer keeps the outermost loops and their first islands, 2
/// ignore keeps only the outermost loops. Nesting depth = how many other loops enclose a loop.
pub fn fill_loops(h: &Hatch, tol: f64) -> Vec<Vec<Vec2>> {
let mut loops: Vec<Vec<Vec2>> = h.loops.iter().map(|l| Polyline { vertices: l.vertices.clone(), closed: true }.tessellate(tol)).collect();
// One point budget for all the loops: many short arcs across loops must not add up unbounded.
let mut budget = cadcraft_geom::MAX_TESSELLATED_POINTS;
let mut loops: Vec<Vec<Vec2>> =
h.loops.iter().map(|l| Polyline { vertices: l.vertices.clone(), closed: true }.tessellate_within(tol, &mut budget)).collect();
dedup_polygons(&mut loops);
let max_depth = match h.style {
1 => 1,
+45 -13
View File
@@ -133,6 +133,20 @@ pub fn table_geometry(d: &Drawing, t: &Table) -> TableGeom {
if parts.len() <= 1 {
return layout(d, t);
}
// Every part is laid out and kept, so the cells of all of them count together (a header repeated
// on each part adds to each); past the limit only the table's outline is drawn.
let cols = t.col_widths.len().min(10_000);
let total: usize = parts.iter().map(|p| p.rows.len().min(10_000).saturating_mul(cols)).fold(0, usize::saturating_add);
if total > MAX_LAYOUT_CELLS {
// One outline per part, at the part's origin and as tall as its displayed rows.
let w = edges(&t.col_widths, cols).last().copied().unwrap_or(0.0);
let mut g = TableGeom::default();
for p in parts {
let h: f64 = p.rows.iter().take(10_000).map(|r| t.row_heights.get(*r).copied().filter(|v| v.is_finite()).unwrap_or(0.0)).sum();
outline_box(&mut g, p.origin, w, h);
}
return g;
}
let mut g = TableGeom::default();
for p in parts {
let mut pg = layout(d, &part_table(t, &p.rows, p.origin));
@@ -156,16 +170,20 @@ fn part_table(t: &Table, rows: &[usize], origin: Vec2) -> Table {
// 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 {
// `run[i]`: how many rows from part row `i` follow one another in the table, found once so a
// merge is cut by a lookup, not by walking the rows it spans.
let mut run = vec![1u32; rows.len()];
for i in (0..rows.len().saturating_sub(1)).rev() {
if rows.get(i + 1).zip(rows.get(i)).is_some_and(|(next, this)| *next == this + 1) {
run[i] = run.get(i + 1).copied().unwrap_or(0).saturating_add(1);
}
}
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 n = rs.max(1).min(run.get(i).copied().unwrap_or(1));
cell.merged = (n > 1 || cs > 1).then_some((n, cs));
}
}
@@ -186,6 +204,27 @@ fn part_table(t: &Table, rows: &[usize], origin: Vec2) -> Table {
}
}
/// The table's outline alone, for a table too large to lay out.
fn outline(t: &Table) -> TableGeom {
let rows = t.row_heights.len().min(10_000);
let cols = t.col_widths.len().min(10_000);
let (w, h) = (edges(&t.col_widths, cols).last().copied().unwrap_or(0.0), edges(&t.row_heights, rows).last().copied().unwrap_or(0.0));
let mut g = TableGeom::default();
outline_box(&mut g, t.insert.xy(), w, h);
g
}
/// The four sides of a `w` by `h` box hanging down from `o`.
fn outline_box(g: &mut TableGeom, o: Vec2, w: f64, h: f64) {
let p = |x: f64, y: f64| o + Vec2::new(x, -y);
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) {
if let [a, b] = s {
g.lines.push(TableLine { a: *a, b: *b, color: Color::ByBlock, lineweight: Lineweight::ByBlock });
}
}
}
/// Lay out one part.
fn layout(d: &Drawing, t: &Table) -> TableGeom {
let st = table_style_of(d, t);
@@ -203,14 +242,7 @@ fn layout(d: &Drawing, t: &Table) -> TableGeom {
}
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) {
if let [a, b] = s {
g.lines.push(TableLine { a: *a, b: *b, color: Color::ByBlock, lineweight: Lineweight::ByBlock });
}
}
return g;
return outline(t);
}
let k = style_scale(&st, t);
// Cell styles by name, scaled to the table.
+22
View File
@@ -1165,3 +1165,25 @@ fn without_linetypes_every_line_is_drawn_whole() {
let drawn = build(&drawing("DASHED"), &Space::Model, &Options::default());
assert!(drawn.prims.len() > 2 * continuous.prims.len(), "drawn with its linetype: {} primitives", drawn.prims.len());
}
/// A broken table whose parts, with their repeated labels, together hold more cells than a table may
/// lay out is drawn as its outline, though the table's own cells are within the limit.
#[test]
fn broken_table_cells_count_across_its_parts() {
let d = Drawing::new_imperial();
let t = cadcraft_doc::Table {
col_widths: vec![1.0; 600],
row_heights: vec![1.0; 1000],
title: true,
header: true,
breaks: Some(cadcraft_doc::TableBreak { enabled: true, height: 3.0, ..Default::default() }),
..Default::default()
};
assert!(t.parts().len() > 500 && 1000 * 600 <= crate::MAX_LAYOUT_CELLS);
let g = crate::table_geometry(&d, &t);
let parts = t.parts();
assert_eq!((g.lines.len(), g.texts.len(), g.fills.len()), (4 * parts.len(), 0, 0));
// Each part's box starts at its own origin.
let top = |i: usize| g.lines[4 * i].a;
assert!((0..parts.len()).all(|i| top(i) == parts[i].origin));
}
+7 -1
View File
@@ -77,10 +77,16 @@ fn rows(app: &CadApp, ui: &egui::Ui) -> Vec<Row> {
let xrefs: Vec<_> = d.blocks.values().filter(|b| b.xref_path.is_some() && !b.name.contains('|')).collect();
let mut counts = vec![0usize; xrefs.len()];
if !xrefs.is_empty() {
// Xref name (ASCII-upper-cased) → its row, the first of names that differ only in case, so
// each INSERT is one lookup instead of a scan of the xref list.
let mut index: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for (k, b) in xrefs.iter().enumerate() {
index.entry(b.name.to_ascii_uppercase()).or_insert(k);
}
let mut count = |store: &cadcraft_engine::doc::EntityStore| {
for e in store.iter() {
if let cadcraft_engine::doc::EntityKind::Insert(i) = &e.kind
&& let Some(k) = xrefs.iter().position(|b| b.name.eq_ignore_ascii_case(&i.block))
&& let Some(&k) = index.get(&i.block.to_ascii_uppercase())
&& let Some(c) = counts.get_mut(k)
{
*c += 1;