Merge pull request #6707 from dotCooCoo/fix/script-case-variant-folder-refused

Scripts: a missing file outside the script's folder is refused like an existing one
This commit is contained in:
BFlat
2026-11-12 09:10:34 +07:00
committed by GitHub
3 changed files with 57 additions and 2 deletions
+16 -1
View File
@@ -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,18 @@ 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. 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(|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.
pub(crate) fn stat(&self, path: &Path) -> Result<ScriptEntry, ScriptRead> {
let real = self.locate(path)?;
+39
View File
@@ -153,6 +153,20 @@ 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]]));
// 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)]
@@ -176,6 +190,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");
+2 -1
View File
@@ -1876,7 +1876,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. 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.