Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 006a087c09 address review: trim colour text before length limit 2026-11-03 22:21:08 +00:00
Dylan M. Taylor c0f59f9c52 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.
2026-11-03 17:00:43 +00:00
9 changed files with 82 additions and 104 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 -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")));
}
}
+1 -15
View File
@@ -844,29 +844,15 @@ pub(crate) fn apply_anno(d: &mut cadcraft_doc::Drawing, p: AnnoPayload) {
std::sync::Arc::make_mut(b).annotative = true;
}
}
// Which layout holds each paper space object, found once: `modify_entity` searches every
// layout for a handle model space lacks, which a long list of missing handles made quadratic.
let in_layout: std::collections::HashMap<Handle, usize> = if p.objects.is_empty() {
Default::default()
} else {
d.layouts.iter().enumerate().flat_map(|(i, l)| l.entities.handles().into_iter().map(move |h| (h, i))).collect()
};
for o in p.objects {
let Some(h) = Handle::parse_hex(&o.handle) else { continue };
let store = if d.model.contains(h) {
&mut d.model
} else if let Some(l) = in_layout.get(&h).and_then(|&i| d.layouts.get_mut(i)) {
&mut l.entities
} else {
continue;
};
let mut scales: Vec<cadcraft_doc::AnnoScale> = Vec::new();
for s in o.scales.into_iter().filter(|s| s.is_valid() && !s.name.trim().is_empty()) {
if scales.len() < cadcraft_doc::MAX_SCALES && !scales.iter().any(|x| x.name.eq_ignore_ascii_case(&s.name)) {
scales.push(s);
}
}
store.modify(h, |e| {
let _ = d.modify_entity(h, |e| {
if cadcraft_doc::can_be_annotative(&e.kind) {
e.common.anno_scales = scales;
}
+2 -14
View File
@@ -339,8 +339,7 @@ pub(crate) fn read(secs: &[Section], d: &mut Drawing) {
}
let root = objects.first().filter(|(k, _, _)| is_dictionary(k)).and_then(|(_, _, t)| head(t).handle);
let root_entries = objects.first().filter(|(k, _, _)| is_dictionary(k)).map(|(_, _, t)| entries(t)).unwrap_or_default();
let root_keys = root_key_index(&root_entries);
let root_key = |h: &str| root_keys.get(h).map(|k| (*k).to_string());
let root_key = |h: &str| root_entries.iter().find(|(_, eh)| eh == h).map(|(k, _)| k.clone());
// An extension dictionary of an entity or record (not of another object).
let is_xdict = |k: usize| {
let Some((_, _, t)) = objects.get(k) else { return false };
@@ -483,16 +482,6 @@ fn extension_dictionaries(
dictionaries
}
/// The root dictionary's key for each entry handle (the first key when several name one handle),
/// built once: a lookup per object scanning the entries made a large dictionary quadratic.
fn root_key_index(root_entries: &[(String, String)]) -> HashMap<&str, &str> {
let mut index = HashMap::with_capacity(root_entries.len());
for (key, handle) in root_entries {
index.entry(handle.as_str()).or_insert(key.as_str());
}
index
}
/// Determine which objects belong to unmodelled dictionary entries or owners.
fn retained_objects(
objects: &[(String, usize, &[Tag])],
@@ -503,8 +492,7 @@ fn retained_objects(
root_entries: &[(String, String)],
xdict_of: &HashMap<String, String>,
) -> HashMap<usize, bool> {
let root_keys = root_key_index(root_entries);
let root_key = |handle: &str| root_keys.get(handle).copied();
let root_key = |handle: &str| root_entries.iter().find(|(_, entry_handle)| entry_handle == handle).map(|(key, _)| key);
let is_xdict = |index: usize| {
let Some((_, _, tags)) = objects.get(index) else { return false };
let header = head(tags);
+15 -54
View File
@@ -1170,37 +1170,26 @@ fn add_class_instance_counts(tags: &mut Vec<Tag>, target: Version) {
}
}
// One pass into a new list: inserting each missing count in place shifted every later tag,
// quadratic in the number of CLASS records.
let old = std::mem::take(tags);
tags.reserve(old.len());
let mut start = 0;
while let Some(t) = old.get(start) {
if t.code != 0 || t.str() != "CLASS" {
tags.push(t.clone());
while start < tags.len() {
if tags.get(start).is_none_or(|t| t.code != 0 || t.str() != "CLASS") {
start += 1;
continue;
}
let end = old[start + 1..].iter().position(|t| t.code == 0).map_or(old.len(), |i| start + 1 + i);
let record = &old[start..end];
let name = record.iter().skip(1).find(|t| t.code == 1).map(Tag::str).unwrap_or_default().to_ascii_uppercase();
let end = tags[start + 1..].iter().position(|t| t.code == 0).map_or(tags.len(), |i| start + 1 + i);
let name = tags[start + 1..end].iter().find(|t| t.code == 1).map(Tag::str).unwrap_or_default().to_ascii_uppercase();
let count = counts.get(&name).copied().unwrap_or(0);
// The first 91 gets the count; without one, a 91 goes in after the first 90.
let mut added = record.iter().skip(1).any(|t| t.code == 91);
let mut replaced = false;
for (i, t) in record.iter().enumerate() {
if i > 0 && t.code == 91 && !replaced {
tags.push(Tag::i(91, count));
replaced = true;
continue;
}
tags.push(t.clone());
if i > 0 && t.code == 90 && !added {
tags.push(Tag::i(91, count));
added = true;
}
}
start = end;
let inserted = if let Some(i) = tags[start + 1..end].iter().position(|t| t.code == 91) {
tags[start + 1 + i] = Tag::i(91, count);
false
} else if let Some(i) = tags[start + 1..end].iter().position(|t| t.code == 90) {
tags.insert(start + 2 + i, Tag::i(91, count));
true
} else {
false
};
// Inserting the group shifts the next record's start by one.
start = end + usize::from(inserted);
}
}
@@ -1228,31 +1217,3 @@ fn mark_xrefs(tags: &mut [Tag], d: &Drawing) {
}
}
}
#[cfg(test)]
mod class_count_tests {
use super::*;
fn record(kind: &str, name: &str, extra: &[Tag]) -> Vec<Tag> {
let mut r = vec![Tag::s(0, kind), Tag::s(1, name), Tag::i(90, 0)];
r.extend_from_slice(extra);
r
}
/// The first 91 gets the count, a CLASS without one gets it after its 90, in one pass over many records.
#[test]
fn counts_are_set_and_added_in_one_pass() {
let mut tags = record("CLASS", "FOO", &[]);
tags.extend(record("CLASS", "BAR", &[Tag::i(91, 7), Tag::i(91, 8)]));
for i in 0..50_000 {
tags.extend(record("CLASS", &format!("X{i}"), &[]));
}
tags.push(Tag::s(0, "FOO"));
let started = std::time::Instant::now();
add_class_instance_counts(&mut tags, Version::R2018);
assert!(started.elapsed() < std::time::Duration::from_secs(5));
assert_eq!(&tags[..4], &[Tag::s(0, "CLASS"), Tag::s(1, "FOO"), Tag::i(90, 0), Tag::i(91, 1)]);
assert_eq!(&tags[4..9], &[Tag::s(0, "CLASS"), Tag::s(1, "BAR"), Tag::i(90, 0), Tag::i(91, 0), Tag::i(91, 8)]);
assert_eq!(tags.iter().filter(|t| t.code == 91).count(), 50_003);
}
}
+1 -4
View File
@@ -590,10 +590,7 @@ fn hidden(e: &BytesStart) -> bool {
}
fn attr(e: &BytesStart, name: &[u8]) -> Option<String> {
// Stop at the first malformed attribute: skipping errors resumed the parser's duplicate check,
// which rescans the attributes before it each time, so many duplicates were quadratic.
for a in e.attributes() {
let Ok(a) = a else { return None };
for a in e.attributes().flatten() {
if a.key.into_inner().as_bytes() == name {
return a.normalized_value(XmlVersion::Explicit1_0).ok().map(|v| v.into_owned());
}
-10
View File
@@ -337,16 +337,6 @@ fn entity_transparency_roundtrips_as_group_440() {
assert_eq!(d.model.iter().map(|e| e.common.transparency).collect::<Vec<_>>(), [Transparency::ByLayer, Transparency::Percent(90)]);
}
/// Elements with many duplicate attributes are read in bounded time.
#[test]
fn svg_duplicate_attributes_are_not_rescanned() {
let dup = " x=\"1\"".repeat(20_000);
let svg = format!("<svg xmlns=\"http://www.w3.org/2000/svg\">{}</svg>", format!("<rect{dup} width=\"1\" height=\"1\"/>").repeat(20));
let started = std::time::Instant::now();
let _ = read(svg.as_bytes(), "dup.svg");
assert!(started.elapsed() < std::time::Duration::from_secs(10), "took {:?}", started.elapsed());
}
#[test]
fn hostile_dxf_does_not_panic() {
for t in [
+7 -1
View File
@@ -78,10 +78,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;