Browse / github.com/storytold/gridcraft / patch
Local file and folder links open; =HYPERLINK() cells follow their link (#2596)
Local File Links not working Expected when inserting a link either with =HYPERLINK("locallink", "display) or using the insert link menu to be able to open the local link whether it be a file or folder. currently it does not let me open it, it doesnt do anything other than select the cell <img width="611" height="339" alt="Image" src="https://github.com/user-attachments/assets/28de3452-fd12-448d-9a96-5b8190ab86e0" />
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- SofiaAFigueredo (imported from pull request #2601, open)
- Base
45f5ab18e4e57bf50ee88d2526deb4b87c5b24cb- Size
- +550 −45 in 9 files
- Review
- pending
- Shared
- 2026-10-28
- Id
01a69b15f5847471015906fb
How it went
No other operator has reported using it yet.
Diff
raw · ROADMAP.md apps/gridcraft/src/main.rs crates/engine/src/cmd/insert/links.rs crates/engine/src/links.rs crates/engine/src/tests.rs crates/ui-egui/src/dialogs.rs crates/ui-egui/src/grid/interact.rs crates/ui-egui/src/grid/show.rs crates/ui-egui/tests/it/hyperlink_ctrl_click.rs
@@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column) · **Target:** Microsoft Excel (Microsoft 365)+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (links to local files and folders open after a confirmation; AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.@@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---|+| 2026-10-25 | Links to files and folders on this computer open: a hyperlink to a path or `file:` address, and a click on a `=HYPERLINK("path", …)` cell (which did nothing, for web links too), ask first and then open an existing document or folder with the system's program, relative paths from the workbook's folder; programs and scripts (`.exe`, `.sh`, `.app`…, also through a symlink or the executable bit), missing files and network shares stay refused (#2596) | | 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before | | 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) | | 2026-10-24 | Status bar: a right-click opens a menu that sets which selection statistics show (Average, Count and Sum by default; Numerical Count, Minimum and Maximum are new), kept with the preferences (`view.statusBar`) |@@ -206,10 +206,22 @@ fn services() -> Services { /// Opens a URL or file with the system's default application. fn open_with_system(target: &str) { // Last line of defence behind `link.open` and the click prompt: the system only ever gets- // http, https and mailto addresses.- let gridcraft_engine::links::LinkKind::External(target) = gridcraft_engine::links::classify(target) else {- log::warn!("refused to open {target:?}: only http, https and mailto links are opened");- return;+ // http, https and mailto addresses and existing documents and folders (never programs).+ let target = match gridcraft_engine::links::classify(target) {+ gridcraft_engine::links::LinkKind::External(url) => url,+ gridcraft_engine::links::LinkKind::Local(path) if std::path::Path::new(&path).is_absolute() => {+ match gridcraft_engine::links::resolve_local(None, &path) {+ Ok(full) => full.to_string_lossy().into_owned(),+ Err(why) => {+ log::warn!("refused to open {path:?}: {why}");+ return;+ }+ }+ }+ _ => {+ log::warn!("refused to open {target:?}: only http, https, mailto and existing local files and folders are opened");+ return;+ } }; let target = target.as_str(); let cmd = if cfg!(target_os = "macos") {@@ -35,27 +35,36 @@ pub(super) fn insert_link(s: &mut Session, p: &Json) -> Result<Json> { }) } -/// Follows a link: a reference selects it, an `http`/`https`/`mailto` address is handed to the-/// host (`UiRequest::OpenUrl`); anything else is refused ([`crate::links::classify`]). Opens no-/// dialog; the interactive click asks for confirmation first, in the UI.+/// Follows a link: a reference selects it, an `http`/`https`/`mailto` address or an existing file+/// or folder on this computer is handed to the host (`UiRequest::OpenUrl`); anything else is+/// refused ([`crate::links::classify_link`]). The cell's link is its hyperlink or the target of a+/// `=HYPERLINK()` formula. Opens no dialog; the interactive click asks for confirmation first, in+/// the UI. pub(super) fn open_link(s: &mut Session, p: &Json) -> Result<Json> {- let target = match str_param(p, "url").or_else(|| str_param(p, "target")) {- Some(t) => t.to_string(),+ let (target, external) = match str_param(p, "url").or_else(|| str_param(p, "target")) {+ Some(t) => (t.to_string(), false), None => { let sheet = target_sheet(s, p)?; let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);- let Some(h) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.hyperlinks.get(&at)) else {+ let Some(link) = crate::links::link_at(&s.doc()?.wb, sheet, at) else { return Err(EngineError::Other("There's no hyperlink in this cell.".into())); };- h.target.clone()+ link } };- match crate::links::classify(&target) {+ match crate::links::classify_link(&target, external) { crate::links::LinkKind::Internal(r) => crate::cmd::edit::go_to(s, &json!({"reference": r.trim_start_matches('#')})), crate::links::LinkKind::External(u) => { s.ui_requests.push(crate::UiRequest::OpenUrl(u.clone())); Ok(json!({"opened": u})) }+ crate::links::LinkKind::Local(path) => {+ let base = s.doc()?.path.as_deref().and_then(|f| std::path::Path::new(f).parent().map(std::path::Path::to_path_buf));+ let full = crate::links::resolve_local(base.as_deref(), &path).map_err(EngineError::Other)?;+ let opened = full.to_string_lossy().into_owned();+ s.ui_requests.push(crate::UiRequest::OpenUrl(opened.clone()));+ Ok(json!({"opened": opened}))+ } crate::links::LinkKind::Refused(why) => Err(EngineError::Other(why)), } }@@ -1,9 +1,15 @@ //! Which hyperlink targets may be opened. A cell's link comes from the document (often a file-//! somebody sent), so it is hostile input: only `http`, `https` and `mailto` ever leave the-//! workbook for the system; `file:`, `smb:`, UNC paths, custom URL handlers, `javascript:` and-//! the like are refused. Everything without a scheme stays an in-workbook reference.+//! somebody sent), so it is hostile input: only `http`, `https` and `mailto` addresses and files+//! or folders on this computer ever leave the workbook for the system; `smb:`, UNC paths, custom+//! URL handlers, `javascript:` and the like are refused. A local target is opened only after the+//! user confirms it, only when it exists, and never when it is a program or script+//! ([`resolve_local`]). Everything else without a scheme stays an in-workbook reference. -use gridcraft_core::RangeRef;+use std::path::{Path, PathBuf};++use gridcraft_core::{CellRef, RangeRef};+use gridcraft_formula::Expr;+use gridcraft_model::Workbook; /// What a link target is. #[derive(Clone, Debug, PartialEq, Eq)]@@ -12,6 +18,9 @@ pub enum LinkKind { Internal(String), /// An address to hand to the system, trimmed: `http`, `https` or `mailto` only. External(String),+ /// A file or folder on this computer (a `file:` address decoded, or a path), relative to the+ /// workbook when it isn't absolute. Opened only after confirmation ([`resolve_local`]).+ Local(String), /// Refused, with the reason to show. Refused(String), }@@ -24,12 +33,15 @@ pub fn classify(target: &str) -> LinkKind { if t.chars().any(char::is_control) { return LinkKind::Refused("The link contains control characters.".into()); }- if t.starts_with("\\\\") || t.starts_with("//") {+ if is_unc_prefix(t) { return LinkKind::Refused("Links to network locations aren't opened.".into()); } if RangeRef::parse(t).is_some() { return LinkKind::Internal(t.to_string()); }+ if let Some(path) = path_like(t) {+ return local(path);+ } let Some((scheme, rest)) = t.split_once(':') else { return LinkKind::Internal(t.to_string()) }; let scheme_like = scheme.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) && scheme.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));@@ -37,6 +49,12 @@ pub fn classify(target: &str) -> LinkKind { return LinkKind::Internal(t.to_string()); } let lower = scheme.to_ascii_lowercase();+ if lower == "file" {+ return match file_url_path(rest) {+ Ok(path) => local(&path),+ Err(why) => LinkKind::Refused(why),+ };+ } if ALLOWED.contains(&lower.as_str()) && !rest.trim().is_empty() { LinkKind::External(t.to_string()) } else {@@ -44,6 +62,271 @@ pub fn classify(target: &str) -> LinkKind { } } +/// Classifies the target of a stored hyperlink. `external` is the workbook's own mark that the+/// link leaves the workbook ("Existing File or Web Page"), which makes a bare file name such as+/// `notes.txt` a file instead of a defined name.+pub fn classify_link(target: &str, external: bool) -> LinkKind {+ match classify(target) {+ LinkKind::Internal(t)+ if external && !t.trim().is_empty() && RangeRef::parse(&t).is_none() && !t.starts_with('#') && !(t.contains('!') && !t.contains('#')) =>+ {+ local(t.split('#').next().unwrap_or_default())+ }+ other => other,+ }+}++/// The text of a path that is plainly one: a drive (`C:\x`), an absolute, home or `.`/`..`+/// relative path, or anything with a separator that is not a `Sheet!A1` reference. Sheet and+/// defined names can't hold `/` or `\`, so those are never mistaken for references.+fn path_like(t: &str) -> Option<&str> {+ let b = t.as_bytes();+ let drive = b.len() >= 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && matches!(b[2], b'/' | b'\\');+ let rooted =+ t.starts_with('/') || t.starts_with("~/") || t.starts_with("./") || t.starts_with("../") || t.starts_with(".\\") || t.starts_with("..\\");+ // `file:/x`, `smb:/h/s` and `javascript:a/b` carry a scheme: `classify` must see them. A single+ // letter is a drive, not a scheme.+ let has_scheme = t.split_once(':').is_some_and(|(s, _)| {+ s.len() > 1+ && s.starts_with(|c: char| c.is_ascii_alphabetic())+ && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))+ });+ let separated = t.contains(['/', '\\']) && !t.contains('!') && !has_scheme;+ (drive || rooted || separated).then(|| t.split('#').next().unwrap_or(t))+}++/// The path of a `file:` address: no host (or `localhost`), percent-decoded. Another host is a+/// network location and is refused.+fn file_url_path(rest: &str) -> Result<String, String> {+ let rest = rest.split(['#', '?']).next().unwrap_or_default();+ let path = match rest.strip_prefix("//") {+ Some(after) => {+ let (host, path) = after.split_at(after.find('/').unwrap_or(after.len()));+ if !host.is_empty() && !host.eq_ignore_ascii_case("localhost") {+ return Err("Links to network locations aren't opened.".into());+ }+ path+ }+ None => rest,+ };+ let decoded = percent_decode(path).ok_or_else(|| "The link isn't a valid file address.".to_string())?;+ // `file:///C:/x` carries the drive after a slash.+ let b = decoded.as_bytes();+ let decoded = if b.len() >= 3 && b[0] == b'/' && b[1].is_ascii_alphabetic() && b[2] == b':' { decoded[1..].to_string() } else { decoded };+ if decoded.is_empty() || decoded.chars().any(char::is_control) || is_unc_prefix(&decoded) {+ return Err("The link isn't a valid file address.".into());+ }+ Ok(decoded)+}++fn percent_decode(s: &str) -> Option<String> {+ let b = s.as_bytes();+ let mut out = Vec::with_capacity(b.len());+ let mut i = 0;+ while let Some(&c) = b.get(i) {+ if c == b'%' {+ let hex = s.get(i + 1..i + 3)?;+ out.push(u8::from_str_radix(hex, 16).ok()?);+ i += 3;+ } else {+ out.push(c);+ i += 1;+ }+ }+ String::from_utf8(out).ok()+}++/// A network location on Windows: two leading separators of either kind (`//h/s`, `\\h\s`, `/\h/s`).+fn is_unc_prefix(path: &str) -> bool {+ let b = path.as_bytes();+ b.len() >= 2 && matches!(b[0], b'/' | b'\\') && matches!(b[1], b'/' | b'\\')+}++fn local(path: &str) -> LinkKind {+ let path = path.trim();+ if path.is_empty() {+ return LinkKind::Refused("The link has no file or folder.".into());+ }+ if is_unc_prefix(path) {+ return LinkKind::Refused("Links to network locations aren't opened.".into());+ }+ if is_program(Path::new(path)) {+ return LinkKind::Refused("Links to programs and scripts aren't opened. Open the file from your file manager if you trust it.".into());+ }+ LinkKind::Local(path.to_string())+}++/// Extensions that run code when the system opens them. Compared without case and without the+/// trailing dots and spaces Windows ignores in a name.+const PROGRAMS: &[&str] = &[+ "exe",+ "com",+ "bat",+ "cmd",+ "msi",+ "msp",+ "scr",+ "pif",+ "ps1",+ "psm1",+ "vbs",+ "vbe",+ "vb",+ "js",+ "jse",+ "wsf",+ "wsh",+ "hta",+ "lnk",+ "url",+ "jar",+ "jnlp",+ "app",+ "command",+ "sh",+ "bash",+ "zsh",+ "csh",+ "ksh",+ "desktop",+ "run",+ "bin",+ "appimage",+ "dll",+ "ocx",+ "drv",+ "sys",+ "vxd",+ "wcx",+ "cpl",+ "msc",+ "reg",+ "inf",+ "scf",+ "chm",+ "rdp",+ "website",+ "gadget",+ "application",+ "appref-ms",+ "appcontent-ms",+ "settingcontent-ms",+ "appx",+ "appxbundle",+ "action",+ "workflow",+ "terminal",+ "py",+ "pyw",+ "pl",+ "rb",+ "pkg",+ "dmg",+];++fn is_program(path: &Path) -> bool {+ let name = path.file_name().and_then(|n| n.to_str()).unwrap_or_default();+ let name = name.trim_end_matches(['.', ' ']);+ let Some((_, ext)) = name.rsplit_once('.') else { return false };+ let ext = ext.trim().to_ascii_lowercase();+ PROGRAMS.contains(&ext.as_str())+}++/// The file or folder a [`LinkKind::Local`] names, ready for the system: relative paths are taken+/// from the workbook's folder (`base`, the folder of the saved file), `~/` from the home folder.+/// Refuses what doesn't exist, programs and scripts (by name, by what a link resolves to, and on+/// Unix by the executable bit), so a confirmed click can only open documents and folders.+pub fn resolve_local(base: Option<&Path>, target: &str) -> Result<PathBuf, String> {+ // Before anything touches the file system: resolving a share would contact it.+ if is_unc_prefix(target) {+ return Err("Links to network locations aren't opened.".into());+ }+ let path = match target.strip_prefix("~/") {+ Some(rest) => {+ let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from);+ home.ok_or_else(|| "The home folder isn't known.".to_string())?.join(rest)+ }+ None => PathBuf::from(target),+ };+ let path = if path.is_absolute() || path.has_root() || target.as_bytes().get(1) == Some(&b':') {+ path+ } else {+ let base = base.ok_or_else(|| "Save the workbook first: this link is relative to the workbook's folder.".to_string())?;+ base.join(path)+ };+ // A workbook kept on a share has relative links on that share.+ if is_unc_prefix(&path.to_string_lossy()) {+ return Err("Links to network locations aren't opened.".into());+ }+ if is_program(&path) {+ return Err("Links to programs and scripts aren't opened.".into());+ }+ // Resolving follows links, and a link on this computer can lead to a share: look at where+ // each one points before the file system is asked to follow it.+ if leads_to_network(&path, 8) {+ return Err("Links to network locations aren't opened.".into());+ }+ let real = std::fs::canonicalize(&path).map_err(|_| format!("\"{}\" doesn't exist or can't be reached.", path.display()))?;+ // A link on this computer can lead to a share (`\\?\UNC\…` once resolved on Windows).+ let resolved = real.to_string_lossy();+ if resolved.starts_with("\\\\?\\UNC\\") || (is_unc_prefix(&resolved) && !resolved.starts_with("\\\\?\\")) {+ return Err("Links to network locations aren't opened.".into());+ }+ if is_program(&real) {+ return Err("Links to programs and scripts aren't opened.".into());+ }+ #[cfg(unix)]+ {+ use std::os::unix::fs::PermissionsExt;+ let meta = std::fs::metadata(&real).map_err(|e| e.to_string())?;+ if meta.is_file() && meta.permissions().mode() & 0o111 != 0 {+ return Err("Links to programs and scripts aren't opened.".into());+ }+ }+ Ok(path)+}++/// Whether `path`, or a folder on the way to it, is a symbolic link (or junction) to a network+/// location, looking at `read_link` only so nothing is contacted. Follows chains of links `depth`+/// deep; one that deep is refused as well.+fn leads_to_network(path: &Path, depth: u8) -> bool {+ let mut seen = path.ancestors().collect::<Vec<_>>();+ seen.reverse();+ seen.into_iter().any(|at| {+ if !std::fs::symlink_metadata(at).is_ok_and(|m| m.file_type().is_symlink()) {+ return false;+ }+ let Ok(to) = std::fs::read_link(at) else { return false };+ let text = to.to_string_lossy();+ if is_unc_prefix(&text) || text.starts_with("\\\\?\\UNC\\") {+ return true;+ }+ let to = if to.is_absolute() { to } else { at.parent().map(|p| p.join(&to)).unwrap_or(to) };+ match depth.checked_sub(1) {+ Some(left) => leads_to_network(&to, left),+ None => true,+ }+ })+}++/// The target of the link in a cell: its stored hyperlink, or the first argument of a cell whose+/// formula is `=HYPERLINK(link, …)`, evaluated. `external` is the stored link's mark ([`classify_link`]).+pub fn link_at(wb: &Workbook, sheet: usize, at: CellRef) -> Option<(String, bool)> {+ let sh = wb.sheet(sheet)?;+ if let Some(h) = sh.hyperlinks.get(&at) {+ return Some((h.target.clone(), h.external));+ }+ let expr = sh.cell(at)?.formula.as_ref()?.expr()?;+ let Expr::Call(name, args) = &expr else { return None };+ if name != "HYPERLINK" {+ return None;+ }+ match gridcraft_calc::recalc::evaluate_expr(wb, sheet, at, args.first()?) {+ gridcraft_core::Value::Text(t) if !t.trim().is_empty() => Some((t.to_string(), true)),+ _ => None,+ }+}+ #[cfg(test)] mod tests { use super::*;@@ -57,7 +340,7 @@ mod tests { for bad in [ "javascript:alert(1)", "JaVaScRiPt:alert(1)",- "file:///etc/passwd",+ "file://host/etc/passwd", "smb://host/share", "ms-excel:ofe|u|http://x", "\\\\host\\share\\x.exe",@@ -74,6 +357,110 @@ mod tests { } } + #[test]+ fn local_files_and_folders_are_recognised() {+ for (target, path) in [+ ("file:///home/u/a%20b.pdf", "/home/u/a b.pdf"),+ ("FILE://localhost/tmp/x.txt", "/tmp/x.txt"),+ ("file:///C:/Users/u/doc.txt", "C:/Users/u/doc.txt"),+ ("file:///tmp/folder/", "/tmp/folder/"),+ ("file:/tmp/a.txt", "/tmp/a.txt"),+ ("/tmp/a.txt", "/tmp/a.txt"),+ ("C:\\Docs\\a.pdf", "C:\\Docs\\a.pdf"),+ ("docs/a.pdf", "docs/a.pdf"),+ ("./a.pdf", "./a.pdf"),+ ("..\\a.pdf", "..\\a.pdf"),+ ("~/Documents", "~/Documents"),+ ("Book.xlsx#Sheet1!A2", "Book.xlsx"),+ ("/tmp/Book.xlsx#Sheet1!A2", "/tmp/Book.xlsx"),+ ] {+ let kind = classify(target);+ if target == "Book.xlsx#Sheet1!A2" {+ // A bare name is still told from a defined name only by the workbook's mark.+ assert!(matches!(kind, LinkKind::Internal(_)), "{target}");+ assert_eq!(classify_link(target, true), LinkKind::Local(path.into()), "{target}");+ } else {+ assert_eq!(kind, LinkKind::Local(path.into()), "{target}");+ }+ }+ assert_eq!(classify_link("notes.txt", true), LinkKind::Local("notes.txt".into()));+ assert!(matches!(classify_link("notes.txt", false), LinkKind::Internal(_)));+ assert!(matches!(classify_link("Total", false), LinkKind::Internal(_)));+ assert!(matches!(classify_link("Sheet2!A1", true), LinkKind::Internal(_)));+ assert!(matches!(classify_link("B2", true), LinkKind::Internal(_)));+ }++ #[test]+ fn local_programs_network_shares_and_bad_addresses_are_refused() {+ for bad in [+ "file://host/share/x.txt",+ "file:////host/share/x.txt",+ "file:///tmp/run.sh",+ "file:///tmp/run.SH",+ "/usr/bin/tool.exe",+ "C:\\x\\setup.msi",+ "docs/macro.vbs",+ "docs/app.exe . ",+ "~/Desktop/Tool.app",+ "file:///tmp/%00.txt",+ "file:///tmp/%zz",+ "file:///tmp/%ff.txt",+ "file:",+ "file:/%5Cserver/share/doc.txt",+ "file:/%2Fserver/share/doc.txt",+ "/\\server/share/doc.txt",+ "\\/server/share/doc.txt",+ "smb:/h/s",+ "javascript:a/b",+ "docs/setup.jnlp",+ "docs/Snap.MSC",+ "docs/tool.settingcontent-ms",+ ] {+ assert!(matches!(classify(bad), LinkKind::Refused(_)), "{bad:?} -> {:?}", classify(bad));+ }+ assert!(matches!(classify_link("tool.exe", true), LinkKind::Refused(_)));+ }++ #[test]+ fn resolving_a_local_link_needs_an_existing_document_or_folder() {+ #[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name+ let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();+ let dir = std::env::temp_dir().join(format!("gridcraft-links-{}-{nonce}", std::process::id()));+ std::fs::create_dir_all(dir.join("sub")).unwrap();+ std::fs::write(dir.join("doc.txt"), "x").unwrap();+ std::fs::write(dir.join("prog"), "#!/bin/sh\n").unwrap();+ // A relative link is taken from the workbook's folder; absolute ones and folders open as they are.+ assert_eq!(resolve_local(Some(&dir), "doc.txt").unwrap(), dir.join("doc.txt"));+ assert_eq!(resolve_local(Some(&dir), "sub").unwrap(), dir.join("sub"));+ assert_eq!(resolve_local(None, dir.join("doc.txt").to_str().unwrap()).unwrap(), dir.join("doc.txt"));+ assert!(resolve_local(Some(Path::new("//server/share")), "doc.txt").unwrap_err().contains("network"));+ assert!(resolve_local(Some(Path::new("\\\\server\\share")), "doc.txt").unwrap_err().contains("network"));+ assert!(resolve_local(None, "/\\server/share").unwrap_err().contains("network"));+ assert!(resolve_local(Some(&dir), "\\\\server\\share").unwrap_err().contains("network"));+ assert!(resolve_local(None, "doc.txt").unwrap_err().contains("Save the workbook"));+ assert!(resolve_local(Some(&dir), "missing.txt").unwrap_err().contains("doesn't exist"));+ #[cfg(unix)]+ {+ use std::os::unix::fs::PermissionsExt;+ // Programs by the executable bit, and through a link with an innocent name.+ std::fs::set_permissions(dir.join("prog"), std::fs::Permissions::from_mode(0o755)).unwrap();+ assert!(resolve_local(Some(&dir), "prog").is_err());+ std::os::unix::fs::symlink(dir.join("prog"), dir.join("looks-fine.txt")).unwrap();+ assert!(resolve_local(Some(&dir), "looks-fine.txt").is_err());+ // A link to a network location is refused without following it, also behind another link.+ std::os::unix::fs::symlink("//server/share/x", dir.join("share.txt")).unwrap();+ std::os::unix::fs::symlink(dir.join("share.txt"), dir.join("via.txt")).unwrap();+ std::os::unix::fs::symlink("//server/share", dir.join("shared-dir")).unwrap();+ for name in ["share.txt", "via.txt", "shared-dir", "shared-dir/doc.txt"] {+ assert!(resolve_local(Some(&dir), name).unwrap_err().contains("network"), "{name}");+ }+ std::fs::write(dir.join("hidden.sh"), "x").unwrap();+ std::os::unix::fs::symlink(dir.join("hidden.sh"), dir.join("notes.txt")).unwrap();+ assert!(resolve_local(Some(&dir), "notes.txt").is_err());+ }+ std::fs::remove_dir_all(dir).unwrap();+ }+ #[test] fn references_stay_internal() { for r in ["Sheet2!A1", "'My Sheet'!B2:C3", "A1:B2", "#Sheet1!A1", "Total", "", " "] {@@ -4430,11 +4430,11 @@ fn a_decrypted_workbook_never_overwrites_its_encrypted_original() { } #[test]-fn link_open_refuses_every_scheme_but_http_https_and_mailto() {+fn link_open_refuses_every_scheme_but_http_https_mailto_and_local_files() { let mut s = s();- s.execute("insert.link", json!({"cell": "A1", "target": "file:///etc/passwd"})).unwrap();+ s.execute("insert.link", json!({"cell": "A1", "target": "file://host/etc/passwd"})).unwrap(); s.take_ui_requests();- for url in ["file:///etc/passwd", "smb://h/s", "\\\\h\\s", "JAVASCRIPT:1"] {+ for url in ["file://host/etc/passwd", "smb://h/s", "\\\\h\\s", "JAVASCRIPT:1"] { assert!(s.execute("link.open", json!({"url": url})).is_err(), "{url}"); } assert!(s.execute("link.open", json!({"cell": "A1"})).is_err());@@ -4445,6 +4445,54 @@ fn link_open_refuses_every_scheme_but_http_https_and_mailto() { assert!(s.take_ui_requests().is_empty()); } +#[test]+#[cfg(not(target_arch = "wasm32"))]+fn link_open_opens_existing_local_files_and_folders_but_not_programs() {+ #[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name+ let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();+ let dir = std::env::temp_dir().join(format!("gridcraft-locallink-{}-{nonce}", std::process::id()));+ std::fs::create_dir_all(dir.join("reports")).unwrap();+ std::fs::write(dir.join("reports").join("q1.txt"), "x").unwrap();+ std::fs::write(dir.join("setup.exe"), "x").unwrap();+ let file = dir.join("reports").join("q1.txt");+ let mut s = s();+ // A hyperlink to a file, a `file:` address and a =HYPERLINK() formula all open it (#2596).+ s.execute("cell.set", json!({"cell": "A1", "input": "q1"})).unwrap();+ s.execute("insert.link", json!({"cell": "A1", "target": file.to_str().unwrap()})).unwrap();+ s.execute(+ "cell.set",+ json!({"cell": "A2", "input": format!("=HYPERLINK(\"{}\",\"folder\")", dir.join("reports").to_str().unwrap().replace('"', "\"\""))}),+ )+ .unwrap();+ s.execute("cell.set", json!({"cell": "A3", "input": format!("=HYPERLINK(\"{}\")", "setup.exe")})).unwrap();+ s.take_ui_requests();+ s.execute("link.open", json!({"cell": "A1"})).unwrap();+ assert_eq!(s.take_ui_requests(), vec![crate::UiRequest::OpenUrl(file.to_string_lossy().into_owned())]);+ s.execute("link.open", json!({"cell": "A2"})).unwrap();+ assert_eq!(s.take_ui_requests(), vec![crate::UiRequest::OpenUrl(dir.join("reports").to_string_lossy().into_owned())]);+ let url = format!("file://{}", file.to_str().unwrap());+ if cfg!(unix) {+ s.execute("link.open", json!({"url": url})).unwrap();+ assert_eq!(s.take_ui_requests(), vec![crate::UiRequest::OpenUrl(file.to_string_lossy().into_owned())]);+ }+ // Programs, missing files and network shares are still refused.+ assert!(s.execute("link.open", json!({"cell": "A3"})).is_err());+ assert!(s.execute("link.open", json!({"url": dir.join("setup.exe").to_str().unwrap()})).is_err());+ assert!(s.execute("link.open", json!({"url": dir.join("missing.txt").to_str().unwrap()})).is_err());+ assert!(s.execute("link.open", json!({"url": "file://host/share/a.txt"})).is_err());+ assert!(s.take_ui_requests().is_empty());+ // A relative link is taken from the saved workbook's folder.+ let book = dir.join("Book.xlsx");+ s.execute("cell.set", json!({"cell": "B1", "input": "rel"})).unwrap();+ s.execute("insert.link", json!({"cell": "B1", "target": "reports/q1.txt"})).unwrap();+ assert!(s.execute("link.open", json!({"cell": "B1"})).is_err(), "unsaved: no folder to take it from");+ s.execute("file.saveAs", json!({"path": book.to_str().unwrap()})).unwrap();+ s.take_ui_requests();+ s.execute("link.open", json!({"cell": "B1"})).unwrap();+ assert_eq!(s.take_ui_requests(), vec![crate::UiRequest::OpenUrl(dir.join("reports/q1.txt").to_string_lossy().into_owned())]);+ std::fs::remove_dir_all(dir).unwrap();+}+ #[test] fn subtotal_109_recalculates_when_row_height_or_autofit_reveals_rows() { let mut s = s();@@ -481,19 +481,17 @@ impl Dialog { json!({"text": existing}), ) }- // Asks before a web or e-mail link leaves the workbook; OK runs `link.open`, which- // refuses anything but http, https and mailto.+ // Asks before a web, e-mail or local file link leaves the workbook; OK runs `link.open`,+ // which refuses anything else (and programs and scripts). "openLink" => { let url = params.get("url").and_then(Json::as_str).unwrap_or_default().to_string();- Dialog::form(- "openLink",- "Open Hyperlink",- "link.open",- vec![Note(format!(- "This workbook asks to open the address below. Links can lead to harmful sites or start other programs: open it only if you trust the workbook.\n\n{url}"- ))],- json!({"url": url}),- )+ let local = params.get("local").and_then(Json::as_bool).unwrap_or(false);+ let ask = if local {+ "This workbook asks to open the file or folder below with the program your computer uses for it. Files can contain harmful content: open it only if you trust the workbook."+ } else {+ "This workbook asks to open the address below. Links can lead to harmful sites or start other programs: open it only if you trust the workbook."+ };+ Dialog::form("openLink", "Open Hyperlink", "link.open", vec![Note(format!("{ask}\n\n{url}"))], json!({"url": url})) } "insertLink" => Dialog::form( "insertLink",@@ -371,12 +371,12 @@ pub(super) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo } } else if mods.command { // Ctrl+click (without Alt) on a hyperlink's text follows it instead of adding the cell to the selection.- if let Some(h) = sh.hyperlinks.get(&c).cloned()+ if let Some((target, external)) = gridcraft_engine::links::link_at(wb, wb.active_sheet(), c) && primary_clicked && !mods.alt && p.x < geo.cell_rect(sh, c).left() + geo.cell_rect(sh, c).width() * 0.8 {- follow_link(app, &h.target);+ follow_link(app, &target, external); } else { select_ranges(app, RangeRef::cell(c), true, c); }@@ -386,13 +386,13 @@ pub(super) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo let _ = app.run("cell.toggleCheckbox", json!({"cell": c.a1()})); } // A click on a hyperlink's text follows it (like Excel); elsewhere in the cell selects.- if let Some(h) = sh.hyperlinks.get(&c).cloned()+ if let Some((target, external)) = gridcraft_engine::links::link_at(wb, wb.active_sheet(), c) && primary_clicked && !mods.any() { let r = geo.cell_rect(sh, c); if p.x < r.left() + r.width() * 0.8 {- follow_link(app, &h.target);+ follow_link(app, &target, external); } } }@@ -859,17 +859,26 @@ pub(super) fn delete_selected_object(app: &mut SheetApp, sh: &Sheet) -> bool { true } -/// A click on a hyperlink. References select; web and e-mail addresses ask first (the dialog's-/// OK runs `link.open`); every other kind is refused with a message. The decision is the-/// engine's ([`gridcraft_engine::links::classify`]), the same one `link.open` applies.-pub fn follow_link(app: &mut SheetApp, target: &str) {- use gridcraft_engine::links::{LinkKind, classify};- match classify(target) {+/// A click on a hyperlink (`external`: the workbook marks it as leaving the workbook). References+/// select; web and e-mail addresses and existing files and folders ask first (the dialog's OK+/// runs `link.open`); every other kind is refused with a message. The decision is the engine's+/// ([`gridcraft_engine::links::classify_link`]), the same one `link.open` applies.+pub fn follow_link(app: &mut SheetApp, target: &str, external: bool) {+ use gridcraft_engine::links::{LinkKind, classify_link, resolve_local};+ match classify_link(target, external) { LinkKind::Internal(r) => { let _ = app.session.run("edit.goTo", json!({"reference": r.trim_start_matches('#')})); app.grid.ensure_visible = true; } LinkKind::External(url) => app.open_dialog("openLink", json!({"url": url})),+ LinkKind::Local(path) => {+ let file = app.session.active().and_then(|d| d.path.clone());+ let base = file.as_deref().and_then(|f| std::path::Path::new(f).parent());+ match resolve_local(base, &path) {+ Ok(full) => app.open_dialog("openLink", json!({"url": full.to_string_lossy(), "local": true})),+ Err(why) => app.message = Some(("Hyperlink".into(), why)),+ }+ } LinkKind::Refused(why) => app.message = Some(("Hyperlink".into(), why)), } }@@ -630,10 +630,15 @@ pub(crate) fn paint_overlays( ); } // Hyperlink tooltip.- if let Some(h) = hover.and_then(|c| sh.hyperlinks.get(&c))+ if let Some(c) = hover && app.editor.is_none()+ && let Some((target, _)) = gridcraft_engine::links::link_at(wb, si, c) {- let tip = h.tooltip.clone().unwrap_or_else(|| format!("{}\nClick once to follow. Click and hold to select this cell.", h.target));+ let tip = sh+ .hyperlinks+ .get(&c)+ .and_then(|h| h.tooltip.clone())+ .unwrap_or_else(|| format!("{target}\nClick once to follow. Click and hold to select this cell.")); egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::unique("link_tip"), egui::PopupAnchor::Pointer).show(|ui| { ui.label(tip); });@@ -8,9 +8,20 @@ use serde_json::json; type Harness = egui_kittest::Harness<'static, SheetApp>; +fn local_file() -> std::path::PathBuf {+ let path = std::env::temp_dir().join(format!("gridcraft-ui-link-{}.txt", std::process::id()));+ std::fs::write(&path, "x").unwrap();+ path+}+ fn harness() -> Harness { let mut session = Session::new(); session.new_workbook();+ session.execute("cell.set", json!({"cell": "B6", "input": "file"})).unwrap();+ session.execute("insert.link", json!({"cell": "B6", "target": local_file().to_str().unwrap()})).unwrap();+ session.execute("cell.set", json!({"cell": "B8", "input": "=HYPERLINK(\"https://example.org/h\",\"go\")"})).unwrap();+ session.execute("cell.set", json!({"cell": "B10", "input": "tool"})).unwrap();+ session.execute("insert.link", json!({"cell": "B10", "target": "/tmp/setup.exe"})).unwrap(); session.execute("cell.set", json!({"cell": "B2", "input": "web"})).unwrap(); session.execute("insert.link", json!({"cell": "B2", "target": "https://example.org/a"})).unwrap(); session.execute("cell.set", json!({"cell": "B4", "input": "jump"})).unwrap();@@ -95,3 +106,28 @@ fn ctrl_alt_click_on_the_link_text_still_extends_the_selection() { assert_eq!(d.selection.active.a1(), "B4", "the link was not followed"); assert!(h.state().dialog.is_none(), "no link dialog"); }++#[test]+fn a_click_on_a_local_file_link_asks_before_opening_it() {+ let mut h = harness();+ click(&mut h, "B6", Modifiers::NONE);+ let d = h.state().dialog.as_ref().expect("a link to a file on this computer asks first (#2596)");+ assert_eq!(d.name, "openLink");+ assert!(h.state().message.is_none());+}++#[test]+fn a_click_on_a_hyperlink_formula_follows_it() {+ let mut h = harness();+ click(&mut h, "B8", Modifiers::NONE);+ let d = h.state().dialog.as_ref().expect("=HYPERLINK() cells follow their link on click (#2596)");+ assert_eq!(d.name, "openLink");+}++#[test]+fn a_click_on_a_link_to_a_program_is_refused_with_a_message() {+ let mut h = harness();+ click(&mut h, "B10", Modifiers::NONE);+ assert!(h.state().dialog.is_none());+ assert!(h.state().message.as_ref().is_some_and(|(_, why)| why.contains("programs and scripts")), "{:?}", h.state().message);+}