From b46ffd0059d949cf78cc13011c84bf195d9a44f4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 16:39:46 +0000 Subject: [PATCH] Links: Edit Original opens only regular files of a format VectorCraft reads A folder or app bundle, a link to another kind of file, or a file with any other extension is refused with a message instead of being handed to the system's default app. On Windows, a path containing a quotation mark is refused before it reaches Explorer. --- apps/vectorcraft/src/main.rs | 8 ++++++ crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/panels/links.rs | 26 ++++++++++++++++- crates/ui-egui/src/panels/tests_links.rs | 36 ++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 2 deletions(-) diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index bfa0f3a49..c719a001a 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -386,6 +386,7 @@ fn pick_now(request: PickRequest, _parent: &Parent) -> Vec { /// File → Show in Folder: select `path` in Finder / Explorer, or open its folder elsewhere. fn reveal(path: &str) -> Result<(), String> { + explorer_safe(path)?; reveal_command(path).spawn().map(|_| ()).map_err(|e| format!("can't show {path}: {e}")) } @@ -548,8 +549,15 @@ fn services(parent: Parent, ui: egui::Context) -> Services { } } +/// Windows passes `path` to Explorer inside quotes it adds itself, so a path holding a quote (which +/// no Windows file name has) is refused rather than split into other arguments. +fn explorer_safe(path: &str) -> Result<(), String> { + if cfg!(windows) && path.contains('"') { Err(format!("can't open {path}: the path contains a quotation mark")) } else { Ok(()) } +} + /// Edit Original, Show Package: open `path` (a file or a folder) in the system's default app for it. fn open_file(path: &str) -> Result<(), String> { + explorer_safe(path)?; #[cfg(windows)] let mut c = { use std::os::windows::process::CommandExt as _; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 6b322a891..7ba3ff6ad 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -733,7 +733,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "links.editOriginal", "Edit Original", "", - "{id?} open the file of linked image `id` (default: the selected linked image) in the system's default app for its type (Links panel, Edit › Edit Original); a placed document's file opens here in a new tab, and saving it updates the documents placing it → {path}; edits saved elsewhere show after links.update", + "{id?} open the file of linked image `id` (default: the selected linked image) in the system's default app for its type (Links panel, Edit › Edit Original; only a regular file with the extension of a format document.open reads); a placed document's file opens here in a new tab, and saving it updates the documents placing it → {path}; edits saved elsewhere show after links.update", ), ( "links.reveal", diff --git a/crates/ui-egui/src/panels/links.rs b/crates/ui-egui/src/panels/links.rs index dcf9f024c..73ecc53ba 100644 --- a/crates/ui-egui/src/panels/links.rs +++ b/crates/ui-egui/src/panels/links.rs @@ -205,10 +205,34 @@ pub(crate) fn open_file(app: &mut VectorcraftApp, p: &Value, reveal: bool) -> Re crate::dialogs::settle(app); return Ok(json!({ "path": path })); } - if reveal { crate::io::reveal_path(app, &path) } else { crate::io::open_in_app(app, &path) }?; + if reveal { + crate::io::reveal_path(app, &path)?; + } else { + openable_original(&path)?; + crate::io::open_in_app(app, &path)?; + } Ok(json!({ "path": path })) } +/// Whether Edit Original may hand `path` to the system's default app: a regular file (after +/// following links) whose name, and the name it links to, have the extension of a format +/// VectorCraft reads. Anything else (a folder or app bundle, a script, a shortcut) is refused. +pub(crate) fn openable_original(path: &str) -> Result<(), String> { + let readable = |p: &std::path::Path| { + let ext = p.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); + !ext.is_empty() && vectorcraft_engine::cmd::fileio::FORMATS.iter().any(|f| f.read && f.extensions.contains(&ext.as_str())) + }; + let name = std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().into_owned()); + let real = std::fs::canonicalize(path).map_err(|e| format!("can't open “{name}”: {e}"))?; + if !real.is_file() { + return Err(format!("“{name}” is not a file")); + } + if !readable(std::path::Path::new(path)) || !readable(&real) { + return Err(format!("Edit Original opens image and document files only; “{name}” is not one")); + } + Ok(()) +} + /// A status badge: missing (red, ?), modified (amber, !), embedded (the image icon). fn badge(ui: &Ui, r: egui::Rect, status: &str) { let t = Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/src/panels/tests_links.rs b/crates/ui-egui/src/panels/tests_links.rs index f27a42b53..7d5c2ce7c 100644 --- a/crates/ui-egui/src/panels/tests_links.rs +++ b/crates/ui-egui/src/panels/tests_links.rs @@ -111,6 +111,42 @@ fn edit_original_and_show_in_folder_hand_the_file_to_the_system() { assert!(app.run("links.editOriginal", json!({"id": linked})).is_err()); } +/// Edit Original hands only regular image and document files to the system's default app. +#[test] +fn edit_original_opens_image_and_document_files_only() { + let dir = Folder::new("kinds"); + for f in ["photo.png", "Art.SVG", "run.exe", "run.bat", "photo.png.lnk", "page.hta", "app.desktop"] { + dir.file(f, b""); + } + for f in ["Tool.app", "folder.png"] { + std::fs::create_dir_all(dir.0.join(f)).unwrap(); + } + let ok = |f: &str| super::links::openable_original(&dir.0.join(f).to_string_lossy()).is_ok(); + assert!(ok("photo.png") && ok("Art.SVG")); + for f in ["run.exe", "run.bat", "photo.png.lnk", "page.hta", "app.desktop", "Tool.app", "folder.png", "missing.png", ""] { + assert!(!ok(f), "{f}"); + } + #[cfg(unix)] + { + std::os::unix::fs::symlink(dir.0.join("app.desktop"), dir.0.join("link.png")).unwrap(); + assert!(!ok("link.png"), "an image name linking to another kind of file"); + // The command refuses a linked image that became such a link, without opening anything. + let (mut app, linked, _) = app(&dir); + let opened: Rc>> = Rc::default(); + let o = opened.clone(); + app.services.open_file = Some(Box::new(move |p: &str| { + o.borrow_mut().push(p.to_string()); + Ok(()) + })); + let png = dir.0.join("linked.png"); + std::fs::remove_file(&png).unwrap(); + std::os::unix::fs::symlink(dir.0.join("app.desktop"), &png).unwrap(); + let e = app.run("links.editOriginal", json!({"id": linked})).unwrap_err(); + assert!(e.contains("image and document files only"), "{e}"); + assert!(opened.borrow().is_empty()); + } +} + #[test] fn placement_options_dialog_sets_the_options() { let dir = Folder::new("dialog");