From 8fe7c421ea95b986cedaa49314ce74f607a8a388 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 17:18:34 +0000 Subject: [PATCH] Xrefs and data links: follow stored paths only on this computer or the drawing's share Xref paths saved in a drawing (on open, on Reload, in the External References list, XOPEN, nested xrefs) and table data-link paths re-read by DATALINKUPDATE go through doc_path::resolve (craft-docpath): relative paths resolve beside the drawing that stores them, and paths to other computers, devices and URLs are not opened. Such an xref is Unresolved with the reason, unless a file of its name is beside the host drawing. Paths given to XATTACH, -XREF Path and DATALINK are used as given. One load of xrefs reads at most 1 GB from referenced files in total. --- Cargo.lock | 5 + Cargo.toml | 2 + crates/engine/Cargo.toml | 1 + crates/engine/src/cmd/blocks/xref.rs | 23 ++- crates/engine/src/cmd/blocks/xref/load.rs | 202 +++++++++++++++------- crates/engine/src/cmd/table/breaks.rs | 28 ++- crates/engine/src/doc_path.rs | 38 ++++ crates/engine/src/lib.rs | 1 + crates/engine/tests/table_breaks.rs | 46 +++++ crates/engine/tests/xref.rs | 69 ++++++++ 10 files changed, 334 insertions(+), 81 deletions(-) create mode 100644 crates/engine/src/doc_path.rs diff --git a/Cargo.lock b/Cargo.lock index c054dcfd..cbc5fd45 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -634,6 +634,7 @@ dependencies = [ "cadcraft-geom", "cadcraft-io", "cadcraft-render", + "craft-docpath", "log", "rstar", "serde", @@ -927,6 +928,10 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" +[[package]] +name = "craft-docpath" +version = "0.1.0" + [[package]] name = "craft-ui" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 9632245d..07dfcb7e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,8 @@ cadcraft-io = { path = "crates/io" } cadcraft-engine = { path = "crates/engine" } cadcraft-ui-egui = { path = "crates/ui-egui" } cadcraft-mcp = { path = "crates/mcp" } +# Which paths stored in a drawing (xrefs, data links) may be opened. +craft-docpath = { path = "../secfix-craft-libs/crates/docpath" } # foundation thiserror = "2" diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 0dad3771..a19da90f 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -16,6 +16,7 @@ cadcraft-dxf = { workspace = true } cadcraft-constraints = { workspace = true } cadcraft-fonts = { workspace = true } cadcraft-render = { workspace = true } +craft-docpath = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/engine/src/cmd/blocks/xref.rs b/crates/engine/src/cmd/blocks/xref.rs index a3b19fa4..b87e7379 100644 --- a/crates/engine/src/cmd/blocks/xref.rs +++ b/crates/engine/src/cmd/blocks/xref.rs @@ -104,8 +104,8 @@ fn reference_count(d: &Drawing, key: &str) -> usize { fn describe(d: &Drawing, b: &Block, host: Option<&str>) -> Value { let refs = reference_count(d, &b.name); let status = if refs == 0 && b.xref_status == XrefStatus::Loaded { "Unreferenced" } else { b.xref_status.label() }; - let dir = host.and_then(|h| std::path::Path::new(h).parent().map(std::path::Path::to_path_buf)); - let found = b.xref_path.as_deref().and_then(|p| load::find(p, dir.as_deref())).map(|f| f.to_string_lossy().into_owned()); + let found = + b.xref_path.as_deref().and_then(|p| load::find(p, host.map(std::path::Path::new), false).ok()).map(|f| f.to_string_lossy().into_owned()); json!({ "name": b.name, "path": b.xref_path, @@ -146,7 +146,7 @@ fn attach(s: &mut Session, name: &str, path: &str, overlay: bool) -> Result {} Some(o) => return Err(bad("xattach", format!("{path} can't be attached: {}", o.detail))), @@ -227,11 +227,7 @@ fn run_dash_xref(s: &mut Session, p: &Value) -> Result { } "reload" | "r" => { let names = named(s, "-xref", p)?; - let mut out = Vec::new(); - for n in &names { - let d = s.doc_mut()?; - out.extend(load::load(d, n, host.as_deref())); - } + let out = load::load_each(s.doc_mut()?, &names, host.as_deref(), false); echo_all(s, &out); Ok( json!({ "xrefs": out.iter().filter(|o| !o.name.is_empty()).map(|o| json!({"name": o.name, "status": o.status.label(), "detail": o.detail})).collect::>() }), @@ -271,14 +267,13 @@ 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)?; - let mut out = Vec::new(); + let d = s.doc_mut()?; for n in &names { - let d = s.doc_mut()?; if let Some(b) = d.blocks.get_mut(n) { Arc::make_mut(b).xref_path = Some(path.clone()); } - out.extend(load::load(d, n, host.as_deref())); } + let out = load::load_each(d, &names, host.as_deref(), true); echo_all(s, &out); Ok(json!({ "xrefs": names, "path": path })) } @@ -367,8 +362,10 @@ fn run_xopen(s: &mut Session, p: &Value) -> Result { let b = d.block(&name).ok_or_else(|| bad("xopen", format!("no xref \"{name}\"")))?; let path = b.xref_path.clone().ok_or_else(|| bad("xopen", format!("\"{name}\" is not an xref")))?; let host = host(s); - let dir = host.as_deref().and_then(|h| std::path::Path::new(h).parent().map(std::path::Path::to_path_buf)); - let file = load::find(&path, dir.as_deref()).ok_or_else(|| bad("xopen", format!("{path}: file not found")))?; + let file = load::find(&path, host.as_deref().map(std::path::Path::new), false).map_err(|m| match m { + load::Missing::NotFound => bad("xopen", format!("{path}: file not found")), + load::Missing::Refused(r) => bad("xopen", format!("{path}: not opened, {r}")), + })?; s.execute("open", &json!({ "path": file.to_string_lossy() })) } diff --git a/crates/engine/src/cmd/blocks/xref/load.rs b/crates/engine/src/cmd/blocks/xref/load.rs index 36abd364..efe9143a 100644 --- a/crates/engine/src/cmd/blocks/xref/load.rs +++ b/crates/engine/src/cmd/blocks/xref/load.rs @@ -3,8 +3,10 @@ //! `xref|name`. //! //! A path is looked for as given (relative paths against the host drawing's folder), then by its -//! file name in the host drawing's folder. A referenced drawing's own attached xrefs load with it -//! (overlays don't), at most [`MAX_BLOCK_DEPTH`] levels deep; a drawing that is already being +//! file name in the host drawing's folder. A path stored in a drawing is followed only to this +//! computer or the host drawing's network share ([`crate::doc_path`]); one that isn't followed and +//! has no file of its name beside the host leaves the xref unresolved. A referenced drawing's own +//! attached xrefs load with it (overlays don't), at most [`MAX_BLOCK_DEPTH`] levels deep; a drawing that is already being //! loaded further up (a circular reference) is not loaded again. Every failure leaves the xref's //! block empty with a status saying why; nothing here stops the host drawing from opening. @@ -13,6 +15,8 @@ use std::sync::Arc; use cadcraft_doc::{Attrib, Drawing, Entity, EntityKind, EntityStore, MAX_BLOCK_DEPTH, XrefStatus}; +use crate::doc_path::{self, Refused}; + /// Most objects the xrefs of one drawing load in one go (nested xrefs included). pub(crate) const MAX_XREF_OBJECTS: usize = 4_000_000; /// Most referenced files read in one go (nested xrefs included). @@ -20,6 +24,9 @@ const MAX_XREF_FILES: usize = 1000; /// Largest referenced file read. #[cfg(not(target_arch = "wasm32"))] const MAX_XREF_BYTES: u64 = 512 << 20; +/// Most bytes read from referenced files in one go (nested xrefs included). +#[cfg(not(target_arch = "wasm32"))] +const MAX_XREF_TOTAL_BYTES: u64 = 1 << 30; /// The kinds of named symbols an object can refer to. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] @@ -119,16 +126,38 @@ struct Ctx { chain: Vec, objects: usize, files: usize, + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + bytes: u64, /// Messages about nested xrefs. notes: Vec, } /// The folder of the host drawing at `host` (a file path). -fn folder(host: Option<&str>) -> Option { - let p = Path::new(host?).parent()?; +fn folder(host: Option<&Path>) -> Option { + let p = host?.parent()?; Some(if p.as_os_str().is_empty() { PathBuf::from(".") } else { p.to_path_buf() }) } +/// Why a referenced drawing wasn't found. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Missing { + NotFound, + /// The saved path isn't followed, and there is no file of its name beside the host. + Refused(Refused), +} + +impl Missing { + fn outcome(self, path: &str) -> (XrefStatus, String) { + match self { + Self::NotFound => { + let why = if cfg!(target_arch = "wasm32") { "files are not available on the web" } else { "file not found" }; + (XrefStatus::NotFound, format!("{path}: {why}")) + } + Self::Refused(r) => (XrefStatus::Unresolved, format!("{path}: not opened, {r}")), + } + } +} + #[cfg(not(target_arch = "wasm32"))] fn canonical(p: &Path) -> PathBuf { std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()) @@ -138,58 +167,94 @@ fn canonical(p: &Path) -> PathBuf { p.to_path_buf() } -/// Where to look for a referenced drawing saved as `path`: the path itself (a relative one against -/// the host's folder), then its file name in the host's folder. Both separators work everywhere. -fn candidates(path: &str, dir: Option<&Path>) -> Vec { - let norm = if cfg!(windows) { path.to_string() } else { path.replace('\\', "/") }; - let p = PathBuf::from(norm.trim()); +/// Where to look for a referenced drawing at `path` in the drawing at `host`: the path itself (a +/// relative one against the host's folder), then its file name in the host's folder. Both +/// separators work everywhere. A path stored in a drawing goes through [`doc_path::resolve`] +/// (the refusal is returned); a path the user just `chosen` is used as given. +fn candidates(path: &str, host: Option<&Path>, chosen: bool) -> (Vec, Option) { + let path = path.trim(); let mut out = Vec::new(); - match dir { - Some(d) if !p.is_absolute() => out.push(d.join(&p)), - _ => out.push(p.clone()), + let mut refused = None; + if chosen { + let norm = if cfg!(windows) { path.to_string() } else { path.replace('\\', "/") }; + let p = PathBuf::from(norm); + match folder(host) { + Some(d) if !p.is_absolute() => out.push(d.join(&p)), + _ => out.push(p), + } + } else { + match doc_path::resolve(path, host) { + Ok(p) => out.push(p), + Err(Refused::Invalid) => {} + Err(r) => refused = Some(r), + } } - if let (Some(d), Some(f)) = (dir, p.file_name()) { - out.push(d.join(f)); + let name = path.rsplit(['/', '\\']).next().unwrap_or(path); + if host.is_some() + && !name.is_empty() + && let Ok(p) = doc_path::resolve(name, host) + && !out.contains(&p) + { + out.push(p); } - out + (out, refused) } -/// The file a referenced drawing saved as `path` is loaded from, if there is one. -pub(crate) fn find(path: &str, dir: Option<&Path>) -> Option { +/// The file a referenced drawing at `path` (saved in the drawing at `host`, or just `chosen` by +/// the user) is loaded from. +pub(crate) fn find(path: &str, host: Option<&Path>, chosen: bool) -> Result { if path.trim().is_empty() { - return None; + return Err(Missing::NotFound); } + let (files, refused) = candidates(path, host, chosen); #[cfg(not(target_arch = "wasm32"))] - { - candidates(path, dir).into_iter().find(|p| p.is_file()) - } + let found = files.into_iter().find(|p| p.is_file()); #[cfg(target_arch = "wasm32")] - { - let _ = candidates(path, dir); + let found: Option = { + let _ = files; None - } + }; + found.ok_or(refused.map_or(Missing::NotFound, Missing::Refused)) } -/// Read a drawing file through the host's io hooks. -fn read(file: &Path) -> Result { +/// Read a drawing file through the host's io hooks, counting its bytes against the load's total. +fn read(file: &Path, cx: &mut Ctx) -> Result { let hooks = super::super::super::file::io().ok_or("file formats are not available in this build")?; #[cfg(not(target_arch = "wasm32"))] { - let len = std::fs::metadata(file).map_err(|e| e.to_string())?.len(); - if len > MAX_XREF_BYTES { - return Err(format!("the file is larger than {} MB", MAX_XREF_BYTES >> 20)); + use std::io::Read; + let left = MAX_XREF_TOTAL_BYTES.saturating_sub(cx.bytes); + let limit = MAX_XREF_BYTES.min(left); + let mut f = std::fs::File::open(file).map_err(|e| e.to_string())?; + let len = f.metadata().map_err(|e| e.to_string())?.len(); + let too_big = || { + if limit < MAX_XREF_BYTES { + format!("the referenced files are larger than {} MB together", MAX_XREF_TOTAL_BYTES >> 20) + } else { + format!("the file is larger than {} MB", MAX_XREF_BYTES >> 20) + } + }; + if len > limit { + return Err(too_big()); } - let bytes = std::fs::read(file).map_err(|e| e.to_string())?; + let mut bytes = Vec::new(); + f.by_ref().take(limit.saturating_add(1)).read_to_end(&mut bytes).map_err(|e| e.to_string())?; + let n = u64::try_from(bytes.len()).unwrap_or(u64::MAX); + if n > limit { + return Err(too_big()); + } + cx.bytes = cx.bytes.saturating_add(n); (hooks.read)(&bytes, &file.to_string_lossy()) } #[cfg(target_arch = "wasm32")] { - let _ = (hooks, file); + let _ = (hooks, file, cx); Err("files are not available on the web".into()) } } -/// Load every xref of `d` that isn't unloaded; `host` is the drawing's own path. +/// Load every xref of `d` that isn't unloaded from the paths saved in it; `host` is the drawing's +/// own path. pub(crate) fn load_all(d: &mut Drawing, host: Option<&str>) -> Vec { let names: Vec = d .blocks @@ -197,28 +262,44 @@ pub(crate) fn load_all(d: &mut Drawing, host: Option<&str>) -> Vec { .filter(|b| b.xref_path.is_some() && b.xref_status != XrefStatus::Unloaded && !b.name.contains('|')) .map(|b| b.name.clone()) .collect(); - names.iter().flat_map(|n| load(d, n, host)).collect() + load_each(d, &names, host, false) } -/// Load (or reload) xref `name` of `d` from its file; `host` is the drawing's own path. Returns -/// the outcome, then notes about its nested xrefs. -pub(crate) fn load(d: &mut Drawing, name: &str, host: Option<&str>) -> Vec { - let host_file = host.map(Path::new).filter(|p| p.file_name().is_some()); - let mut cx = Ctx { chain: host_file.map(canonical).into_iter().collect(), objects: 0, files: 0, notes: Vec::new() }; - let dir = folder(host); - let (status, detail) = load_in(d, name, dir.as_deref(), &mut cx, 0); +/// Load (or reload) xref `name` of `d` from its file; `host` is the drawing's own path, and +/// `chosen` says the xref's path was just given by the user (not read from a drawing). Returns the +/// outcome, then notes about its nested xrefs. +pub(crate) fn load(d: &mut Drawing, name: &str, host: Option<&str>, chosen: bool) -> Vec { + load_each(d, std::slice::from_ref(&name.to_string()), host, chosen) +} + +/// [`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() +} + +impl Ctx { + fn new(host: Option<&str>) -> Self { + let host_file = host.map(Path::new).filter(|p| p.file_name().is_some()); + Ctx { chain: host_file.map(canonical).into_iter().collect(), objects: 0, files: 0, bytes: 0, notes: Vec::new() } + } +} + +fn load_with(d: &mut Drawing, name: &str, host: Option<&str>, chosen: bool, cx: &mut Ctx) -> Vec { + let host = host.map(Path::new).filter(|p| p.file_name().is_some()); + let (status, detail) = load_in(d, name, host, chosen, cx, 0); let key = d.block(name).map(|b| b.name.clone()).unwrap_or_else(|| name.to_string()); let mut out = vec![Outcome { name: key, status, detail }]; - out.extend(cx.notes.into_iter().map(|n| Outcome { name: String::new(), status: XrefStatus::Unresolved, detail: n })); + out.extend(cx.notes.drain(..).map(|n| Outcome { name: String::new(), status: XrefStatus::Unresolved, detail: n })); out } -fn load_in(d: &mut Drawing, name: &str, dir: Option<&Path>, cx: &mut Ctx, depth: usize) -> (XrefStatus, String) { +fn load_in(d: &mut Drawing, name: &str, host: Option<&Path>, chosen: bool, cx: &mut Ctx, depth: usize) -> (XrefStatus, String) { let Some(b) = d.block(name) else { return (XrefStatus::NotFound, "no such xref".into()) }; let key = b.name.clone(); let Some(path) = b.xref_path.clone() else { return (XrefStatus::Unresolved, "not an xref".into()) }; clear(d, &key); - let (status, detail) = match fetch(&path, dir, cx, depth) { + let (status, detail) = match fetch(&path, host, chosen, cx, depth) { Ok((x, file)) => match import(d, &key, &x, cx) { Ok(()) => (XrefStatus::Loaded, file.to_string_lossy().into_owned()), Err(e) => (XrefStatus::Unresolved, e), @@ -234,15 +315,12 @@ fn load_in(d: &mut Drawing, name: &str, dir: Option<&Path>, cx: &mut Ctx, depth: } /// Read the drawing referenced as `path` and load its own attached xrefs into it. -fn fetch(path: &str, dir: Option<&Path>, cx: &mut Ctx, depth: usize) -> Result<(Drawing, PathBuf), (XrefStatus, String)> { +fn fetch(path: &str, host: Option<&Path>, chosen: bool, cx: &mut Ctx, depth: usize) -> Result<(Drawing, PathBuf), (XrefStatus, String)> { let unresolved = |m: &str| (XrefStatus::Unresolved, m.to_string()); if depth >= MAX_BLOCK_DEPTH { return Err(unresolved("xrefs are nested too deeply")); } - let Some(file) = find(path, dir) else { - let why = if cfg!(target_arch = "wasm32") { "files are not available on the web" } else { "file not found" }; - return Err((XrefStatus::NotFound, format!("{path}: {why}"))); - }; + let file = find(path, host, chosen).map_err(|m| m.outcome(path))?; let canon = canonical(&file); if cx.chain.contains(&canon) { return Err(unresolved("circular reference")); @@ -251,13 +329,12 @@ fn fetch(path: &str, dir: Option<&Path>, cx: &mut Ctx, depth: usize) -> Result<( return Err(unresolved("too many referenced files")); } cx.files += 1; - let mut x = read(&file).map_err(|e| (XrefStatus::Unresolved, format!("{}: {e}", file.to_string_lossy())))?; + let mut x = read(&file, cx).map_err(|e| (XrefStatus::Unresolved, format!("{}: {e}", file.to_string_lossy())))?; cx.chain.push(canon); let nested: Vec = x.blocks.values().filter(|b| b.xref_path.is_some() && !b.xref_overlay && !b.name.contains('|')).map(|b| b.name.clone()).collect(); - let xdir = file.parent().map(Path::to_path_buf); for n in nested { - let (status, detail) = load_in(&mut x, &n, xdir.as_deref(), cx, depth + 1); + let (status, detail) = load_in(&mut x, &n, Some(&file), false, cx, depth + 1); if status != XrefStatus::Loaded { cx.notes.push(format!("nested xref \"{n}\" not loaded: {detail}")); } @@ -430,18 +507,23 @@ mod tests { #[test] fn paths_resolve_against_the_host_folder_then_by_name() { - let dir = Path::new("/proj/plans"); - let c = candidates("parts/a.dxf", Some(dir)); - assert_eq!(c, vec![dir.join("parts/a.dxf"), dir.join("a.dxf")]); - let c = candidates("/abs/b.dwg", Some(dir)); - assert_eq!(c, vec![PathBuf::from("/abs/b.dwg"), dir.join("b.dwg")]); + let dir = std::env::temp_dir().join("proj").join("plans"); + let host = dir.join("host.dxf"); + let c = |path: &str, chosen| candidates(path, Some(&host), chosen); + assert_eq!(c("parts/a.dxf", false), (vec![dir.join("parts").join("a.dxf"), dir.join("a.dxf")], None)); + let abs = std::env::temp_dir().join("abs").join("b.dwg"); + assert_eq!(c(&abs.to_string_lossy(), false), (vec![abs.clone(), dir.join("b.dwg")], None)); if !cfg!(windows) { // A Windows path saved elsewhere still finds the file next to the host. - let c = candidates("C:\\jobs\\site.dwg", Some(dir)); - assert_eq!(c.last(), Some(&dir.join("site.dwg"))); + assert_eq!(c("C:\\jobs\\site.dwg", false), (vec![dir.join("site.dwg")], None)); } - assert_eq!(candidates("a.dxf", None), vec![PathBuf::from("a.dxf")]); - assert!(find("", Some(dir)).is_none()); + // Paths to other computers are only looked for beside the host. + assert_eq!(c(r"\\192.0.2.1\share\x.dxf", false), (vec![dir.join("x.dxf")], Some(Refused::Network))); + assert_eq!(c("file://192.0.2.1/s/x.dxf", false), (vec![dir.join("x.dxf")], Some(Refused::Network))); + // Without a host drawing a relative path names no file. + assert_eq!(candidates("a.dxf", None, false), (vec![], None)); + assert_eq!(candidates("a.dxf", None, true), (vec![PathBuf::from("a.dxf")], None)); + assert_eq!(find("", Some(&host), false), Err(Missing::NotFound)); } #[test] diff --git a/crates/engine/src/cmd/table/breaks.rs b/crates/engine/src/cmd/table/breaks.rs index 9bf98e56..f7e6ce03 100644 --- a/crates/engine/src/cmd/table/breaks.rs +++ b/crates/engine/src/cmd/table/breaks.rs @@ -224,15 +224,25 @@ fn separator(path: &str, text: &str) -> char { if first.contains(';') && !first.contains(',') { ';' } else { ',' } } +/// The file to read for a data link: a path the user just `chosen` as given, a path stored in the +/// drawing at `host` only through [`crate::doc_path::resolve`]. +fn link_file(id: &str, path: &str, host: Option<&str>, chosen: bool) -> Result { + if chosen { + return Ok(std::path::PathBuf::from(path)); + } + crate::doc_path::resolve(path, host.map(std::path::Path::new)).map_err(|r| bad(id, format!("{path}: not opened, {r}"))) +} + /// Read a linked file through the host (natively the file system; not on the web). -fn read_link(id: &str, path: &str) -> Result { +fn read_link(id: &str, file: &std::path::Path) -> Result { + let path = file.display(); #[cfg(not(target_arch = "wasm32"))] { - let meta = std::fs::metadata(path).map_err(|e| bad(id, format!("{path}: {e}")))?; + 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(path).map_err(|e| bad(id, format!("{path}: {e}")))?; + let bytes = std::fs::read(file).map_err(|e| bad(id, format!("{path}: {e}")))?; Ok(String::from_utf8_lossy(&bytes).into_owned()) } #[cfg(target_arch = "wasm32")] @@ -278,14 +288,16 @@ fn fill(t: &mut Table, start: usize, data: &[Vec]) { } } -/// Re-read the linked file of table `h`. -fn update(s: &mut Session, h: Handle) -> Result<(usize, usize)> { +/// Re-read the linked file of table `h`; `chosen` says its path was just given by the user. +fn update(s: &mut Session, h: Handle, chosen: bool) -> Result<(usize, usize)> { let id = "datalinkupdate"; let link = match s.doc()?.entity(h).map(|e| &e.kind) { Some(EntityKind::Table(t)) => t.data_link.clone().ok_or_else(|| bad(id, "the table has no data link"))?, _ => return Err(bad(id, "object is not a table")), }; - let text = read_link(id, &link.path)?; + let host = s.state()?.path.clone(); + let file = link_file(id, &link.path, host.as_deref(), chosen)?; + let text = read_link(id, &file)?; let data = parse_csv(&text, separator(&link.path, &text)); let mut size = (0, 0); edit(s, &json!({ "handle": h.hex() }), id, |t| { @@ -313,7 +325,7 @@ fn run_datalink(s: &mut Session, p: &Value) -> Result { Ok(()) })?; let h = r.get("handle").and_then(Value::as_str).and_then(Handle::parse_hex).ok_or_else(|| bad(id, "no table"))?; - let (rows, cols) = update(s, h)?; + let (rows, cols) = update(s, h, true)?; Ok(json!({ "handle": h.hex(), "rows": rows, "cols": cols })) } @@ -340,7 +352,7 @@ fn run_update(s: &mut Session, p: &Value) -> Result { let mut updated = 0; let mut errors = Vec::new(); for h in hs { - match update(s, h) { + match update(s, h, false) { Ok(_) => updated += 1, Err(e) => errors.push(e.to_string()), } diff --git a/crates/engine/src/doc_path.rs b/crates/engine/src/doc_path.rs new file mode 100644 index 00000000..06b25c2f --- /dev/null +++ b/crates/engine/src/doc_path.rs @@ -0,0 +1,38 @@ +//! Paths stored in a drawing: xref paths and table data links. +//! +//! Such a path reaches the file system only through [`resolve`]: a relative path is taken from +//! the drawing's folder, and only files on this computer, or on the network share the drawing +//! itself is on, are opened (see `craft_docpath`). A refused path is handled like a missing file, +//! with the reason in its status. Paths the user gives a command (XATTACH, -XREF Path, DATALINK) +//! are their own choice and are used as given. + +use std::path::{Path, PathBuf}; + +pub use craft_docpath::Refused; + +/// The file to open for `stored`, a path or `file:` URL stored in the drawing saved at +/// `document` (`None` for a drawing without a file). +/// +/// A result that isn't absolute is refused as [`Refused::Invalid`]: without a document a +/// relative path would depend on the working directory, and a Windows drive path names no file +/// on other platforms. +pub fn resolve(stored: &str, document: Option<&Path>) -> Result { + let document = document.filter(|d| !d.as_os_str().is_empty()).map(|d| std::path::absolute(d).unwrap_or_else(|_| d.to_path_buf())); + let path = craft_docpath::resolve(stored.trim(), document.as_deref())?; + if path.is_absolute() { Ok(path) } else { Err(Refused::Invalid) } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn relative_paths_resolve_beside_the_drawing_and_other_computers_are_refused() { + let doc = std::env::temp_dir().join("plans").join("host.dxf"); + assert_eq!(resolve("parts/a.dxf", Some(&doc)), Ok(std::env::temp_dir().join("plans").join("parts").join("a.dxf"))); + assert_eq!(resolve("a.dxf", None), Err(Refused::Invalid)); + assert_eq!(resolve(r"\\192.0.2.1\share\a.dxf", Some(&doc)), Err(Refused::Network)); + assert_eq!(resolve("file://192.0.2.1/s/a.dxf", Some(&doc)), Err(Refused::Network)); + assert_eq!(resolve("//192.0.2.1/s/a.dxf", None), Err(Refused::Network)); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 97f4b969..778b9696 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -13,6 +13,7 @@ pub mod audit; pub mod autosave; pub mod blockedit; pub mod cmd; +pub mod doc_path; mod finite; pub mod grip_dims; pub mod grips; diff --git a/crates/engine/tests/table_breaks.rs b/crates/engine/tests/table_breaks.rs index 90b05d7d..80aab078 100644 --- a/crates/engine/tests/table_breaks.rs +++ b/crates/engine/tests/table_breaks.rs @@ -125,3 +125,49 @@ fn data_links_fill_and_update() { assert!(table(&s, &h).data_link.is_none()); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn data_links_saved_in_a_drawing_are_read_beside_it_or_on_this_computer_only() { + cadcraft_engine::cmd::file::set_io(cadcraft_engine::cmd::file::IoHooks { + read: |b, name| cadcraft_io::read(b, name).map_err(|e| e.to_string()), + write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()), + plot: None, + }); + let base = std::env::temp_dir().join(format!("cadcraft-datalink-saved-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let (dir, elsewhere) = (base.join("plans"), base.join("elsewhere")); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::create_dir_all(&elsewhere).unwrap(); + std::fs::write(dir.join("parts.csv"), "Part,Qty\nbolt,4\n").unwrap(); + std::fs::write(elsewhere.join("far.csv"), "Part,Qty\nnut,8\n").unwrap(); + + let mut links = vec![r"\\192.0.2.1\share\far.csv".to_string(), "file://192.0.2.1/s/far.csv".to_string(), "parts.csv".to_string()]; + if cfg!(unix) { + // Read as a local path by Linux, and as a network path by the resolver. + links.push(format!("/{}", elsewhere.join("far.csv").display())); + } + let mut s = Session::new(); + for (i, link) in links.iter().enumerate() { + let r = s.execute("table", &json!({ "at": [0, 10.0 * i as f64], "cols": 2, "rows": 1 })).unwrap(); + let h = Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap(); + s.doc_mut() + .unwrap() + .modify_entity(h, |e| { + if let EntityKind::Table(t) = &mut e.kind { + t.data_link = Some(cadcraft_engine::doc::DataLink { path: link.clone(), start_row: 0 }); + } + }) + .unwrap(); + } + let host = dir.join("host.dxf"); + s.execute("saveas", &json!({ "path": host.to_str().unwrap() })).unwrap(); + let mut s = Session::new(); + s.execute("open", &json!({ "path": host.to_str().unwrap() })).unwrap(); + + let r = s.execute("datalinkupdate", &json!({})).unwrap(); + assert_eq!(r["updated"], 1, "only the file beside the drawing: {r}"); + let errors: Vec<&str> = r["errors"].as_array().unwrap().iter().filter_map(|e| e.as_str()).collect(); + assert_eq!(errors.len(), links.len() - 1, "{errors:?}"); + assert!(errors.iter().all(|e| e.contains("another computer")), "{errors:?}"); + let _ = std::fs::remove_dir_all(&base); +} diff --git a/crates/engine/tests/xref.rs b/crates/engine/tests/xref.rs index d50fdc46..405f71c6 100644 --- a/crates/engine/tests/xref.rs +++ b/crates/engine/tests/xref.rs @@ -435,3 +435,72 @@ fn hostile_xref_input_never_panics() { let _ = s.execute("xopen", &json!({ "name": "nothing" })); let _ = s.execute("xopen", &json!({})); } + +/// A drawing with xrefs `(name, saved path)`, each placed at 0,0. +fn host_with_paths(xrefs: &[(&str, &str)]) -> Drawing { + let mut d = Drawing::new_metric(); + for (name, path) in xrefs { + let mut b = Block::new(name); + b.xref_path = Some(path.to_string()); + d.blocks.insert(name.to_string(), Arc::new(b)); + let h = d.new_handle(); + d.model.push(Entity::new(h, insert(name, 0.0))); + } + d +} + +#[test] +fn saved_xref_paths_are_followed_beside_the_host_or_on_this_computer_only() { + hooks(); + let base = folder("saved-paths"); + let (dir, elsewhere) = (base.join("plans"), base.join("elsewhere")); + std::fs::create_dir_all(dir.join("parts")).unwrap(); + std::fs::create_dir_all(&elsewhere).unwrap(); + save(&site(), &elsewhere.join("far.dxf")); + // A part beside the host that references drawings the same ways. + let mut nested = vec![("unc", r"\\192.0.2.1\share\far.dxf".to_string())]; + // Read as a local path by Linux, and as a network path by the resolver. + let far_slashes = format!("/{}", elsewhere.join("far.dxf").display()); + if cfg!(unix) { + nested.push(("slashes", far_slashes.clone())); + } + let mut part = host_with_paths(&nested.iter().map(|(n, p)| (*n, p.as_str())).collect::>()); + line(&mut part, "0", (0.0, 0.0), (1.0, 1.0)); + save(&part, &dir.join("parts").join("part.dxf")); + + let mut xrefs = vec![ + ("unc", r"\\192.0.2.1\share\far.dxf".to_string()), + ("url", "file://192.0.2.1/s/far.dxf".to_string()), + ("part", "parts/part.dxf".to_string()), + ]; + if cfg!(unix) { + xrefs.push(("slashes", far_slashes.clone())); + } + let host = dir.join("host.dxf"); + save(&host_with_paths(&xrefs.iter().map(|(n, p)| (*n, p.as_str())).collect::>()), &host); + let mut s = Session::new(); + open(&mut s, &host); + + assert_eq!(status(&s, "part"), Some(XrefStatus::Loaded), "a relative path beside the host: {:?}", s.log); + assert_eq!(objects(&s, "part"), 1 + nested.len(), "the part's line and references, with nothing loaded into them"); + for name in ["unc", "url", "slashes"].into_iter().take(xrefs.len() - 1) { + assert_eq!((status(&s, name), objects(&s, name)), (Some(XrefStatus::Unresolved), 0), "{name}: {:?}", s.log); + assert!(s.log.iter().any(|m| m.contains(&format!("\"{name}\" not loaded")) && m.contains("another computer")), "{name}: {:?}", s.log); + } + assert!(s.log.iter().any(|m| m.contains("nested xref \"unc\" not loaded") && m.contains("another computer")), "{:?}", s.log); + if cfg!(unix) { + assert!(s.log.iter().any(|m| m.contains("nested xref \"slashes\" not loaded")), "{:?}", s.log); + } + let r = s.execute("-xref", &json!({ "option": "?" })).unwrap(); + let found: Vec<(&str, bool)> = r["xrefs"].as_array().unwrap().iter().map(|x| (x["name"].as_str().unwrap(), x["foundAt"].is_string())).collect(); + assert!(found.iter().all(|(n, f)| *f == (*n == "part")), "{found:?}"); + assert!(s.execute("xopen", &json!({ "name": "unc" })).unwrap_err().to_string().contains("another computer")); + // Reloading follows the saved path the same way. + s.execute("-xref", &json!({ "option": "reload", "names": "*" })).unwrap(); + assert_eq!(status(&s, "unc"), Some(XrefStatus::Unresolved)); + + // A path the user gives is used as given. + let far = elsewhere.join("far.dxf"); + s.execute("-xref", &json!({ "option": "path", "names": "unc", "path": far.to_str().unwrap() })).unwrap(); + assert_eq!(status(&s, "unc"), Some(XrefStatus::Loaded)); +}