mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-07 20:41:50 +00:00
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:
@@ -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": "网络位置"
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, ¶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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user