mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-24 10:56:51 -06:00
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.
This commit is contained in:
Generated
+5
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<Stri
|
||||
b.xref_path = Some(path.to_string());
|
||||
b.xref_overlay = overlay;
|
||||
d.blocks.insert(name.to_string(), Arc::new(b));
|
||||
let out = load::load(&mut d, name, host.as_deref());
|
||||
let out = load::load(&mut d, name, host.as_deref(), true);
|
||||
match out.first() {
|
||||
Some(o) if o.status == XrefStatus::Loaded => {}
|
||||
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<Value> {
|
||||
}
|
||||
"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::<Vec<_>>() }),
|
||||
@@ -271,14 +267,13 @@ fn run_dash_xref(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"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<Value> {
|
||||
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() }))
|
||||
}
|
||||
|
||||
|
||||
@@ -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<PathBuf>,
|
||||
objects: usize,
|
||||
files: usize,
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
bytes: u64,
|
||||
/// Messages about nested xrefs.
|
||||
notes: Vec<String>,
|
||||
}
|
||||
|
||||
/// The folder of the host drawing at `host` (a file path).
|
||||
fn folder(host: Option<&str>) -> Option<PathBuf> {
|
||||
let p = Path::new(host?).parent()?;
|
||||
fn folder(host: Option<&Path>) -> Option<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<PathBuf>, Option<Refused>) {
|
||||
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<PathBuf> {
|
||||
/// 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<PathBuf, Missing> {
|
||||
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<PathBuf> = {
|
||||
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<Drawing, String> {
|
||||
/// 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<Drawing, String> {
|
||||
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<Outcome> {
|
||||
let names: Vec<String> = d
|
||||
.blocks
|
||||
@@ -197,28 +262,44 @@ pub(crate) fn load_all(d: &mut Drawing, host: Option<&str>) -> Vec<Outcome> {
|
||||
.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<Outcome> {
|
||||
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<Outcome> {
|
||||
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<Outcome> {
|
||||
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<Outcome> {
|
||||
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<String> =
|
||||
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]
|
||||
|
||||
@@ -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<std::path::PathBuf> {
|
||||
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<String> {
|
||||
fn read_link(id: &str, file: &std::path::Path) -> Result<String> {
|
||||
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<String>]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<Value> {
|
||||
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<Value> {
|
||||
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()),
|
||||
}
|
||||
|
||||
@@ -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<PathBuf, Refused> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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::<Vec<_>>());
|
||||
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::<Vec<_>>()), &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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user