From 739e442824ff318b052d4a49c570ef0eb90d3521 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Mon, 26 Oct 2026 18:52:43 -0400 Subject: [PATCH] Links: ask before following a design's network paths (#7372) A path a design names (a linked design, a sketch text's font file) now goes through one rule before any file call: a network location is reached only once its server is allowed, a device path never. Update Links and Open Linked Design ask first (Allow once / Always allow this server / Cancel); Preferences > Design lists the allowed servers and can allow every network location. component.links reports such a link's found as unknown with a reason, without looking for it. Co-authored-by: Claude Opus 5.5 --- assets/locales/pending/network-locations.json | 32 ++ crates/engine/src/cmd/component_link.rs | 154 ++++++++- crates/engine/src/lib.rs | 2 + crates/io/src/vfs.rs | 310 +++++++++++++++++- crates/ui-egui/src/context_menu.rs | 11 +- crates/ui-egui/src/edit_clipboard.rs | 7 +- crates/ui-egui/src/file_load.rs | 21 +- crates/ui-egui/src/file_load_ui_tests.rs | 77 +++++ crates/ui-egui/src/lib.rs | 17 +- crates/ui-egui/src/linked_source.rs | 6 +- crates/ui-egui/src/network_prompt.rs | 142 ++++++++ crates/ui-egui/src/prefs.rs | 15 + crates/ui-egui/src/prefs/design.rs | 17 + crates/ui-egui/src/value_step_tests.rs | 16 +- 14 files changed, 792 insertions(+), 35 deletions(-) create mode 100644 assets/locales/pending/network-locations.json create mode 100644 crates/ui-egui/src/network_prompt.rs diff --git a/assets/locales/pending/network-locations.json b/assets/locales/pending/network-locations.json new file mode 100644 index 0000000000..4619760e5f --- /dev/null +++ b/assets/locales/pending/network-locations.json @@ -0,0 +1,32 @@ +{ + "en": { + "network.allow_once": "Allow once", + "network.always_allow": "Always allow this server", + "network.prompt_detail": "This design links to a file on network location {host}. Opening it connects to that computer and may send your Windows sign-in to it. Only allow servers you trust.", + "network.prompt_title": "Network location", + "prefs.network_host_remove": "Remove", + "prefs.network_locations": "Allow network locations in designs", + "prefs.network_locations_note": "Off: a design's linked files on another computer (\\\\server\\share) are opened only from the servers listed here, or after you allow one when asked.", + "prefs.network_locations_section": "Network locations" + }, + "ja": { + "network.allow_once": "今回のみ許可", + "network.always_allow": "このサーバーを常に許可", + "network.prompt_detail": "このデザインはネットワーク上の場所 {host} にあるファイルにリンクしています。開くとそのコンピューターに接続し、Windows のサインイン情報が送信される場合があります。信頼できるサーバーだけを許可してください。", + "network.prompt_title": "ネットワーク上の場所", + "prefs.network_host_remove": "削除", + "prefs.network_locations": "デザイン内のネットワーク上の場所を許可", + "prefs.network_locations_note": "オフ: 別のコンピューター (\\\\server\\share) にあるデザインのリンク先ファイルは、ここに表示されたサーバー、または確認時に許可したサーバーからのみ開きます。", + "prefs.network_locations_section": "ネットワーク上の場所" + }, + "zh": { + "network.allow_once": "仅允许一次", + "network.always_allow": "始终允许此服务器", + "network.prompt_detail": "此设计链接到网络位置 {host} 上的文件。打开它会连接到该计算机,并可能向其发送你的 Windows 登录信息。只允许你信任的服务器。", + "network.prompt_title": "网络位置", + "prefs.network_host_remove": "删除", + "prefs.network_locations": "允许设计中的网络位置", + "prefs.network_locations_note": "关闭:设计中位于其他计算机 (\\\\server\\share) 上的链接文件,只会从此处列出的服务器打开,或在询问时你允许的服务器打开。", + "prefs.network_locations_section": "网络位置" + } +} diff --git a/crates/engine/src/cmd/component_link.rs b/crates/engine/src/cmd/component_link.rs index ed14937b82..7fd4cb5fc6 100644 --- a/crates/engine/src/cmd/component_link.rs +++ b/crates/engine/src/cmd/component_link.rs @@ -54,10 +54,10 @@ pub static COMMANDS: &[CommandSpec] = &[ (its link here, else a new linked component) → each component's history goes and its placements become placements of the linked \ component; refused when its bodies are not the same as the link's; returns component, name, replaced and warnings", ), - CommandSpec::new("component.links", "List Links", list_links) - .icon("chain") - .noundo() - .params("→ linked components: component, name, path, file (resolved), found"), + CommandSpec::new("component.links", "List Links", list_links).icon("chain").noundo().params( + "→ linked components: component, name, path, file (resolved), found (null when the file is on a network location not allowed, \ + or names a device: it is not looked for), and then host (the network location) and reason", + ), ]; /// The folder relative link paths are kept against: the design's own, once it has a file. @@ -119,6 +119,21 @@ fn resolve(design: Option<&str>, link: &str) -> PathBuf { } } +/// Why the file a stored link names may not be reached, from its text alone (`None`: it may): a +/// device, or a network location not allowed (`vfs::design_path_refusal`). Both the link as +/// stored and the file it resolves to are judged, before anything asks the disk or the network. +fn link_refusal(design: Option<&str>, link: &str) -> Option { + let file = resolve(design, link); + solidcraft_io::vfs::design_path_refusal(link, design).or_else(|| solidcraft_io::vfs::design_path_refusal(&file.to_string_lossy(), design)) +} + +/// The error for a link [`link_refusal`] refuses. +fn link_refused(design: Option<&str>, link: &str) -> Result<()> { + solidcraft_io::vfs::check_design_path(link, design).map_err(|e| EngineError::Other(e.to_string()))?; + let file = resolve(design, link); + solidcraft_io::vfs::check_design_path(&file.to_string_lossy(), design).map_err(|e| EngineError::Other(e.to_string())) +} + /// The links of `doc` stored for a design saved at `to` instead of at `from`. pub(super) fn rebased(doc: &Document, from: Option<&str>, to: &str) -> std::collections::BTreeMap { doc.links.iter().map(|(c, l)| (*c, DesignLink { path: stored(Some(to), &resolve(from, &l.path)), feature: l.feature })).collect() @@ -142,7 +157,12 @@ fn linked_bodies(file: &Path, own: Option<&str>) -> Result { if own.is_some_and(|o| absolute(Path::new(o)) == absolute(file)) { return Err(EngineError::Other(format!("{shown}: a design can't be linked into itself"))); } - let bytes = solidcraft_io::vfs::read(&shown, solidcraft_io::MAX_DESIGN_BYTES).map_err(|e| EngineError::Other(format!("{shown}: {e}")))?; + // A network location only when its server is allowed, a device never: judged from the text + // before anything is read (on Windows, reading `\\host\share\x` connects to the host). + let bytes = solidcraft_io::vfs::read_design_path(&shown, own, solidcraft_io::MAX_DESIGN_BYTES).map_err(|e| { + let e = e.to_string(); + EngineError::Other(if e.starts_with(&shown) { e } else { format!("{shown}: {e}") }) + })?; let doc = solidcraft_io::read_design(&bytes).map_err(|e| EngineError::Other(format!("{shown}: {e}")))?; session_bodies(&Session::opened_at(doc, None, Some(&shown)), &shown) } @@ -186,6 +206,8 @@ fn insert_design(s: &mut Session, p: &Value) -> Result { None => solidcraft_geom::Vec3::ZERO, Some(v) => vec3(v).filter(|t| t.is_finite() && t.len() < 1e9).ok_or_else(|| bad(cmd, "`translate` must be [x, y, z] in mm"))?, }; + // A network or device path is judged from its text, as given, before anything reads it. + link_refused(s.path.as_deref(), given).map_err(|e| bad(cmd, e.to_string()))?; let file = absolute(Path::new(given)); if s.path.as_deref().is_some_and(|own| absolute(Path::new(own)) == file) { return Err(bad(cmd, "a design can't be linked into itself")); @@ -263,6 +285,7 @@ fn update_links(s: &mut Session, p: &Value) -> Result { )); }; let old = old.clone(); + link_refused(s.path.as_deref(), &link.path).map_err(|e| bad(cmd, format!("{cname}: {e}")))?; let file = resolve(s.path.as_deref(), &link.path); let (_, sf, faces, _) = linked_bodies(&file, s.path.as_deref()).map_err(|e| bad(cmd, format!("{cname}: {e}")))?; warnings.extend(sf.warnings.iter().cloned()); @@ -334,6 +357,8 @@ fn relink(s: &mut Session, p: &Value) -> Result { if !file.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("solidcraft")) { return Err(bad(cmd, "choose a saved .solidcraft design")); } + // A network or device path is judged from its text before the disk is asked anything. + link_refused(s.path.as_deref(), given).map_err(|e| bad(cmd, e.to_string()))?; // The ordinary lexical check in linked_bodies also works in the browser VFS. On native // hosts, reject an alias of this assembly too (a symlink or a path with different case). if let (Ok(candidate), Some(Ok(own))) = (file.canonicalize(), s.path.as_deref().map(std::fs::canonicalize)) @@ -401,7 +426,16 @@ fn list_links(s: &mut Session, _p: &Value) -> Result { let name = s.doc.components.iter().find(|x| x.id == *c).map(|x| x.name.clone()); let path = stored(s.path.as_deref(), &file); let file = file.to_string_lossy(); - json!({"component": c, "name": name, "path": path, "file": file, "found": solidcraft_io::vfs::is_file(&file)}) + // A refused path is not looked for at all: on Windows asking whether a file on + // `\\host\share` exists already connects to the host. + match link_refusal(s.path.as_deref(), &l.path) { + None => json!({"component": c, "name": name, "path": path, "file": file, + "found": solidcraft_io::vfs::design_path_is_file(&file, s.path.as_deref())}), + Some(solidcraft_io::vfs::Remote::Network(host)) => json!({"component": c, "name": name, "path": path, "file": file, + "found": null, "host": host, "reason": format!("on the network location \\\\{host}, which is not allowed")}), + Some(solidcraft_io::vfs::Remote::Device) => json!({"component": c, "name": name, "path": path, "file": file, + "found": null, "reason": "names a device, not a file"}), + } }) .collect(); Ok(json!({"links": links})) @@ -752,4 +786,112 @@ mod tests { assert!(update["warnings"].to_string().contains("joint PinInHole refers to faces of Plate by their place"), "{update}"); let _ = std::fs::remove_dir_all(&dir); } + + /// A design someone sends may name a linked file on another computer (`\\host\share\…`) or a + /// device. On Windows, reading it or only asking whether it exists connects to that computer + /// and may send it the user's sign-in. Update Links and List Links judge the stored path from + /// its text and touch nothing: List Links reports it unknown with a reason, Update Links + /// refuses with the server's name, and nothing in the design changes. + #[test] + fn links_to_network_locations_or_devices_touch_nothing_until_allowed() { + let dir = std::env::temp_dir().join(format!("solidcraft-link-network-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("parts")).unwrap(); + let part = dir.join("parts").join("plate.solidcraft"); + save_box(&part, 30.0); + let case = dir.join("case.solidcraft"); + let mut a = Session::default(); + let comp = run(&mut a, "component.insert_design", json!({"path": part}))["component"].as_u64().unwrap(); + run(&mut a, "file.save", json!({"path": case})); + let feature = a.doc.links[&comp].feature; + // On Unix these names are plain files beside the design, so a check that was skipped would + // find them: the refusal below comes from the text alone. + let crafted = [r"\\evil.example\share\plate.solidcraft", "//evil.example/share/plate.solidcraft", r"\\?\UNC\evil.example\share\p.solidcraft"]; + if cfg!(unix) { + std::fs::copy(&part, dir.join(crafted[0])).unwrap(); + } + let devices = [r"\\.\pipe\plate.solidcraft", r"\\.\COM1", "parts/NUL.solidcraft", "parts/com1.solidcraft", r"parts\CON.part.solidcraft"]; + std::fs::copy(&part, dir.join("parts").join("NUL.solidcraft")).unwrap(); + let before = volume(&mut a); + for link in crafted.iter().chain(&devices) { + a.doc_mut().links.insert(comp, DesignLink { path: link.to_string(), feature }); + let calls = solidcraft_io::vfs::file_calls(); + let listed = run(&mut a, "component.links", json!({})); + let entry = &listed["links"][0]; + assert!(entry["found"].is_null() && entry["reason"].is_string(), "{link}: {listed}"); + let err = a.execute("component.update_links", &json!({})).unwrap_err().to_string(); + assert_eq!(solidcraft_io::vfs::file_calls(), calls, "{link}: a file call was made"); + if devices.contains(link) { + assert!(entry["host"].is_null() && err.contains("names a device"), "{link}: {listed} {err}"); + } else { + assert_eq!(entry["host"], "evil.example", "{link}: {listed}"); + assert!(err.contains(r"network location \\evil.example"), "{link}: {err}"); + } + assert_eq!(volume(&mut a), before, "{link}: the design changed"); + } + // Allowed (Allow once, a preference, …), the same link is read as any other. + #[cfg(unix)] + { + a.doc_mut().links.insert(comp, DesignLink { path: crafted[0].to_string(), feature }); + solidcraft_io::vfs::allowing_hosts(&["EVIL.example".to_string()], || { + let listed = run(&mut a, "component.links", json!({})); + assert_eq!(listed["links"][0]["found"], true, "{listed}"); + assert_eq!(run(&mut a, "component.update_links", json!({}))["unchanged"], json!(["plate"])); + }); + assert!(run(&mut a, "component.links", json!({}))["links"][0]["found"].is_null(), "allowed for that action only"); + } + // A local link, relative or absolute, is found and read as before. + for local in ["parts/plate.solidcraft".to_string(), part.to_string_lossy().to_string()] { + a.doc_mut().links.insert(comp, DesignLink { path: local.clone(), feature }); + assert_eq!(run(&mut a, "component.links", json!({}))["links"][0]["found"], true, "{local}"); + assert_eq!(run(&mut a, "component.update_links", json!({}))["unchanged"], json!(["plate"]), "{local}"); + } + // Relink to a network path (programmatic: no prompt) is refused before the disk is asked. + let calls = solidcraft_io::vfs::file_calls(); + let err = a.execute("component.relink", &json!({"component": comp, "path": r"\\evil.example\share\x.solidcraft"})).unwrap_err().to_string(); + assert!(err.contains("network location"), "{err}"); + assert_eq!(solidcraft_io::vfs::file_calls(), calls); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Every place that reaches a file through a link path goes through the design-path rule + /// (`vfs::check_design_path` / `read_design_path` / `design_path_is_file` / `link_refusal`): + /// no plain file call on a link's path in the link commands, and the font file a design's text + /// names is checked before it is read. + #[test] + fn every_link_path_goes_through_the_design_path_rule() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let code = |file: &str| { + let text = std::fs::read_to_string(root.join(file)).unwrap(); + // The code before its test module. + text.split("\n#[cfg(test)]\n#[allow(clippy::unwrap_used").next().unwrap_or("").to_string() + }; + for file in ["src/cmd/component_link.rs", "src/cmd/component_extract.rs"] { + let text = code(file); + for raw in [ + "vfs::read(", + "vfs::read_to_string(", + "vfs::is_file(", + "vfs::exists(", + "vfs::len(", + "std::fs::read", + "std::fs::metadata", + ".is_file()", + ".exists()", + ".metadata()", + ] { + assert!(!text.contains(raw), "{file}: `{raw}` on a link's path must go through vfs::check_design_path"); + } + // Relink canonicalises the path it is given, after the rule. + if let Some(at) = text.find("canonicalize") { + let rule = text[..at].rfind("link_refused(s.path.as_deref(), given)").unwrap_or(usize::MAX); + assert!(rule < at, "{file}: canonicalize before check_design_path"); + } + } + let fonts = std::fs::read_to_string(root.join("../sketch/src/fonts.rs")).unwrap(); + let (check, read) = (fonts.find("FILE_CHECK.get()").unwrap(), fonts.find("read_font(&path)").unwrap()); + assert!(check < read, "a font file named by a design is checked before it is read"); + let io = std::fs::read_to_string(root.join("../io/src/vfs.rs")).unwrap(); + assert!(io.contains("set_font_file_check(|p| check_design_path("), "the font check is the design-path rule"); + } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7ae0f0c627..7cf4d4898d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -508,6 +508,8 @@ impl Session { /// Use [`Self::opened_at`] when the document's file location is known. /// `SOLIDCRAFT_NO_MODEL_CACHE` set always evaluates. pub fn opened(mut doc: Document, saved: Option<&solidcraft_doc::SavedModel>) -> Self { + // A design's font files are checked like its other paths before it is evaluated. + solidcraft_io::vfs::install_checks(); // The session keeps what is hidden (an undo step of its own); the design file carries it. let visibility = std::mem::take(&mut doc.visibility); let doc = Arc::new(doc); diff --git a/crates/io/src/vfs.rs b/crates/io/src/vfs.rs index 471927a406..526c1e5f25 100644 --- a/crates/io/src/vfs.rs +++ b/crates/io/src/vfs.rs @@ -15,6 +15,7 @@ mod imp { /// more than the caller's cap plus the byte that proves it was exceeded. pub fn read(path: &str, max: usize) -> Result> { check(path)?; + touched(); // Cheap and non-blocking: refuse before opening, in case the open itself would block. let meta = std::fs::metadata(path)?; if !meta.is_file() { @@ -37,26 +38,36 @@ mod imp { } pub fn write(path: &str, bytes: &[u8]) -> Result<()> { check(path)?; + touched(); std::fs::write(path, bytes) } pub fn len(path: &str) -> Result { check(path)?; + touched(); std::fs::metadata(path).map(|m| m.len()) } pub fn exists(path: &str) -> bool { - check(path).is_ok() && std::path::Path::new(path).exists() + check(path).is_ok() && { + touched(); + std::path::Path::new(path).exists() + } } /// Whether the path names a regular file the caller may access. pub fn is_file(path: &str) -> bool { - check(path).is_ok() && std::path::Path::new(path).is_file() + check(path).is_ok() && { + touched(); + std::path::Path::new(path).is_file() + } } pub fn remove(path: &str) -> Result<()> { check(path)?; + touched(); std::fs::remove_file(path) } /// A folder and the folders above it, where missing. pub fn create_dir_all(path: &str) -> Result<()> { check(path)?; + touched(); std::fs::create_dir_all(path) } } @@ -249,6 +260,7 @@ impl PathScope { { return Err(windows(why)); } + touched(); let resolved = match resolve(std::path::Path::new(path)) { Ok(r) => r, Err(e) if e.kind() == ErrorKind::NotFound => return Err(self.missing_folder(path).unwrap_or_else(refused)), @@ -350,16 +362,196 @@ pub fn windows_refusal(path: &str) -> Option<&'static str> { if short_name(name) { return Some("short (8.3) names such as `PROGRA~1`"); } - let stem = name.split('.').next().unwrap_or("").trim_end_matches([' ', '.']).to_uppercase(); - let numbered = - |p: &str| stem.strip_prefix(p).is_some_and(|n| matches!(n, "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³")); - if matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$") || numbered("COM") || numbered("LPT") { + if device_name(name) { return Some("reserved device names (CON, NUL, COM1, …)"); } } None } +/// Whether a file or folder name is one Windows keeps for a device (`NUL`, `com1.txt`, `Con .x`). +fn device_name(name: &str) -> bool { + let stem = name.split('.').next().unwrap_or("").trim_end_matches([' ', '.']).to_uppercase(); + let numbered = + |p: &str| stem.strip_prefix(p).is_some_and(|n| matches!(n, "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³")); + matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$") || numbered("COM") || numbered("LPT") +} + +/// Where a path would lead other than to a file on this computer, judged from its text by Windows +/// rules on every platform (a design made on one system is opened on another). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Remote { + /// A file on another computer (`\\host\share\…`, `\\?\UNC\host\…`): the host as written. + Network(String), + /// A device or NT namespace path (`\\.\pipe\x`, `\\?\Volume{…}`, `\??\…`) or a reserved + /// device name (`NUL`, `COM1.txt`). + Device, +} + +/// Where `path` leads when it is not to a file on this computer (`None`: a local path, absolute +/// or relative). Text only: nothing on disk or the network is asked. +pub fn remote(path: &str) -> Option { + let norm = path.trim().replace('/', "\\"); + let host = |rest: &str| -> Remote { + let h = rest.split('\\').next().unwrap_or("").trim(); + if h.is_empty() || h == "." || h == "?" { Remote::Device } else { Remote::Network(h.to_string()) } + }; + let starts = |p: &str| norm.get(..p.len()).is_some_and(|s| s.eq_ignore_ascii_case(p)); + let body = if starts("\\\\?\\UNC\\") || starts("\\??\\UNC\\") { + return Some(host(norm.get(8..).unwrap_or(""))); + } else if let Some(v) = norm.strip_prefix("\\\\?\\") { + if !matches!(v.as_bytes(), [d, b':', b'\\', ..] if d.is_ascii_alphabetic()) { + return Some(Remote::Device); + } + v + } else if norm.starts_with("\\\\.\\") || norm.starts_with("\\??\\") || norm == "\\\\." || norm == "\\\\?" { + return Some(Remote::Device); + } else if let Some(rest) = norm.strip_prefix("\\\\") { + return Some(host(rest)); + } else { + norm.as_str() + }; + body.split('\\').filter(|n| !n.is_empty()).any(device_name).then_some(Remote::Device) +} + +/// Which network hosts the paths a design names may lead to. Empty by default: a design that +/// names `\\host\share\part.solidcraft` reaches nothing on the network until the user allows that +/// host, since on Windows merely asking whether the file exists connects to the host and sends +/// it the user's sign-in. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct NetworkPolicy { + /// Every host (a managed organisation that keeps its designs on file servers). + pub allow_all: bool, + /// Hosts allowed by name (case does not matter; a leading `\\` is ignored). + pub hosts: Vec, +} + +impl NetworkPolicy { + const NONE: NetworkPolicy = NetworkPolicy { allow_all: false, hosts: Vec::new() }; + + /// Whether `host` is allowed. + pub fn allows(&self, host: &str) -> bool { + let host = host_key(host); + self.allow_all || (!host.is_empty() && self.hosts.iter().any(|h| host_key(h) == host)) + } +} + +/// A host name as compared: without `\\` around it, lower case. +fn host_key(host: &str) -> String { + host.trim().trim_matches('\\').trim_matches('/').to_ascii_lowercase() +} + +static NETWORK: std::sync::RwLock = std::sync::RwLock::new(NetworkPolicy::NONE); + +thread_local! { + static ONCE_HOSTS: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; + static FILE_CALLS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The hosts allowed for every design (the app's preferences). +pub fn set_network_policy(policy: NetworkPolicy) { + if let Ok(mut p) = NETWORK.write() + && *p != policy + { + *p = policy; + } +} + +/// The hosts allowed for every design. +pub fn network_policy() -> NetworkPolicy { + NETWORK.read().map(|p| p.clone()).unwrap_or_default() +} + +/// Run `f` with `hosts` also allowed on this thread: the user allowed them for this one action +/// ("Allow once"), or picked the file on them in a file dialog. +pub fn allowing_hosts(hosts: &[String], f: impl FnOnce() -> T) -> T { + struct Restore(usize); + impl Drop for Restore { + fn drop(&mut self) { + let n = self.0; + ONCE_HOSTS.with(|h| h.borrow_mut().truncate(n)); + } + } + let _restore = Restore(ONCE_HOSTS.with(|h| { + let mut h = h.borrow_mut(); + let n = h.len(); + h.extend(hosts.iter().cloned()); + n + })); + f() +} + +/// The network host of a path, when it names one (`\\host\share\x` → `host`). +pub fn network_host(path: &str) -> Option { + match remote(path) { + Some(Remote::Network(h)) => Some(h), + _ => None, + } +} + +/// Why a path a design names may not be reached, or `None` when it may: a device path never; a +/// network path when its host is allowed (the preferences, [`allowing_hosts`], or the host the +/// design itself was opened from, `design`, which the user already connected to). +pub fn design_path_refusal(path: &str, design: Option<&str>) -> Option { + let r = remote(path)?; + let Remote::Network(host) = &r else { return Some(r) }; + let own = design.and_then(network_host).is_some_and(|d| host_key(&d) == host_key(host)); + let once = || ONCE_HOSTS.with(|h| h.borrow().iter().any(|x| host_key(x) == host_key(host))); + if own || once() || network_policy().allows(host) { None } else { Some(r) } +} + +/// The error for a refused design path. +fn design_path_error(path: &str, why: &Remote) -> Error { + let text = match why { + Remote::Network(host) => format!( + "{path}: on the network location \\\\{host}, which designs may not reach until you allow that server \ + (Update Links or Open Linked Design asks; or Preferences › Design › Network locations)" + ), + Remote::Device => format!("{path}: names a device, not a file"), + }; + Error::new(ErrorKind::PermissionDenied, text) +} + +/// Check a path a design names (a linked design, a font file) before anything reads it, judged +/// from its text first: a network path whose host isn't allowed, or a device path, is refused +/// without a filesystem call. An agent's scope ([`check`]) applies on top. +pub fn check_design_path(path: &str, design: Option<&str>) -> Result<()> { + if let Some(why) = design_path_refusal(path, design) { + return Err(design_path_error(path, &why)); + } + check(path) +} + +/// [`read`] of a path a design names ([`check_design_path`] first). +pub fn read_design_path(path: &str, design: Option<&str>, max: usize) -> Result> { + check_design_path(path, design)?; + read(path, max) +} + +/// Whether a path a design names is a file; `None` (unknown) when [`check_design_path`] refuses +/// it, which asks nothing of the disk or the network. +pub fn design_path_is_file(path: &str, design: Option<&str>) -> Option { + design_path_refusal(path, design).is_none().then(|| is_file(path)) +} + +/// The checks the layers below ask before reading a file a design names (sketch text's font +/// files). Done once; every session and agent scope does it. +pub fn install_checks() { + solidcraft_doc::sketch::set_font_file_check(|p| check_design_path(&p.to_string_lossy(), None).map_err(|e| e.to_string())); +} + +/// File system calls made on this thread through this module (tests: a refused path made none). +#[doc(hidden)] +pub fn file_calls() -> usize { + FILE_CALLS.with(|c| c.get()) +} + +/// Count one file system call (native). +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +fn touched() { + FILE_CALLS.with(|c| c.set(c.get().saturating_add(1))); +} + /// Whether a file or folder name looks like a short (8.3) name: a `~` and a digit (`PROGRA~1`). fn short_name(name: &str) -> bool { name.as_bytes().windows(2).any(|w| w[0] == b'~' && w[1].is_ascii_digit()) @@ -424,7 +616,7 @@ pub fn scoped(scope: Option, f: impl FnOnce() -> T) -> T { } } // Sketch text reads the font files a design names, below this crate: it asks here first. - solidcraft_doc::sketch::set_font_file_check(|p| check(&p.to_string_lossy()).map_err(|e| e.to_string())); + install_checks(); let _restore = Restore(SCOPE.with(|s| s.replace(scope))); f() } @@ -795,6 +987,110 @@ mod tests { let _ = std::fs::remove_dir_all(&base); } + /// A path a design names (a linked design, a font file) that leads to another computer or a + /// device is refused from its text, on every platform, before any file call: on Windows even + /// asking whether `\\host\share\x` exists connects to the host and may send it the user's + /// sign-in. Local paths, absolute or relative, are unchanged. + #[test] + fn design_paths_on_network_locations_and_devices_are_refused_without_a_file_call() { + use super::{Remote, check_design_path, design_path_is_file, design_path_refusal, read_design_path, remote}; + let net = |h: &str| Some(Remote::Network(h.into())); + for (path, want) in [ + (r"\\server\share\part.solidcraft", net("server")), + ("//server/share/part.solidcraft", net("server")), + (r"\\Server.corp.example\share", net("Server.corp.example")), + (r"\\10.0.0.5\c$\x.solidcraft", net("10.0.0.5")), + (r"\\?\UNC\server\share\part.solidcraft", net("server")), + (r"\\?\unc\server\share\part.solidcraft", net("server")), + (r"\??\UNC\server\share\x", net("server")), + ("//?/UNC/server/share/x", net("server")), + (r"\\.\pipe\x", Some(Remote::Device)), + ("//./pipe/x", Some(Remote::Device)), + (r"\\.\COM1", Some(Remote::Device)), + (r"\\?\Volume{0}\x", Some(Remote::Device)), + (r"\\?\GLOBALROOT\Device\x", Some(Remote::Device)), + (r"\??\C:\x", Some(Remote::Device)), + (r"\\.", Some(Remote::Device)), + ("CON", Some(Remote::Device)), + ("nul", Some(Remote::Device)), + ("NUL.solidcraft", Some(Remote::Device)), + ("COM1", Some(Remote::Device)), + ("com1.txt.solidcraft", Some(Remote::Device)), + ("LPT9.ttf", Some(Remote::Device)), + (r"C:\work\aux.step", Some(Remote::Device)), + ("parts/Con .solidcraft", Some(Remote::Device)), + (r"\\?\C:\work\prn", Some(Remote::Device)), + // Local: unchanged. + (r"C:\work\part.solidcraft", None), + ("C:/work/part.solidcraft", None), + (r"\\?\C:\work\part.solidcraft", None), + ("/home/me/parts/plate.solidcraft", None), + ("parts/plate.solidcraft", None), + ("../parts/plate.solidcraft", None), + ("console.solidcraft", None), + (r"C:\com10\x.solidcraft", None), + ] { + assert_eq!(remote(path), want, "{path}"); + assert_eq!(design_path_refusal(path, None), want, "{path}"); + if want.is_some() { + let calls = super::file_calls(); + let e = check_design_path(path, None).unwrap_err(); + assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied, "{path}: {e}"); + assert!(read_design_path(path, None, 1 << 20).is_err(), "{path}"); + assert_eq!(design_path_is_file(path, None), None, "{path}"); + assert_eq!(super::file_calls(), calls, "{path}: refused, yet a file call was made"); + if let Some(Remote::Network(host)) = &want { + assert!(e.to_string().contains(&format!(r"network location \\{host}")), "{e}"); + } else { + assert!(e.to_string().contains("names a device"), "{e}"); + } + } + } + // A local file is read as before. + let dir = std::env::temp_dir().join(format!("solidcraft-vfs-design-path-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + let p = dir.join("part.solidcraft").to_string_lossy().to_string(); + super::write(&p, b"x").unwrap(); + assert_eq!(read_design_path(&p, None, 16).unwrap(), b"x"); + assert_eq!(design_path_is_file(&p, None), Some(true)); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A server is reached once allowed: for one action ([`allowing_hosts`], "Allow once"), by + /// the preferences ([`NetworkPolicy`]) or because the design itself was opened from it. A + /// device path never is. + #[test] + fn an_allowed_network_host_passes_and_only_that_one() { + use super::{NetworkPolicy, Remote, allowing_hosts, design_path_refusal}; + let unc = r"\\files.corp\cad\part.solidcraft"; + assert!(design_path_refusal(unc, None).is_some()); + allowing_hosts(&["FILES.corp".into()], || { + assert_eq!(design_path_refusal(unc, None), None); + assert_eq!(design_path_refusal(r"\\?\UNC\files.corp\cad\x", None), None); + assert_eq!(design_path_refusal(r"\\evil\share\x", None), Some(Remote::Network("evil".into()))); + assert_eq!(design_path_refusal(r"\\.\pipe\x", None), Some(Remote::Device)); + }); + assert!(design_path_refusal(unc, None).is_some(), "allowed for that action only"); + // The design was opened from that server: its links beside it are reached. + assert_eq!(design_path_refusal(unc, Some(r"\\Files.Corp\cad\asm.solidcraft")), None); + assert!(design_path_refusal(unc, Some(r"\\other\cad\asm.solidcraft")).is_some()); + assert!(design_path_refusal(unc, Some("/home/me/asm.solidcraft")).is_some()); + let listed = NetworkPolicy { allow_all: false, hosts: vec![r"\\Files.corp".into()] }; + assert!(listed.allows("files.corp") && !listed.allows("evil") && !listed.allows("")); + assert!(NetworkPolicy { allow_all: true, hosts: vec![] }.allows("anything")); + assert!(!NetworkPolicy::default().allows("files.corp"), "empty by default"); + } + + /// The font file a design's sketch text names follows the same rule. + #[test] + fn a_design_font_file_on_a_network_location_is_refused() { + super::install_checks(); + let calls = super::file_calls(); + let e = solidcraft_doc::sketch::font_data(Some(r"\\evil.example\fonts\x.ttf")).err().map(|e| e.to_string()).unwrap_or_default(); + assert!(e.contains(r"network location \\evil.example"), "{e}"); + assert_eq!(super::file_calls(), calls); + } + /// A directory is not a file either. #[cfg(not(target_arch = "wasm32"))] #[test] diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index b7ea086132..f58e8f8d70 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -1315,15 +1315,8 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) { app.set_status("This component is no longer linked to a design", true); return; }; - let Some(file) = link["file"].as_str() else { - app.set_status("The linked design has no file path", true); - return; - }; - if link["found"] != true { - app.set_status(format!("Linked design not found: {file}"), true); - return; - } - app.open_path_async(file); + // A link on a network server not allowed yet asks first. + crate::network_prompt::open_linked(app, link); } "ui.moveOccurrence" => { if let Some(o) = id_of(&p, "occurrence") { diff --git a/crates/ui-egui/src/edit_clipboard.rs b/crates/ui-egui/src/edit_clipboard.rs index 6bbfd61d51..abcdfc8b8d 100644 --- a/crates/ui-egui/src/edit_clipboard.rs +++ b/crates/ui-egui/src/edit_clipboard.rs @@ -145,7 +145,12 @@ pub fn paste(app: &mut SolveApp, at: Option) -> bool { } if let Some(path) = app.linked_design_clipboard.clone() { let (parent_path, translate) = paste_placement(app, at); - let Ok(out) = app.run("component.insert_design", json!({"path": path, "reuse": true, "translate":translate})) else { return false }; + // The copied design is one the user opened: its server is the user's choice. + let hosts: Vec = solidcraft_engine::io::vfs::network_host(&path).into_iter().collect(); + let pasted = solidcraft_engine::io::vfs::allowing_hosts(&hosts, || { + app.run("component.insert_design", json!({"path": path, "reuse": true, "translate":translate})) + }); + let Ok(out) = pasted else { return false }; if let Some(id) = out.get("occurrence").and_then(Value::as_u64) { let mut movement = crate::browser::OccMove::new(id); movement.path = parent_path; diff --git a/crates/ui-egui/src/file_load.rs b/crates/ui-egui/src/file_load.rs index 90f023b13c..496f7e5c84 100644 --- a/crates/ui-egui/src/file_load.rs +++ b/crates/ui-egui/src/file_load.rs @@ -52,11 +52,20 @@ pub struct Request { /// What that caller asked of an open or import besides its path (`up`, `names`, `matrix`, /// `name`, ...): the command gets them as given and checks them as it does run directly. pub options: serde_json::Map, + /// Network servers the user allowed for this one request ("Allow once", `network_prompt`). + pub allow_hosts: Vec, } impl Request { pub fn new(path: &str, kind: Kind) -> Self { - Request { path: path.into(), kind, reply: None, scope: solidcraft_engine::io::vfs::current_scope(), options: Default::default() } + Request { + path: path.into(), + kind, + reply: None, + scope: solidcraft_engine::io::vfs::current_scope(), + options: Default::default(), + allow_hosts: Vec::new(), + } } fn insert(&self) -> bool { self.kind == Kind::Insert @@ -259,7 +268,7 @@ pub fn begin_request(app: &mut SolveApp, request: Request) -> Result<(), String> } Kind::UpdateLinks => { let (command, params) = request.command(); - app.run(command, params) + solidcraft_engine::io::vfs::allowing_hosts(&request.allow_hosts, || app.run(command, params)) } }; request.answer(r); @@ -350,7 +359,7 @@ pub fn control(app: &mut SolveApp, req: crate::control::ControlRequest) -> Optio options.remove("path"); options.retain(|_, v| !v.is_null()); } - let _ = begin_request(app, Request { path: path.into(), kind, reply: Some(req.reply.clone()), scope, options }); + let _ = begin_request(app, Request { path: path.into(), kind, reply: Some(req.reply.clone()), scope, options, allow_hosts: Vec::new() }); None } @@ -420,6 +429,7 @@ fn start(app: &mut SolveApp) { let (preview_tx, preview_rx) = std::sync::mpsc::channel(); let (command, params) = request.command(); let scope = request.scope.clone(); + let allow_hosts = request.allow_hosts.clone(); let thumbnail = request.kind == Kind::Open; // While a design rebuilds, a picture of what is built so far (a large design takes a while). let finished = Arc::new(std::sync::atomic::AtomicBool::new(false)); @@ -479,8 +489,11 @@ fn start(app: &mut SolveApp) { } })); } + // Servers allowed once hold for this request only, on this worker thread. let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - compute(scratch, command, ¶ms, scope, thumbnail, &worker_phase, &worker_progress) + solidcraft_engine::io::vfs::allowing_hosts(&allow_hosts, || { + compute(scratch, command, ¶ms, scope, thumbnail, &worker_phase, &worker_progress) + }) })) .unwrap_or_else(|_| Err("the file operation failed; the current design is unchanged".into())); worker_finished.store(true, Ordering::Relaxed); diff --git a/crates/ui-egui/src/file_load_ui_tests.rs b/crates/ui-egui/src/file_load_ui_tests.rs index c14b098e78..e38543c2c7 100644 --- a/crates/ui-egui/src/file_load_ui_tests.rs +++ b/crates/ui-egui/src/file_load_ui_tests.rs @@ -251,6 +251,83 @@ fn update_links_names_the_design_it_reads_while_the_window_draws() { let _ = std::fs::remove_dir_all(&dir); } +/// A design may link to a file on another computer (`\\host\share\…`); on Windows reading it, or +/// only asking whether it is there, connects to that computer and may send it the user's sign-in. +/// Update Links and Open Linked Design ask first, naming the server: Cancel reads nothing, Allow +/// once reads it for that action alone, Always allow this server keeps it in the preferences. +/// (Unix: the crafted link names a file beside the design, so nothing leaves this computer.) +#[cfg(unix)] +#[test] +fn update_links_and_open_linked_design_ask_before_a_network_location() { + use crate::network_prompt::{Answer, NetworkPrompt, Then, answer}; + let dir = scratch("network"); + let part = dir.join("plate.solidcraft"); + let case = dir.join("case.solidcraft"); + let crafted = r"\\evil.example\share\plate.solidcraft"; + let save_part = |height: f64| { + let mut s = Session::default(); + s.execute("solid.box", &json!({"length": 10, "width": 10, "height": height})).unwrap(); + s.execute("file.save", &json!({"path": part})).unwrap(); + // The same file under the crafted name, beside the design (a plain file name on Unix). + std::fs::copy(&part, format!("{}/{crafted}", dir.display())).unwrap(); + }; + let volume = |app: &mut SolveApp| app.session.execute("inspect.measure", &json!({})).unwrap()["total"]["volume_mm3"].as_f64().unwrap(); + save_part(5.0); + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + let comp = h.app.run("component.insert_design", json!({"path": part})).unwrap()["component"].as_u64().unwrap(); + h.app.run("file.save", json!({"path": case})).unwrap(); + let feature = h.app.session.doc.links[&comp].feature; + h.app.session.doc_mut().links.insert(comp, solidcraft_engine::doc::DesignLink { path: crafted.into(), feature }); + save_part(8.0); + let until_idle = |h: &mut crate::scenario::Harness| { + let began = std::time::Instant::now(); + while !h.app.file_load.idle() { + assert!(began.elapsed() < std::time::Duration::from_secs(120), "Update Links went on"); + h.frames(1); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + h.frames(3); + }; + + // Update Links asks, naming the server, and Cancel reads nothing. + let assembly = h.app.session.doc.clone(); + h.app.start("component.update_links"); + let asked = Some(NetworkPrompt { hosts: vec!["evil.example".into()], then: Then::UpdateLinks }); + assert_eq!(h.app.network_prompt, asked); + assert!(h.app.file_load.idle(), "nothing is read while it asks"); + h.frames(2); + assert!(h.widget("Always allow this server").is_some() && h.widget("Allow once").is_some(), "the question is shown"); + answer(&mut h.app, Answer::Cancel); + h.frames(2); + assert!(h.app.network_prompt.is_none() && h.app.file_load.idle()); + assert!(std::sync::Arc::ptr_eq(&h.app.session.doc, &assembly), "Cancel changes nothing"); + assert!((volume(&mut h.app) - 500.0).abs() < 1e-3); + + // Allow once: the link is read for that update alone. + h.app.start("component.update_links"); + answer(&mut h.app, Answer::Once); + until_idle(&mut h); + assert!((volume(&mut h.app) - 800.0).abs() < 1e-3, "{}", volume(&mut h.app)); + let links = h.app.session.execute("component.links", &json!({})).unwrap(); + assert!(links["links"][0]["found"].is_null(), "allowed once, not for good: {links}"); + assert!(h.app.preferences.network_hosts.is_empty()); + + // Open Linked Design asks the same; Always allow keeps the server and opens the file. + let link = links["links"][0].clone(); + crate::network_prompt::open_linked(&mut h.app, &link); + assert_eq!(h.app.network_prompt.as_ref().map(|p| p.hosts.clone()), Some(vec!["evil.example".to_string()])); + assert!(matches!(h.app.network_prompt.as_ref().map(|p| &p.then), Some(Then::OpenLinked(_)))); + assert!(h.app.file_load.idle(), "nothing is opened while it asks"); + answer(&mut h.app, Answer::Always); + assert_eq!(h.app.preferences.network_hosts, vec!["evil.example".to_string()]); + assert!(!h.app.file_load.idle(), "the linked design opens"); + until_idle(&mut h); + h.app.preferences.network_hosts.clear(); + crate::prefs::apply_network(&h.app.preferences); + let _ = std::fs::remove_dir_all(&dir); +} + /// A control-channel (MCP) open is answered when the file is in, with the synchronous /// command's reply, and the window draws frames while it waits. Commands sent meanwhile wait. #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bb9da47de4..164bf187b4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -179,6 +179,7 @@ mod fillet_spline_handle_tests; pub mod mouse_gesture; pub(crate) mod navigation; pub use navigation::WebWheelGestures; +pub mod network_prompt; pub mod open_elsewhere; pub(crate) mod open_meshing; pub mod params_dialog; @@ -512,6 +513,8 @@ pub struct SolveApp { pub lock_prompts: Vec, /// A Save waiting for "the file changed on disk" to be answered. pub disk_prompt: bool, + /// Update Links or Open Linked Design waiting for a network server to be allowed. + pub network_prompt: Option, /// Designs left by crashed apps, offered for recovery. pub recoverable: Vec, /// The bodies of a just-opened Y-up file, offered to be stood up (`stand_up`). @@ -673,6 +676,7 @@ impl SolveApp { locks: None, lock_prompts: Vec::new(), disk_prompt: false, + network_prompt: None, recoverable: Vec::new(), stand_up: None, stand_up_occurrence: None, @@ -903,6 +907,8 @@ impl SolveApp { self.ui.layout = p.layout; self.preferences = p; } + // Before a design given at start opens: its paths follow the network preferences. + prefs::apply_network(&self.preferences); // Saved before the theme was a preference: the Dark/Light flag says which. if v.get("preferences").and_then(|p| p.get("theme")).is_none() { self.preferences.theme = if self.ui.dark { "dark" } else { "light" }.into(); @@ -1221,8 +1227,9 @@ impl SolveApp { } if id == "component.update_links" && !self.session.doc.links.is_empty() { // Reading the linked designs again takes a while in a large assembly: off the window - // thread, the overlay naming the one being read (#5329). - let _ = file_load::begin_request(self, file_load::Request::new("", file_load::Kind::UpdateLinks)); + // thread, the overlay naming the one being read (#5329). A link on a network server + // not allowed yet asks first. + network_prompt::update_links(self); return; } if id == "component.insert_design" { @@ -1231,7 +1238,10 @@ impl SolveApp { self.pick_open(|app, path| { let Some(path) = path else { return }; if solidcraft_engine::io::is_design_path(&path) { - if app.run("component.insert_design", json!({ "path": path })).is_ok() { + // Picked in the file dialog: its server is the user's choice. + let hosts: Vec = solidcraft_engine::io::vfs::network_host(&path).into_iter().collect(); + let inserted = solidcraft_engine::io::vfs::allowing_hosts(&hosts, || app.run("component.insert_design", json!({ "path": path }))); + if inserted.is_ok() { app.fit_view(); } } else { @@ -2068,6 +2078,7 @@ impl SolveApp { open_meshing::show(self, ui.ctx()); documents::prompt(self, ui.ctx()); open_elsewhere::show(self, ui.ctx()); + network_prompt::show(self, ui.ctx()); update::show(self, ui.ctx()); install::show(self, ui.ctx()); // Settings › AI's Add agent dialog: Settings opens over Home too (#3483). diff --git a/crates/ui-egui/src/linked_source.rs b/crates/ui-egui/src/linked_source.rs index a090315685..6ea4c0b1c9 100644 --- a/crates/ui-egui/src/linked_source.rs +++ b/crates/ui-egui/src/linked_source.rs @@ -44,7 +44,11 @@ pub(crate) fn start(app: &mut SolveApp, component: u64) { app.set_status(app.tr_label("ui.relink_design_changed").to_string(), true); return; } - if let Ok(result) = app.run("component.relink", json!({"component": component, "path": path})) { + // Picked in the file dialog: its server is the user's choice. + let hosts: Vec = solidcraft_engine::io::vfs::network_host(&path).into_iter().collect(); + let relinked = + solidcraft_engine::io::vfs::allowing_hosts(&hosts, || app.run("component.relink", json!({"component": component, "path": path}))); + if let Ok(result) = relinked { let warnings: Vec<&str> = result["warnings"].as_array().into_iter().flatten().filter_map(|w| w.as_str()).collect(); if warnings.is_empty() { app.set_status(app.tr_label("ui.relink_done").to_string(), false); diff --git a/crates/ui-egui/src/network_prompt.rs b/crates/ui-egui/src/network_prompt.rs new file mode 100644 index 0000000000..8784a50c5c --- /dev/null +++ b/crates/ui-egui/src/network_prompt.rs @@ -0,0 +1,142 @@ +//! Files a design names on another computer: a linked design on `\\host\share`. On Windows, +//! reading such a file, or only asking whether it exists, connects to that computer and may send +//! it the user's Windows sign-in, so the engine refuses those paths until the server is allowed +//! (`vfs::design_path_refusal`). Update Links and Open Linked Design ask first, naming the +//! server: Allow once, Always allow this server (Preferences › Design), or Cancel. +//! +//! Only these user actions ask. Commands run programmatically or by an agent never prompt: they +//! get the engine's error, and `component.links` reports such a link's `found` as unknown. + +use serde_json::Value; + +use crate::SolveApp; +use crate::ui_kit; + +/// What runs once the servers are allowed. +#[derive(Clone, Debug, PartialEq)] +pub enum Then { + /// Update Links (`component.update_links`, off the window thread). + UpdateLinks, + /// Open Linked Design: open this file. + OpenLinked(String), +} + +/// A user action waiting for its network servers to be allowed. +#[derive(Clone, Debug, PartialEq)] +pub struct NetworkPrompt { + /// The servers, as the design names them. + pub hosts: Vec, + pub then: Then, +} + +/// The answer. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Answer { + Once, + Always, + Cancel, +} + +/// The servers `component.links` reported as not allowed (each once). +pub(crate) fn blocked_hosts<'a>(links: impl IntoIterator) -> Vec { + let mut hosts: Vec = Vec::new(); + for host in links.into_iter().filter(|l| l["found"].is_null()).filter_map(|l| l["host"].as_str()) { + if !hosts.iter().any(|h| h.eq_ignore_ascii_case(host)) { + hosts.push(host.to_string()); + } + } + hosts +} + +/// Update Links from the toolbar: ask first when a link is on a server not allowed yet. +pub(crate) fn update_links(app: &mut SolveApp) { + let hosts = match app.session.execute("component.links", &serde_json::json!({})) { + Ok(links) => blocked_hosts(links["links"].as_array().into_iter().flatten()), + Err(_) => Vec::new(), + }; + if hosts.is_empty() { + run(app, Vec::new(), Then::UpdateLinks); + } else { + app.network_prompt = Some(NetworkPrompt { hosts, then: Then::UpdateLinks }); + } +} + +/// Open Linked Design for a link `component.links` reported (`link`). +pub(crate) fn open_linked(app: &mut SolveApp, link: &Value) { + let Some(file) = link["file"].as_str() else { + app.set_status("The linked design has no file path", true); + return; + }; + if link["found"].is_null() { + match link["host"].as_str() { + Some(host) => app.network_prompt = Some(NetworkPrompt { hosts: vec![host.to_string()], then: Then::OpenLinked(file.to_string()) }), + None => app.set_status(format!("{file}: {}", link["reason"].as_str().unwrap_or("not a file")), true), + } + return; + } + if link["found"] != true { + app.set_status(format!("Linked design not found: {file}"), true); + return; + } + app.open_path_async(file); +} + +/// Run the action with `hosts` allowed for it alone. +fn run(app: &mut SolveApp, hosts: Vec, then: Then) { + match then { + Then::UpdateLinks => { + let mut request = crate::file_load::Request::new("", crate::file_load::Kind::UpdateLinks); + request.allow_hosts = hosts; + let _ = crate::file_load::begin_request(app, request); + } + // Opening a file reads only it, as File › Open does; the design it opens is checked anew. + Then::OpenLinked(file) => app.open_path_async(&file), + } +} + +/// Act on the answer to the waiting question. +pub fn answer(app: &mut SolveApp, answer: Answer) { + let Some(NetworkPrompt { hosts, then }) = app.network_prompt.take() else { return }; + match answer { + Answer::Cancel => {} + Answer::Once => run(app, hosts, then), + Answer::Always => { + for host in &hosts { + if !app.preferences.network_hosts.iter().any(|h| h.eq_ignore_ascii_case(host)) { + app.preferences.network_hosts.push(host.clone()); + } + } + crate::prefs::apply_network(&app.preferences); + run(app, hosts, then) + } + } +} + +/// The question, when one is waiting. +pub fn show(app: &mut SolveApp, ctx: &egui::Context) { + let Some(prompt) = app.network_prompt.as_ref() else { return }; + let _language = crate::i18n::scope(&app.preferences.language); + let hosts = prompt.hosts.iter().map(|h| format!("\\\\{h}")).collect::>().join(", "); + let detail = crate::i18n::format("network.prompt_detail", &[("host", &hosts)]); + let (closed, action) = ui_kit::Confirm::new(egui::Id::new("sc_network_prompt"), "status_warning", crate::i18n::tr("network.prompt_title")) + .tone(ui_kit::Tone::Warning) + .actions(crate::i18n::tr("network.allow_once"), crate::i18n::tr("ui.cancel"), Some(crate::i18n::tr("network.always_allow"))) + .selected(ui_kit::Action::Cancel) + .show(ctx, true, |ui| { + ui_kit::text(ui, &detail, ui_kit::Text::Report("network_prompt", false)); + }); + let mut chosen = match action { + ui_kit::Action::Ok => Some(Answer::Once), + ui_kit::Action::Apply => Some(Answer::Always), + ui_kit::Action::Cancel => Some(Answer::Cancel), + ui_kit::Action::None if closed => Some(Answer::Cancel), + ui_kit::Action::None => None, + }; + if ctx.input(|x| x.key_pressed(egui::Key::Escape)) { + chosen = Some(Answer::Cancel); + app.esc_handled = true; + } + if let Some(a) = chosen { + answer(app, a); + } +} diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index 4fe267542c..c1638fbd17 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -670,6 +670,12 @@ preference_settings! { /// drags it across the model and a ring that turns it. Off by default: the feature is then placed /// by its click only, as before, and its Position input takes faces only. placement_handles: bool = false, PAGE_DESIGN => check("Placement handles", "Handles on the preview of a Rest, Boss or Snap Fit: drag the marker to move it across the model, turn it by its ring"); + /// The files a design names (linked designs, font files) may be on any network location, for + /// organisations that keep their designs on file servers. Off (the default): only on the + /// servers in `network_hosts`, or one the user allows when asked (`network_prompt`). + network_locations: bool = false, PAGE_DESIGN => check_tr("prefs.network_locations"); + /// The servers the files a design names may be on ("Always allow this server"). + network_hosts: Vec = Vec::new(), PAGE_DESIGN; /// What a middle-button double-click in the view does: `home`, `fit` or `off`. middle_double_click: String = "home".into(), PAGE_NAVIGATION; /// Colour vision mode (#778): `off`, `protan`, `deutan` or `tritan`. @@ -1060,6 +1066,7 @@ fn defaults(app: &mut SolveApp, page: usize) { /// Settings that follow the preferences every frame: the theme when it follows the system, and /// the interface scale. pub fn apply(app: &mut SolveApp, ctx: &egui::Context) { + apply_network(&app.preferences); // The viewport's display quality (an unknown saved value draws at Normal). solidcraft_engine::doc::display_quality::set_current( solidcraft_engine::doc::display_quality::DisplayQuality::parse(&app.preferences.display_quality).unwrap_or_default(), @@ -1104,6 +1111,14 @@ pub fn apply(app: &mut SolveApp, ctx: &egui::Context) { } } +/// The network locations the files a design names may be on follow the preferences. +pub fn apply_network(p: &Prefs) { + solidcraft_engine::io::vfs::set_network_policy(solidcraft_engine::io::vfs::NetworkPolicy { + allow_all: p.network_locations, + hosts: p.network_hosts.clone(), + }); +} + /// A new, empty design takes the preferred units (not an undo step, not a change to save). /// Callers: a launch with no file, New on a design that is already empty, `file.new`, and the /// blank design left when the last tab closes. A design that already has features is unchanged. diff --git a/crates/ui-egui/src/prefs/design.rs b/crates/ui-egui/src/prefs/design.rs index 59a275822f..162cf1632e 100644 --- a/crates/ui-egui/src/prefs/design.rs +++ b/crates/ui-egui/src/prefs/design.rs @@ -78,4 +78,21 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) { ui_kit::settings_section(ui, "placement", app.tr("prefs.placement"), |ui| { setting::placement_handles(app, ui); }); + // Where the files a design names may be: never a network location the user hasn't allowed. + ui_kit::settings_section(ui, "network_locations", app.tr("prefs.network_locations_section"), |ui| { + setting::network_locations(app, ui); + ui_kit::text(ui, app.tr("prefs.network_locations_note"), ui_kit::Text::Hint("network_locations")); + let mut remove = None; + for (i, host) in app.preferences.network_hosts.iter().enumerate() { + ui.horizontal(|ui| { + ui_kit::text(ui, &format!("\\\\{host}"), ui_kit::Text::Hint("network_host")); + if ui_kit::button(ui, app.tr("prefs.network_host_remove"), ui_kit::Button::Small).clicked() { + remove = Some(i); + } + }); + } + if let Some(i) = remove.filter(|i| *i < app.preferences.network_hosts.len()) { + app.preferences.network_hosts.remove(i); + } + }); } diff --git a/crates/ui-egui/src/value_step_tests.rs b/crates/ui-egui/src/value_step_tests.rs index 5d651ed4fd..d95fa440dd 100644 --- a/crates/ui-egui/src/value_step_tests.rs +++ b/crates/ui-egui/src/value_step_tests.rs @@ -358,10 +358,18 @@ fn the_preference_is_on_the_design_page() { crate::prefs::set(&mut h.app, &json!({"step_values": false})).unwrap(); h.call("ui.prefs", json!({"open": "Design"})); h.frames(3); - // The row is below the page's fold: scroll the page down to it, as a user would. - let window = h.ctx.memory(|m| m.area_rect(egui::Id::new("sc_prefs"))).unwrap(); - h.call("ui.scroll", json!({"x": window.center().x, "y": window.center().y, "delta": -400.0})); - h.frames(30); + // The row can be below the page's fold: scroll the page to it, as a user would, a little at a + // time so the page's length doesn't matter. + for _ in 0..40 { + let window = h.ctx.memory(|m| m.area_rect(egui::Id::new("sc_prefs"))).unwrap(); + let row = crate::scenario::handle_at("prefs:step_values").expect("the Design page shows the row"); + if window.shrink(60.0).contains(row) { + break; + } + let delta = if row.y > window.center().y { -60.0 } else { 60.0 }; + h.call("ui.scroll", json!({"x": window.center().x, "y": window.center().y, "delta": delta})); + h.frames(10); + } h.step(&json!({"click": {"handle": "prefs:step_values"}})).unwrap(); h.frames(2); assert!(h.app.preferences.step_values, "the Design page's checkbox turns it on");