From bc9ad6b059271b1c86ee2fa9bfbf2aacf6a0d6bf Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 10 Nov 2026 21:29:35 -0500 Subject: [PATCH] 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 --- apps/cadcraft/src/logging.rs | 25 +++++++++++++++++++++--- crates/constraints/src/dof.rs | 4 +++- crates/engine/src/cmd/qselect.rs | 33 ++++++++++++++++++++++++++++++-- crates/ui-egui/src/xrefs.rs | 8 +++++++- 4 files changed, 63 insertions(+), 7 deletions(-) diff --git a/apps/cadcraft/src/logging.rs b/apps/cadcraft/src/logging.rs index 724d09c1a..e4e86c982 100644 --- a/apps/cadcraft/src/logging.rs +++ b/apps/cadcraft/src/logging.rs @@ -148,7 +148,20 @@ pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> { /// One formatted log line: ` [] : \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] diff --git a/crates/constraints/src/dof.rs b/crates/constraints/src/dof.rs index e6128665c..7ac58e112 100644 --- a/crates/constraints/src/dof.rs +++ b/crates/constraints/src/dof.rs @@ -138,7 +138,9 @@ pub fn dof(d: &Drawing, handles: &[Handle]) -> Result { 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 = 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); diff --git a/crates/engine/src/cmd/qselect.rs b/crates/engine/src/cmd/qselect.rs index 2c5330371..469a0895f 100644 --- a/crates/engine/src/cmd/qselect.rs +++ b/crates/engine/src/cmd/qselect.rs @@ -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"))); + } +} diff --git a/crates/ui-egui/src/xrefs.rs b/crates/ui-egui/src/xrefs.rs index ee5aa385b..ed1f43d79 100644 --- a/crates/ui-egui/src/xrefs.rs +++ b/crates/ui-egui/src/xrefs.rs @@ -77,10 +77,16 @@ fn rows(app: &CadApp, ui: &egui::Ui) -> Vec { 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 = 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;