From 5a6aa9b65ff86c5da13517f7685513feab6ca7af Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 16:11:19 -0500 Subject: [PATCH] fix: bound or confine seven name, path and file inputs (#2732) * fix: bound or confine seven name, path and file inputs (QSELECT wildcard, layout template name, LISP OPEN/CLOSE, font manifest, xref palette, test walkers) * address review: reserved device names, fresh fifo test dir, stat before open in build.rs * review: honour the name alias and Unicode case in exact xrefs, reserve Windows device basenames, charge line headers, skip non-directories in the module walk * address review: distinct exact xref names, exact-capacity LISP line queue, fail the module walk on entry errors * address review: xrefs differing only by case cannot be loaded together; panic-free default template name * address review: refuse detach and path changes across case-variant xrefs; ASCII case folding for xref names * address review: report the case-variant xref a load skips; assert both xref paths in the test --- crates/engine/src/cmd/blocks/xref.rs | 57 ++++++++++++++++++++++- crates/engine/src/cmd/blocks/xref/load.rs | 18 ++++++- crates/engine/src/cmd/layout/saveas.rs | 56 ++++++++++++++++++++-- crates/engine/src/cmd/qselect.rs | 5 +- crates/engine/src/lisp/builtins/files.rs | 51 ++++++++++++++++++-- crates/engine/tests/xref.rs | 22 +++++++++ crates/fonts/build.rs | 31 ++++++++++-- crates/ui-egui/src/i18n/tests.rs | 22 +++++++-- crates/ui-egui/src/xrefs.rs | 8 ++-- xtask/src/stats.rs | 18 +++++-- 10 files changed, 260 insertions(+), 28 deletions(-) diff --git a/crates/engine/src/cmd/blocks/xref.rs b/crates/engine/src/cmd/blocks/xref.rs index 3b91809e5..4c11a3546 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,46 @@ 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()], + _ => str_param(p, "name").into_iter().collect(), + }; + let d = s.doc()?; + let mut names = Vec::new(); + let mut selected = std::collections::HashSet::new(); + // Symbol names are case-insensitive: two xrefs that differ only by case can't be loaded together. + let mut folded = std::collections::HashMap::new(); + for literal in &lits { + if let Some(block) = xrefs(d).find(|block| block.name == *literal) { + if let Some(first) = folded.insert(block.name.to_ascii_lowercase(), block.name.clone()).filter(|f| *f != block.name) { + return Err(bad(cmd, format!("xref names \"{first}\" and \"{}\" differ only by case", block.name))); + } + if selected.insert(block.name.clone()) { + names.push(block.name.clone()); + } + continue; + } + let normalized = literal.to_ascii_lowercase(); + let mut matches = xrefs(d).filter(|block| block.name.to_ascii_lowercase() == normalized); + if let Some(block) = matches.next() { + if matches.next().is_some() { + return Err(bad(cmd, format!("xref name \"{literal}\" is ambiguous ignoring case"))); + } + if let Some(first) = folded.insert(block.name.to_ascii_lowercase(), block.name.clone()).filter(|f| *f != block.name) { + return Err(bad(cmd, format!("xref names \"{first}\" and \"{}\" differ only by case", block.name))); + } + if selected.insert(block.name.clone()) { + names.push(block.name.clone()); + } + } + } + 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(), @@ -223,6 +263,19 @@ fn run_xref(s: &mut Session, _p: &Value) -> Result { Ok(r) } +/// An error when two of `names` (or, with `all`, one of them and any other attached xref) differ only +/// by case: symbol names are case-insensitive, so such xrefs cannot be handled together, and the +/// command must refuse before it changes anything. +fn refuse_case_clash(d: &Drawing, cmd: &str, names: &[String], all: bool) -> Result<()> { + let others: Vec<&str> = if all { xrefs(d).map(|b| b.name.as_str()).collect() } else { names.iter().map(String::as_str).collect() }; + for n in names { + if let Some(o) = others.iter().find(|o| *o != n && o.eq_ignore_ascii_case(n)) { + return Err(bad(cmd, format!("xref names \"{n}\" and \"{o}\" differ only by case"))); + } + } + Ok(()) +} + fn run_dash_xref(s: &mut Session, p: &Value) -> Result { let option = str_param(p, "option").unwrap_or("?").trim().to_ascii_lowercase(); let host = host(s); @@ -252,6 +305,7 @@ fn run_dash_xref(s: &mut Session, p: &Value) -> Result { } "detach" | "d" => { let names = named(s, "-xref", p)?; + refuse_case_clash(s.doc()?, "-xref", &names, true)?; let mut erased = 0; for n in &names { erased += load::detach(s.doc_mut()?, n); @@ -276,6 +330,7 @@ fn run_dash_xref(s: &mut Session, p: &Value) -> Result { "path" | "p" => { let path = str_param(p, "path").map(str::trim).filter(|t| !t.is_empty()).ok_or_else(|| bad("-xref", "`path` is required"))?.to_string(); let names = named(s, "-xref", p)?; + refuse_case_clash(s.doc()?, "-xref", &names, false)?; let d = s.doc_mut()?; for n in &names { if let Some(b) = d.blocks.get_mut(n) { diff --git a/crates/engine/src/cmd/blocks/xref/load.rs b/crates/engine/src/cmd/blocks/xref/load.rs index 981df918d..d6627e615 100644 --- a/crates/engine/src/cmd/blocks/xref/load.rs +++ b/crates/engine/src/cmd/blocks/xref/load.rs @@ -313,7 +313,23 @@ pub(crate) fn load(d: &mut Drawing, name: &str, host: Option<&str>, chosen: bool /// [`load`] the xrefs `names` within one set of limits. pub(crate) fn load_each(d: &mut Drawing, names: &[String], host: Option<&str>, chosen: bool) -> Vec { let mut cx = Ctx::new(host); - names.iter().flat_map(|n| load_with(d, n, host, chosen, &mut cx)).collect() + // Names are case-insensitive, so a name that differs only by case from an earlier one is the same xref. + let mut seen = std::collections::HashMap::new(); + names + .iter() + .flat_map(|n| match seen.entry(n.to_ascii_lowercase()) { + std::collections::hash_map::Entry::Vacant(v) => { + v.insert(n.as_str()); + load_with(d, n, host, chosen, &mut cx) + } + // Reported, not silent: the skipped xref keeps its status and is not loaded here. + std::collections::hash_map::Entry::Occupied(o) => vec![Outcome { + name: String::new(), + status: XrefStatus::Unresolved, + detail: format!("xref \"{n}\" not loaded: it differs only by case from \"{}\"", o.get()), + }], + }) + .collect() } impl Ctx { diff --git a/crates/engine/src/cmd/layout/saveas.rs b/crates/engine/src/cmd/layout/saveas.rs index 0a0b2b5f8..06fac2e9d 100644 --- a/crates/engine/src/cmd/layout/saveas.rs +++ b/crates/engine/src/cmd/layout/saveas.rs @@ -26,11 +26,30 @@ 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"); - 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, + // 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(); + // Windows reserves device names (`CON`, `NUL`, `COM1`...) with any extension, so prefix them. + let base = name.split('.').next().unwrap_or("").trim_end().to_ascii_uppercase(); + let reserved = matches!(base.as_str(), "CON" | "PRN" | "AUX" | "NUL") + || (base.len() == 4 && (base.starts_with("COM") || base.starts_with("LPT")) && base.ends_with(|c: char| ('1'..='9').contains(&c))); + let file = if reserved { format!("_{name}.dwt") } else { format!("{name}.dwt") }; + let candidate = match s.state().ok().and_then(|st| st.path.clone()) { + Some(p) => std::path::Path::new(&p).with_file_name(&file), + None => std::path::PathBuf::from(file), + }; + if !candidate.exists() { + return candidate.to_string_lossy().into_owned(); } + let stem = candidate.file_stem().and_then(|s| s.to_str()).unwrap_or("layout"); + let extension = candidate.extension().and_then(|e| e.to_str()).unwrap_or("dwt"); + let parent = candidate.parent().unwrap_or_else(|| std::path::Path::new("")); + // The suffix range never ends in practice; the fallback only keeps the code panic-free. + let alternative = (2u64..) + .map(|suffix| parent.join(format!("{stem}-{suffix}.{extension}"))) + .find(|alternative| !alternative.exists()) + .unwrap_or(candidate.clone()); + alternative.to_string_lossy().into_owned() } /// `path` with `.dwt` added when it has no extension; an error for any other file type. @@ -133,3 +152,32 @@ 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"); + } + + /// Windows device names are prefixed so the suggestion is never a device. + #[test] + fn reserved_device_names_are_prefixed() { + let s = Session::empty(); + for name in ["CON", "nul", "Com1", "LPT9", "aux.x"] { + assert_eq!(default_path(&s, name), format!("_{name}.dwt")); + } + for name in ["COM0", "CONSOLE", "COM10"] { + assert_eq!(default_path(&s, name), format!("{name}.dwt")); + } + } +} diff --git a/crates/engine/src/cmd/qselect.rs b/crates/engine/src/cmd/qselect.rs index 469a0895f..18959e197 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..f34ed3ab7 100644 --- a/crates/engine/src/lisp/builtins/files.rs +++ b/crates/engine/src/lisp/builtins/files.rs @@ -27,8 +27,14 @@ pub(super) fn open(ctx: &mut Ctx<'_>, a: Vec) -> Result if !writing { match read_text_file(&path) { Some(t) => { - charge_bytes(t.len())?; - f.lines = t.lines().map(str::to_string).collect(); + // Each line has a String header and a separately allocated buffer. Charge a + // conservative 64 bytes per line for both, including the allocator's minimum + // allocation size, before collecting so many short lines cannot evade the budget. + let count = t.lines().count(); + charge_bytes(t.len().saturating_add(count.saturating_mul(64)))?; + // Exact capacity: collecting would grow by doubling and hold unbilled slack. + f.lines = std::collections::VecDeque::with_capacity(count); + f.lines.extend(t.lines().map(str::to_string)); } None => return Ok(Flow::Value(Value::Nil)), } @@ -100,8 +106,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 +135,29 @@ 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() { + // A fresh directory: `create_dir` fails if the name exists, so no stale pipe or planted symlink is reused. + let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("cadcraft-lispfifo-{}-{nanos}", std::process::id())); + std::fs::create_dir(&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..5398b31d0 100644 --- a/crates/engine/tests/xref.rs +++ b/crates/engine/tests/xref.rs @@ -190,6 +190,28 @@ 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(); + + // Two xrefs that differ only by case: an exact selection of both is refused before anything loads, + // and reload-all loads one of them once. + let mut twin = Block::new("SITE"); + twin.xref_path = Some("site.dxf".into()); + s.doc_mut().unwrap().blocks.insert("SITE".into(), Arc::new(twin)); + assert!(s.execute("-xref", &json!({ "option": "reload", "names": ["site", "SITE"], "exact": true })).is_err()); + let r = s.execute("-xref", &json!({ "option": "reload" })).unwrap(); + assert_eq!(r["xrefs"].as_array().map(Vec::len), Some(1)); + // Detaching one of them, or giving both a new path, is refused before anything changes. + assert!(s.execute("-xref", &json!({ "option": "detach", "names": "site", "exact": true })).is_err()); + assert!(s.doc().unwrap().block("site").is_some()); + assert!(s.execute("-xref", &json!({ "option": "path", "names": "*", "path": "other.dxf" })).is_err()); + for k in ["site", "SITE"] { + assert_eq!(s.doc().unwrap().blocks.get(k).and_then(|b| b.xref_path.clone()).as_deref(), Some("site.dxf"), "{k}"); + } + s.doc_mut().unwrap().blocks.remove("SITE"); + 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..a290aecd0 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,28 @@ 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 _; + // stat never blocks, but opening a FIFO with no writer does, so refuse non-regular files first. + if !std::fs::metadata(path).is_ok_and(|m| m.is_file()) { + return Err(format!("{}: not a regular file", path.display())); + } + 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 2d13612df..ce6b0b74f 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -161,13 +161,25 @@ 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) { + if !std::fs::symlink_metadata(dir).is_ok_and(|metadata| metadata.file_type().is_dir()) { + return; + } 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. + // An unreadable entry must fail the scan, not silently shrink the translation report. + let mut paths: Vec<_> = entries + .map(|e| { + let e = e.expect("read module directory entry"); + let kind = e.file_type().expect("read module entry type"); + (e.path(), kind) + }) + .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 ed1f43d79..839abf7ac 100644 --- a/crates/ui-egui/src/xrefs.rs +++ b/crates/ui-egui/src/xrefs.rs @@ -197,7 +197,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() { @@ -221,7 +221,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 }))); @@ -235,7 +235,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); @@ -248,7 +248,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"));