diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 27ebc9c40..7114e6f13 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -463,7 +463,10 @@ impl ScriptScope { let real = match std::fs::canonicalize(path) { Ok(real) => real, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - return Err(if std::fs::symlink_metadata(path).is_ok() { ScriptRead::Refused } else { ScriptRead::Missing }); + if std::fs::symlink_metadata(path).is_ok() || !self.missing_inside(path) { + return Err(ScriptRead::Refused); + } + return Err(ScriptRead::Missing); } Err(e) => return Err(ScriptRead::Failed(e.to_string())), }; @@ -473,6 +476,13 @@ impl ScriptScope { Ok(real) } + /// 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. + 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))) + } + /// What the file or folder at `path` is, located as [`ScriptScope::locate`] locates it. pub(crate) fn stat(&self, path: &Path) -> Result { let real = self.locate(path)?; diff --git a/crates/engine/src/tests/script_io.rs b/crates/engine/src/tests/script_io.rs index aae4810da..7f3af701e 100644 --- a/crates/engine/src/tests/script_io.rs +++ b/crates/engine/src/tests/script_io.rs @@ -153,6 +153,9 @@ fn script_file_link_escape_refused() { Folder.current.getFiles().map(function (f) { return f.name; }), new Folder('near').alias, new Folder('near').resolve().name]);", ); assert_eq!(log, json!([[false, null, DENIED, null, false, false, DENIED, ["inner", "main.jsx", "near", "own.txt"], true, "inner"]])); + // 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]])); #[cfg(unix)] let made = |target: &Path, link: &Path| std::os::unix::fs::symlink(target, link).is_ok(); #[cfg(windows)] @@ -176,6 +179,31 @@ fn script_file_link_escape_refused() { let _ = std::fs::remove_dir_all(base); } +/// A path whose names differ from the script's folder only in case is that folder where case does +/// not matter, and outside it where case matters, whether or not the file is there. +#[test] +fn script_file_case_variant_folder() { + let base = folder("case"); + write(&base.join("main/own.txt"), b"own"); + let upper = base.join("MAIN"); + let source = |name: &str| { + let p = format!("{:?}", upper.join(name).to_string_lossy()); + format!("var f = new File({p}); vc.log([f.exists, f.open('r'), f.error]);") + }; + if upper.exists() { + // Case does not matter here: `MAIN` is the script's folder. + assert_eq!(logged(&base, &source("own.txt")), json!([[true, true, ""]])); + assert_eq!(logged(&base, &source("nope.txt")), json!([[false, false, "Script file does not exist"]])); + } else { + let denied = json!([[false, false, DENIED]]); + assert_eq!(logged(&base, &source("nope.txt")), denied, "MAIN does not exist"); + write(&upper.join("secret.txt"), b"secret"); + assert_eq!(logged(&base, &source("secret.txt")), denied); + assert_eq!(logged(&base, &source("nope.txt")), denied, "MAIN exists beside main"); + } + let _ = std::fs::remove_dir_all(base); +} + #[test] fn script_file_oversize_refused_before_read() { let base = folder("oversize"); diff --git a/docs/mcp.md b/docs/mcp.md index 5826c5c8c..7f75de7c3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1837,7 +1837,8 @@ VectorCraft reads files by these rules of its own: line, the files must also be inside them. A path outside, a Windows network path (`\\host\share`) and a device path (`\\?\`, `\\.\`) 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. + `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. - `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.