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 <noreply@anthropic.com>
This commit is contained in:
bherbruck
2026-10-26 18:52:43 -04:00
committed by GitHub
parent 30f951ecf9
commit 739e442824
14 changed files with 792 additions and 35 deletions
@@ -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": "网络位置"
}
}
+148 -6
View File
@@ -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<solidcraft_io::vfs::Remote> {
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<u64, DesignLink> {
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<Linked> {
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<Value> {
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<Value> {
));
};
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<Value> {
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<Value> {
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");
}
}
+2
View File
@@ -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);
+303 -7
View File
@@ -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<Vec<u8>> {
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<u64> {
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<Remote> {
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<String>,
}
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<NetworkPolicy> = std::sync::RwLock::new(NetworkPolicy::NONE);
thread_local! {
static ONCE_HOSTS: std::cell::RefCell<Vec<String>> = const { std::cell::RefCell::new(Vec::new()) };
static FILE_CALLS: std::cell::Cell<usize> = 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<T>(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<String> {
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<Remote> {
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<Vec<u8>> {
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<bool> {
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<T>(scope: Option<PathScope>, 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]
+2 -9
View File
@@ -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") {
+6 -1
View File
@@ -145,7 +145,12 @@ pub fn paste(app: &mut SolveApp, at: Option<Pos2>) -> 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<String> = 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;
+17 -4
View File
@@ -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<String, Value>,
/// Network servers the user allowed for this one request ("Allow once", `network_prompt`).
pub allow_hosts: Vec<String>,
}
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, &params, scope, thumbnail, &worker_phase, &worker_progress)
solidcraft_engine::io::vfs::allowing_hosts(&allow_hosts, || {
compute(scratch, command, &params, 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);
+77
View File
@@ -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]
+14 -3
View File
@@ -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<open_elsewhere::LockPrompt>,
/// 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<network_prompt::NetworkPrompt>,
/// Designs left by crashed apps, offered for recovery.
pub recoverable: Vec<solidcraft_engine::recovery::Entry>,
/// 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<String> = 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).
+5 -1
View File
@@ -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<String> = 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);
+142
View File
@@ -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<String>,
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<Item = &'a Value>) -> Vec<String> {
let mut hosts: Vec<String> = 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<String>, 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::<Vec<_>>().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);
}
}
+15
View File
@@ -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<String> = 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.
+17
View File
@@ -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);
}
});
}
+12 -4
View File
@@ -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");