From 335acab01b2c1c833888546e829fcca26586149d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 15:52:05 -0800 Subject: [PATCH] Scripts: a missing file below a link that leads nowhere is refused A missing file below a folder link whose target does not exist got "does not exist", while one below a link to an existing outside folder was refused, so a script could tell whether the folder a link leads to exists. A missing path is now refused when the nearest entry above it that exists is a link that leads to nothing. --- crates/engine/src/file_access.rs | 9 +++++++-- crates/engine/src/tests/script_io.rs | 11 +++++++++++ docs/mcp.md | 2 +- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 7114e6f13..73c8ba8ef 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -478,9 +478,14 @@ impl ScriptScope { /// Is the missing `path` inside a granted folder: does the nearest folder above it that exists /// lie inside one? On a file system where case matters, `MAIN/x` beside the granted `main` is - /// outside, whether `MAIN` exists or not. + /// outside, whether `MAIN` exists or not. A link above it that leads to nothing is outside too, + /// so a script cannot tell whether the folder a link leads to exists. fn missing_inside(&self, path: &Path) -> bool { - path.ancestors().skip(1).find_map(|a| std::fs::canonicalize(a).ok()).is_some_and(|real| self.roots.iter().any(|r| inside(&real, r))) + path.ancestors() + .skip(1) + .find(|a| std::fs::symlink_metadata(a).is_ok()) + .and_then(|a| std::fs::canonicalize(a).ok()) + .is_some_and(|real| self.roots.iter().any(|r| inside(&real, r))) } /// What the file or folder at `path` is, located as [`ScriptScope::locate`] locates it. diff --git a/crates/engine/src/tests/script_io.rs b/crates/engine/src/tests/script_io.rs index 7f3af701e..4f9f193f5 100644 --- a/crates/engine/src/tests/script_io.rs +++ b/crates/engine/src/tests/script_io.rs @@ -156,6 +156,17 @@ fn script_file_link_escape_refused() { // A missing file behind the link is refused like an existing one, so nothing tells them apart. let log = logged(&base, "var n = new File('away/nope.txt'); vc.log([n.exists, n.open('r'), n.error]);"); assert_eq!(log, json!([[false, false, DENIED]])); + // A folder link to an outside folder that does not exist is refused the same way, so a script + // cannot tell whether the folder it leads to exists. + link_folder(&base.join("gone"), &base.join("main").join("dangling")); + let log = logged( + &base, + "var d = new File('dangling/nope.txt'); var e = new File('dangling/a/b.txt');\n\ + vc.log([d.exists, d.open('r'), d.error, e.open('r'), e.error]);", + ); + assert_eq!(log, json!([[false, false, DENIED, false, DENIED]])); + let dangling = base.join("main").join("dangling"); + std::fs::remove_dir(&dangling).or_else(|_| std::fs::remove_file(&dangling)).unwrap(); #[cfg(unix)] let made = |target: &Path, link: &Path| std::os::unix::fs::symlink(target, link).is_ok(); #[cfg(windows)] diff --git a/docs/mcp.md b/docs/mcp.md index 7f75de7c3..0d0944148 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1838,7 +1838,7 @@ VectorCraft reads files by these rules of its own: (`\\?\`, `\\.\`) are refused before anything is looked up: `exists` is `false`, the dates and `getFiles` give `null`, and `error` is `Script file access denied`. A path that leads out through a link or a junction is refused too, and `getFiles` leaves such an entry out. A missing file is refused the same way unless the nearest folder above it that - exists is inside the script's folder. + exists is inside the script's folder. It is also refused when a link above it leads to a folder that does not exist. - `open` reads the whole file. A file larger than 8 MiB is refused from its size before any of it is read, with `Script file exceeds 8 MiB`, and a run that opens more than 32 MiB in all ends with an error. A run has at most 256 files open at once; another `open` returns `false` with `Script has 256 files open` until one is closed.