From 65ff2f42f40d1266cf01087fa19faab7cd4e8ade Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 18:27:03 +0000 Subject: [PATCH] fix: bound or confine seven name, path and file inputs (QSELECT wildcard, layout template name, LISP OPEN/CLOSE, font manifest, xref palette, test walkers) --- crates/engine/src/cmd/blocks/xref.rs | 15 ++++++++- crates/engine/src/cmd/layout/saveas.rs | 22 ++++++++++++- crates/engine/src/cmd/qselect.rs | 5 +-- crates/engine/src/lisp/builtins/files.rs | 42 ++++++++++++++++++++++-- crates/engine/tests/xref.rs | 5 +++ crates/fonts/build.rs | 27 +++++++++++++-- crates/ui-egui/src/i18n/tests.rs | 12 ++++--- crates/ui-egui/src/xrefs.rs | 8 ++--- xtask/src/stats.rs | 18 +++++++--- 9 files changed, 131 insertions(+), 23 deletions(-) diff --git a/crates/engine/src/cmd/blocks/xref.rs b/crates/engine/src/cmd/blocks/xref.rs index 3b91809e5..35d595908 100644 --- a/crates/engine/src/cmd/blocks/xref.rs +++ b/crates/engine/src/cmd/blocks/xref.rs @@ -34,7 +34,7 @@ pub(super) fn specs() -> Vec { .noundo(), CommandSpec::new("-xref", "External References", run_dash_xref) .alias(&["-xr"]) - .params("{option: \"?\"|bind|detach|path|unload|reload|overlay|attach|type, names?: \"A,B*\" (default *), path? (path, attach, overlay), insert?: bool (bind: Insert type), overlay?: bool (type), at?, scale?, rotation? (attach)}") + .params("{option: \"?\"|bind|detach|path|unload|reload|overlay|attach|type, names?: \"A,B*\" (default *), exact?: bool (names are literal xref names, no wildcards or commas), path? (path, attach, overlay), insert?: bool (bind: Insert type), overlay?: bool (type), at?, scale?, rotation? (attach)}") .interactive(|_| Ok(Box::new(XrefM::new("-XREF", XStage::Option)))), CommandSpec::new("xbind", "Bind...", run_xbind) .menu(&["Modify", "Object", "External Reference", "Bind..."]) @@ -201,6 +201,19 @@ fn run_xattach(s: &mut Session, p: &Value) -> Result { /// The xrefs a JSON call names (`names`, default all); an error when none match. fn named(s: &Session, cmd: &str, p: &Value) -> Result> { + if p.get("exact").and_then(Value::as_bool) == Some(true) { + let lits: Vec<&str> = match p.get("names") { + Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).collect(), + Some(Value::String(t)) => vec![t.as_str()], + _ => Vec::new(), + }; + let d = s.doc()?; + let names: Vec = xrefs(d).filter(|b| lits.iter().any(|l| l.eq_ignore_ascii_case(&b.name))).map(|b| b.name.clone()).collect(); + if names.is_empty() { + return Err(bad(cmd, format!("no xref named \"{}\"", lits.join(",")))); + } + return Ok(names); + } let pattern = match p.get("names") { Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).collect::>().join(","), Some(Value::String(t)) => t.clone(), diff --git a/crates/engine/src/cmd/layout/saveas.rs b/crates/engine/src/cmd/layout/saveas.rs index 0a0b2b5f8..bc36c0cb6 100644 --- a/crates/engine/src/cmd/layout/saveas.rs +++ b/crates/engine/src/cmd/layout/saveas.rs @@ -26,7 +26,10 @@ const MAX_PURGE_PASSES: usize = 64; /// The file a SAveas typed at the command line suggests: `.dwt` beside the drawing. pub(super) fn default_path(s: &Session, layout: &str) -> String { - let file = format!("{layout}.dwt"); + // A layout name from a file may hold path separators or `..`; the suggestion is one file name + // beside the drawing, so those become `_`. + let name: String = layout.chars().map(|c| if matches!(c, '/' | '\\' | ':') || c.is_control() { '_' } else { c }).collect(); + let file = format!("{name}.dwt"); match s.state().ok().and_then(|st| st.path.clone()) { Some(p) => std::path::Path::new(&p).with_file_name(&file).to_string_lossy().into_owned(), None => file, @@ -133,3 +136,20 @@ fn store(path: &str, bytes: &[u8]) -> Result<()> { super::super::exchange::deliver_file("layout.saveas", path, bytes, super::super::file::deliver()) } } + +#[cfg(test)] +mod default_path_tests { + use super::*; + + /// A layout name with separators suggests a file beside the drawing, not a path elsewhere. + #[test] + fn layout_name_is_one_file_name() { + let s = Session::empty(); + for name in ["../../etc/x", "a/b", "a\\b", "C:x"] { + let p = default_path(&s, name); + assert!(!p.contains('/') && !p.contains('\\') && !p.contains(':'), "{p}"); + assert!(p.ends_with(".dwt")); + } + assert_eq!(default_path(&s, "Layout1"), "Layout1.dwt"); + } +} diff --git a/crates/engine/src/cmd/qselect.rs b/crates/engine/src/cmd/qselect.rs index 2c5330371..d8612836c 100644 --- a/crates/engine/src/cmd/qselect.rs +++ b/crates/engine/src/cmd/qselect.rs @@ -108,8 +108,9 @@ fn property(d: &Drawing, e: &Entity, name: &str) -> Option { /// Glob match, case-insensitive: `*` any run, `?` one character, `#` one digit. pub fn wildcard(pat: &str, text: &str) -> bool { - let p: Vec = pat.to_lowercase().chars().take(1024).collect(); - let t: Vec = text.to_lowercase().chars().take(4096).collect(); + // Only the characters that can match are lower-cased, so a huge text isn't copied whole. + let p: Vec = pat.chars().take(1024).flat_map(char::to_lowercase).collect(); + let t: Vec = text.chars().take(4096).flat_map(char::to_lowercase).collect(); // Iterative matcher with single back-track point (linear-ish, no recursion). let (mut pi, mut ti) = (0usize, 0usize); let mut star: Option<(usize, usize)> = None; diff --git a/crates/engine/src/lisp/builtins/files.rs b/crates/engine/src/lisp/builtins/files.rs index 44452ae89..ed63e9a41 100644 --- a/crates/engine/src/lisp/builtins/files.rs +++ b/crates/engine/src/lisp/builtins/files.rs @@ -27,7 +27,8 @@ pub(super) fn open(ctx: &mut Ctx<'_>, a: Vec) -> Result if !writing { match read_text_file(&path) { Some(t) => { - charge_bytes(t.len())?; + // The lines are separate Strings: their headers cost memory the text length hides. + charge_bytes(t.len().saturating_add(t.lines().count().saturating_mul(std::mem::size_of::())))?; f.lines = t.lines().map(str::to_string).collect(); } None => return Ok(Flow::Value(Value::Nil)), @@ -100,8 +101,19 @@ fn can_write(_path: &str) -> bool { #[cfg(not(target_arch = "wasm32"))] fn write_text_file(path: &str, text: &str, append: bool) { use std::io::Write; - let f = std::fs::OpenOptions::new().create(true).write(true).append(append).truncate(!append).open(path); - if let Ok(mut f) = f { + // Opened without blocking and without truncating, so a FIFO with no reader fails at once + // instead of stalling the interpreter; only a regular file is then truncated and written. + let mut o = std::fs::OpenOptions::new(); + o.create(true).write(true).append(append); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + o.custom_flags(libc::O_NONBLOCK); + } + if let Ok(mut f) = o.open(path) + && f.metadata().is_ok_and(|m| m.is_file()) + && (append || f.set_len(0).is_ok()) + { let _ = f.write_all(text.as_bytes()); } } @@ -118,3 +130,27 @@ fn file_exists(path: &str) -> bool { fn file_exists(_path: &str) -> bool { false } + +#[cfg(all(test, unix))] +mod tests { + use super::*; + + /// Writing to a FIFO with no reader returns at once, and a regular file is still replaced. + #[test] + fn write_to_a_fifo_without_a_reader_does_not_block() { + let dir = std::env::temp_dir().join(format!("cadcraft-lispfifo-{}", 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(); + write_text_file(fifo.to_str().unwrap(), "x", false); + assert!(started.elapsed() < std::time::Duration::from_secs(5)); + let f = dir.join("f.txt"); + std::fs::write(&f, "long old text").unwrap(); + write_text_file(f.to_str().unwrap(), "new", false); + assert_eq!(std::fs::read_to_string(&f).unwrap(), "new"); + write_text_file(f.to_str().unwrap(), "+", true); + assert_eq!(std::fs::read_to_string(&f).unwrap(), "new+"); + std::fs::remove_dir_all(&dir).ok(); + } +} diff --git a/crates/engine/tests/xref.rs b/crates/engine/tests/xref.rs index 87c645a81..238d86ecc 100644 --- a/crates/engine/tests/xref.rs +++ b/crates/engine/tests/xref.rs @@ -190,6 +190,11 @@ fn reload_unload_path_and_detach() { assert_eq!(objects(&s, "site"), 3, "reload reads the file again"); assert!(s.doc().unwrap().layer("site|Walls").unwrap().frozen); + // `exact`: a name is literal, so `#` or `*` in it doesn't reach other xrefs. + assert!(s.execute("-xref", &json!({ "option": "reload", "names": "s#te", "exact": true })).is_err()); + assert!(s.execute("-xref", &json!({ "option": "reload", "names": "*", "exact": true })).is_err()); + s.execute("-xref", &json!({ "option": "reload", "names": "SITE", "exact": true })).unwrap(); + s.execute("-xref", &json!({ "option": "unload", "names": "site" })).unwrap(); assert_eq!((status(&s, "site"), objects(&s, "site")), (Some(XrefStatus::Unloaded), 0)); assert!(s.doc().unwrap().block("site|Tree").is_none()); diff --git a/crates/fonts/build.rs b/crates/fonts/build.rs index b807eb45c..d790adb47 100644 --- a/crates/fonts/build.rs +++ b/crates/fonts/build.rs @@ -43,13 +43,16 @@ fn craft_fonts(dir: &std::path::Path) -> Result { rerun_if_changed(&manifest)?; // Resolve the checkout root once so every manifest file can be confined inside it. let root = dir.canonicalize().map_err(|e| format!("{}: {e}", dir.display()))?; - let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?; + // The manifest itself is confined too (a symlink in the checkout can't point it elsewhere), must + // be a regular file, and is read through a limit so `/dev/zero` can't fill memory. + let text = read_manifest(&resolve_font(&root, "fonts/manifest.txt")?)?; let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32"); let mut out = String::new(); - for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) { + for (n, line) in text.lines().enumerate().map(|(n, l)| (n + 1, l.trim())).filter(|(_, l)| !l.is_empty() && !l.starts_with('#')) { let f: Vec<&str> = line.split(" | ").map(str::trim).collect(); + // The line's text is not repeated: it comes from the checkout and goes to the build output. let [family, style, file, scripts, ..] = f.as_slice() else { - return Err(format!("malformed manifest line: {line}")); + return Err(format!("malformed manifest line {n}")); }; if BARRED_FAMILIES.iter().any(|barred| family.starts_with(barred)) { println!("cargo::warning=craft-fonts: not embedding {family} {style} (AGENTS.md: no Adobe fonts)"); @@ -73,6 +76,24 @@ fn craft_fonts(dir: &std::path::Path) -> Result { Ok(out) } +/// The most a manifest may hold; the real one is a few KiB. +const MAX_MANIFEST_BYTES: u64 = 1 << 20; + +/// Read the regular file `path` as text, at most [`MAX_MANIFEST_BYTES`]. +fn read_manifest(path: &std::path::Path) -> Result { + use std::io::Read as _; + let file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?; + if !file.metadata().is_ok_and(|m| m.is_file()) { + return Err(format!("{}: not a regular file", path.display())); + } + let mut text = String::new(); + file.take(MAX_MANIFEST_BYTES + 1).read_to_string(&mut text).map_err(|e| format!("{}: {e}", path.display()))?; + if text.len() as u64 > MAX_MANIFEST_BYTES { + return Err(format!("{}: larger than {MAX_MANIFEST_BYTES} bytes", path.display())); + } + Ok(text) +} + /// Resolve a manifest `file` field to a real path *confined to the checkout* `root` (CWE-22). /// /// The field is rejected if it is absolute or contains a parent (`..`) component, and the diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 66fe62199..e7e57e8e9 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -161,12 +161,14 @@ fn missing_marked_ui_literals(language: &str) -> BTreeSet { /// Every `.rs` file under `dir`, at any depth, so code split into submodules stays scanned. fn module_files(dir: &std::path::Path, files: &mut Vec) { let Ok(entries) = std::fs::read_dir(dir) else { return }; - let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect(); - paths.sort(); - for path in paths { - if path.is_dir() { + // `file_type` doesn't follow links, so a symlink (a directory alias or a file) is skipped, + // not walked again as a new subtree. + let mut paths: Vec<_> = entries.flatten().filter_map(|e| Some((e.path(), e.file_type().ok()?))).collect(); + paths.sort_by(|a, b| a.0.cmp(&b.0)); + for (path, kind) in paths { + if kind.is_dir() { module_files(&path, files); - } else if path.extension().is_some_and(|x| x == "rs") { + } else if kind.is_file() && path.extension().is_some_and(|x| x == "rs") { files.push(path); } } diff --git a/crates/ui-egui/src/xrefs.rs b/crates/ui-egui/src/xrefs.rs index 51a21834f..9c1350367 100644 --- a/crates/ui-egui/src/xrefs.rs +++ b/crates/ui-egui/src/xrefs.rs @@ -192,7 +192,7 @@ pub fn section(app: &mut CadApp, ui: &mut egui::Ui) { fn details(ui: &mut egui::Ui, r: &Row, path_id: egui::Id, act: &mut Option) { let name = r.name.clone(); let loaded = r.status == "Loaded" || r.status == "Unreferenced"; - let op = |option: &str| json!({ "option": option, "names": name }); + let op = |option: &str| json!({ "option": option, "names": name, "exact": true }); ui.horizontal_wrapped(|ui| { ui.add_space(8.0); if ui.button(crate::tl!("Reload")).clicked() { @@ -216,7 +216,7 @@ fn details(ui: &mut egui::Ui, r: &Row, path_id: egui::Id, act: &mut Option) .on_hover_text(crate::tl!("Make it a block of this drawing, keeping its layer names")) .clicked() { - *act = Some(Act::Run("-xref", json!({ "option": "bind", "names": name, "insert": true }))); + *act = Some(Act::Run("-xref", json!({ "option": "bind", "names": name, "exact": true, "insert": true }))); } if ui.add_enabled(r.status != "Not Found", egui::Button::new(crate::tl!("Open"))).clicked() { *act = Some(Act::Run("xopen", json!({ "name": name }))); @@ -230,7 +230,7 @@ fn details(ui: &mut egui::Ui, r: &Row, path_id: egui::Id, act: &mut Option) let a = ui.radio_value(&mut overlay, false, crate::tl!("Attach")).clicked(); let o = ui.radio_value(&mut overlay, true, crate::tl!("Overlay")).clicked(); if (a || o) && overlay != r.overlay { - *act = Some(Act::Run("-xref", json!({ "option": "type", "names": name, "overlay": overlay }))); + *act = Some(Act::Run("-xref", json!({ "option": "type", "names": name, "exact": true, "overlay": overlay }))); } }); ui.add_space(4.0); @@ -243,7 +243,7 @@ fn details(ui: &mut egui::Ui, r: &Row, path_id: egui::Id, act: &mut Option) let apply = ui.add_enabled(!text.trim().is_empty() && text != r.path, egui::Button::new(crate::tl!("Apply"))).clicked() || (edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && text != r.path); if apply { - *act = Some(Act::Run("-xref", json!({ "option": "path", "names": name, "path": text.trim() }))); + *act = Some(Act::Run("-xref", json!({ "option": "path", "names": name, "exact": true, "path": text.trim() }))); ui.data_mut(|d| d.remove::(path_id)); } else if text != r.path { ui.data_mut(|d| d.insert_temp(path_id, text)); diff --git a/xtask/src/stats.rs b/xtask/src/stats.rs index 6136de15a..790f68b50 100644 --- a/xtask/src/stats.rs +++ b/xtask/src/stats.rs @@ -136,6 +136,18 @@ pub fn run(_root: &Path, exact: bool) -> Result<(), String> { mod tests { use super::{Counts, count_tests}; + /// A new private directory in the temp folder. It is created, never reused: an existing path + /// (planted by another user, or a symlink) makes the test fail instead of redirecting its writes. + fn fresh_dir(tag: &str) -> std::path::PathBuf { + let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos()); + let p = std::env::temp_dir().join(format!("cadcraft-{tag}-{}-{nanos}", std::process::id())); + let mut b = std::fs::DirBuilder::new(); + #[cfg(unix)] + std::os::unix::fs::DirBuilderExt::mode(&mut b, 0o700); + b.create(&p).unwrap(); + p + } + #[test] fn counts_attributes_and_proptest_blocks() { let src = "#[test]\nfn a() {}\n// #[test] commented\n #[test]\nfn b() {}\nproptest! {\n}\n"; @@ -147,8 +159,7 @@ mod tests { #[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 base = fresh_dir("xtask"); let real = base.join("real"); std::fs::create_dir_all(&real).unwrap(); std::fs::write(real.join("a.rs"), "fn a() {}\n").unwrap(); @@ -162,8 +173,7 @@ mod tests { /// Traversal reports an exhausted entry budget and accepts a tree that exactly fits it. #[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(); + let base = fresh_dir("stats-budget"); 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"));