Scripts: #include and the other preprocessor lines run, and $.evalFile runs a file

Before a script runs, `script.run` reads its preprocessor lines (`#` or
`//@` at the start of a line, outside strings and comments):

- `#include` and `//@include` put another file's text in place of the
  line, looked for beside the including file, then in each
  `#includepath` folder, then in each `JSINCLUDE` folder. A later
  `#includepath` replaces the list.
- `#script` names the run unless `name` is given; `#target` takes any
  value; `#strict off` is taken. `#strict on`, `#targetengine` and
  `#engine` are refused as unsupported, and any other `#name` as
  unknown.

`$.evalFile(path, timeout?)` runs a file in the same global environment
and returns its last value, `$.fileName` is the file being run and
`$.includePath` the include folders.

A script run from a file reads these files only in its folder and the
folders below it. Paths with a `..` name, links or junctions that lead
out, device names, streams and network paths are refused; a script
given as text reads none. A run reads at most 64 files 16 levels deep,
32 MiB of source in all and 1,024 paths looked up. An error in an
included or evaluated file is reported at that file's line and column,
and the report's new `file` field names it.

Docs: docs/mcp.md (script.run, Script Console), docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-11-10 21:59:22 -08:00
parent 0695db3ec0
commit 693031c509
17 changed files with 1194 additions and 51 deletions
+327
View File
@@ -0,0 +1,327 @@
//! A script's preprocessor lines: `#include` and `//@include` in place, the folders they are looked
//! for in, the run's limits and scope, the other lines, `$.evalFile`, `$.fileName`, and errors at the
//! line of the file they are in.
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
use crate::script::TEST_JSINCLUDE;
use crate::*;
/// A fresh folder for test `name`, with a `main` folder inside it for the script.
fn folder(name: &str) -> PathBuf {
let base = std::env::temp_dir().join(format!("vc-script-include-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join("main")).unwrap();
base
}
fn write(path: &Path, text: &str) {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).unwrap();
}
std::fs::write(path, text).unwrap();
}
/// Runs `main/main.jsx` in `base`, with the text `text`.
fn run(base: &Path, text: &str) -> Result<Value> {
let main = base.join("main/main.jsx");
write(&main, text);
Session::new().execute("script.run", &json!({"path": main.to_string_lossy()}))
}
/// The report of the run that failed with `e`.
fn failed(base: &Path, text: &str) -> Value {
let mut s = Session::new();
let main = base.join("main/main.jsx");
write(&main, text);
let e = s.execute("script.run", &json!({"path": main.to_string_lossy()}));
assert!(e.is_err(), "the run failed: {e:?}");
s.script_report().unwrap().clone()
}
#[test]
fn script_include_sibling_runs_in_place() {
let base = folder("sibling");
write(&base.join("main/lib.jsxinc"), "var b = a + 1;");
write(&base.join("main/two.jsxinc"), "b = b * 10;\r\n");
let r = run(&base, "var a = 1;\n #include \"lib.jsxinc\"\n//@include 'two.jsxinc'\nvc.log(a + b);").unwrap();
assert_eq!(r["log"], json!([21]));
// A file may be included again once its earlier inclusion has ended.
let r = run(&base, "var n = 0;\n#include \"three.jsxinc\"\n#include \"three.jsxinc\"\nvc.log(n);");
assert!(r.is_err(), "three.jsxinc is missing");
write(&base.join("main/three.jsxinc"), "n++;");
let r = run(&base, "var n = 0;\n#include \"three.jsxinc\"\n#include three.jsxinc\nvc.log(n);").unwrap();
assert_eq!(r["log"], json!([2]));
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn script_nested_include_uses_including_folder() {
let base = folder("nested");
write(&base.join("main/sub/one.jsxinc"), "#include \"two.jsxinc\"\nvar one = two + 1;");
write(&base.join("main/sub/two.jsxinc"), "var two = 2;");
write(&base.join("main/two.jsxinc"), "var two = 200;");
let r = run(&base, "#include \"sub/one.jsxinc\"\nvc.log(one);").unwrap();
assert_eq!(r["log"], json!([3]), "two.jsxinc beside one.jsxinc, not beside the script");
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn script_missing_include_reports_origin() {
let base = folder("missing");
let report = failed(&base, "vc.log(1);\n\t#include \"nope.jsxinc\"\n");
assert_eq!(report["kind"], "thrown");
assert_eq!((report["line"].as_u64(), report["column"].as_u64()), (Some(2), Some(2)));
assert!(report["message"].as_str().unwrap().contains("include file not found: nope.jsxinc"), "{report}");
assert_eq!(report["log"], json!([]), "nothing ran before the include was read");
assert!(report.get("file").is_none(), "the line is the script's own");
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn script_includepath_searches_in_order() {
let base = folder("includepath");
write(&base.join("main/a/x.jsxinc"), "var x = 'a';");
write(&base.join("main/b/x.jsxinc"), "var x = 'b';");
write(&base.join("main/b/y.jsxinc"), "var y = 'b';");
let r = run(&base, "#includepath \"a; b\"\n#include \"x.jsxinc\"\n#include \"y.jsxinc\"\nvc.log(x + y + $.includePath);").unwrap();
assert_eq!(r["log"], json!(["aba;b"]));
// A later #includepath replaces the list.
let report = failed(&base, "#includepath \"b\"\n#includepath \"a\"\n#include \"y.jsxinc\"");
assert!(report["message"].as_str().unwrap().contains("include file not found: y.jsxinc"), "{report}");
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn script_include_cycle_and_depth_stop() {
let base = folder("cycle");
write(&base.join("main/a.jsxinc"), "#include \"b.jsxinc\"");
write(&base.join("main/b.jsxinc"), "\n#include \"a.jsxinc\"");
let report = failed(&base, "#include \"a.jsxinc\"");
assert!(report["message"].as_str().unwrap().contains("include cycle: a.jsxinc"), "{report}");
assert_eq!(report["line"], 2);
assert!(report["file"].as_str().unwrap().ends_with("b.jsxinc"), "{report}");
// A script that includes itself.
let report = failed(&base, "#include \"main.jsx\"");
assert!(report["message"].as_str().unwrap().contains("include cycle"), "{report}");
for i in 0..20 {
write(&base.join(format!("main/d{i}.jsxinc")), &format!("#include \"d{}.jsxinc\"", i + 1));
}
let report = failed(&base, "#include \"d0.jsxinc\"");
assert_eq!(report["kind"], "limit");
assert!(report["message"].as_str().unwrap().contains("include depth limit (16) reached"), "{report}");
assert!(report["file"].as_str().unwrap().ends_with("d15.jsxinc"), "the 17th level is refused: {report}");
let _ = std::fs::remove_dir_all(base);
}
/// Makes `link` lead to the folder `target`: a junction on Windows, a symbolic link elsewhere.
/// `false` when the system does not allow it here.
fn link_folder(target: &Path, link: &Path) -> bool {
#[cfg(windows)]
{
let made = std::process::Command::new("cmd").args(["/C", "mklink", "/J"]).arg(link).arg(target).output().is_ok_and(|o| o.status.success());
made && std::fs::canonicalize(link).ok() == std::fs::canonicalize(target).ok()
}
#[cfg(unix)]
{
std::os::unix::fs::symlink(target, link).is_ok()
}
#[cfg(not(any(unix, windows)))]
{
let _ = (target, link);
false
}
}
#[test]
fn script_include_link_out_of_the_folder_is_refused() {
let base = folder("junction");
write(&base.join("outside/x.jsxinc"), "var leaked = 1;");
// `mklink` reads a `/` in its paths as a switch, so each name is joined on its own.
if !link_folder(&base.join("outside"), &base.join("main").join("away")) {
eprintln!("skipped: a folder link can't be made here");
let _ = std::fs::remove_dir_all(base);
return;
}
assert!(std::fs::canonicalize(base.join("main/away/x.jsxinc")).unwrap().starts_with(std::fs::canonicalize(base.join("outside")).unwrap()));
let report = failed(&base, "#include \"away/x.jsxinc\"\nvc.log(leaked);");
assert!(report["message"].as_str().unwrap().contains("script file read refused: away/x.jsxinc"), "{report}");
let report = failed(&base, "$.evalFile('away/x.jsxinc');");
assert!(report["message"].as_str().unwrap().contains("script file read refused: away/x.jsxinc"), "{report}");
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn script_include_parent_component_refused() {
let base = folder("parent");
write(&base.join("outside.jsxinc"), "var leaked = 1;");
write(&base.join("main/sub/x.jsxinc"), "var x = 1;");
let report = failed(&base, "#include \"../outside.jsxinc\"");
assert!(report["message"].as_str().unwrap().contains("script file read refused: ../outside.jsxinc"), "{report}");
// Refused even where the path would come back inside the folder.
let report = failed(&base, "#include \"sub/../sub/x.jsxinc\"");
assert!(report["message"].as_str().unwrap().contains("script file read refused"), "{report}");
let report = failed(&base, "#includepath \"..\"\n#include \"outside.jsxinc\"");
assert!(report["message"].as_str().unwrap().contains("script file read refused: outside.jsxinc"), "{report}");
let absolute = base.join("outside.jsxinc").to_string_lossy().replace('\\', "/");
let report = failed(&base, &format!("#include \"{absolute}\""));
assert!(report["message"].as_str().unwrap().contains("script file read refused"), "an absolute path outside: {report}");
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn script_jsinclude_outside_grants_ignored() {
let base = folder("jsinclude");
write(&base.join("outside/x.jsxinc"), "var x = 'outside';");
write(&base.join("main/lib/y.jsxinc"), "var y = 'lib';");
TEST_JSINCLUDE.with(|t| *t.borrow_mut() = Some(vec![base.join("outside"), base.join("main/lib")]));
let report = failed(&base, "#include \"x.jsxinc\"");
let r = run(&base, "#include \"y.jsxinc\"\nvc.log(y);");
TEST_JSINCLUDE.with(|t| *t.borrow_mut() = None);
assert!(report["message"].as_str().unwrap().contains("include file not found: x.jsxinc"), "passed over, not refused: {report}");
assert_eq!(r.unwrap()["log"], json!(["lib"]), "a JSINCLUDE folder inside the script's folder is searched");
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn script_target_other_app_does_not_route() {
let mut s = Session::new();
let r = s.execute("script.run", &json!({"source": "#target someapp-12\n#target \"other\"\n//@target x\n#strict off\nvc.log(1);"})).unwrap();
assert_eq!(r["log"], json!([1]));
for (source, message) in [
("#target\nvc.log(1);", "#target needs a value"),
("#targetengine \"session\"", "unsupported script directive: #targetengine"),
("#engine \"main\"", "unsupported script directive: #engine"),
("#strict on", "unsupported strict checking: #strict on"),
("#strict maybe", "#strict takes on or off"),
("#frobnicate 1", "unknown script directive: #frobnicate"),
("#include \"x.jsxinc", "unterminated quote in #include"),
("#include \"x.jsxinc\"", "script file read refused: x.jsxinc"),
] {
let e = s.execute("script.run", &json!({"source": format!("vc.log(0);\n{source}")})).unwrap_err().to_string();
assert!(e.contains(message), "{source}: {e}");
assert_eq!(s.script_report().unwrap()["line"], 2, "{source}");
assert_eq!(s.script_report().unwrap()["log"], json!([]), "{source}");
}
// An unknown `//@` name stays a comment.
assert_eq!(s.execute("script.run", &json!({"source": "//@frobnicate 1\nvc.log(2);"})).unwrap()["log"], json!([2]));
}
#[test]
fn script_name_comes_from_the_script_line() {
let mut s = Session::new();
let r = s.execute("script.run", &json!({"source": "#script \"First\"\n#script Second\nvc.log($.fileName);"})).unwrap();
assert_eq!((r["name"].as_str(), r["log"].clone()), (Some("Second"), json!([""])));
let r = s.execute("script.run", &json!({"source": "#script Second\n", "name": "given.js"})).unwrap();
assert_eq!(r["name"], "given.js", "a name given to script.run wins");
let base = folder("name");
write(&base.join("main/lib.jsxinc"), "#script Inner\n");
let r = run(&base, "#include \"lib.jsxinc\"").unwrap();
assert_eq!(r["name"], "main.jsx", "an included file's #script line does not rename the run");
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn script_strict_on_is_refused() {
let mut s = Session::new();
let e = s.execute("script.run", &json!({"source": "#strict on\nvc.log(1);"})).unwrap_err().to_string();
assert!(e.contains("unsupported strict checking: #strict on"), "{e}");
assert_eq!(s.script_report().unwrap()["kind"], "syntax");
}
#[test]
fn script_eval_file_returns_last_value_and_shares_globals() {
let base = folder("evalfile");
write(&base.join("main/sub/e.jsx"), "var inner = $.fileName;\nfunction h(x) { return x * g; }\n#include \"f.jsxinc\"\ng + f;");
write(&base.join("main/sub/f.jsxinc"), "var f = 40;");
write(&base.join("main/empty.jsx"), "");
let r = run(
&base,
"var g = 2;\nvar r = $.evalFile('sub/e.jsx', 1000);\nvc.log(r);\nvc.log(h(3));\nvc.log(/e\\.jsx$/.test(inner));\nvc.log(/main\\.jsx$/.test($.fileName));\nvc.log($.evalFile('empty.jsx') === undefined);",
)
.unwrap();
assert_eq!(r["log"], json!([42, 6, true, true, true]));
// Its errors can be caught; a file is evaluated again after it has returned.
let r = run(
&base,
"var g = 1; var out = [];\ntry { $.evalFile(''); } catch (e) { out.push(e.name); }\ntry { $.evalFile('nope.jsx'); } catch (e) { out.push(String(e)); }\ntry { $.evalFile('sub/e.jsx', -1); } catch (e) { out.push(e.name); }\ntry { $.evalFile('sub/e.jsx', Infinity); } catch (e) { out.push(e.name); }\nout.push($.evalFile('sub/e.jsx') + $.evalFile('sub/e.jsx'));\nvc.log(out);",
)
.unwrap();
assert_eq!(r["log"], json!([["TypeError", "Error: include file not found: nope.jsx", "RangeError", "TypeError", 82]]));
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn script_eval_file_outside_scope_refused() {
let base = folder("evaloutside");
write(&base.join("outside.jsx"), "1;");
let absolute = base.join("outside.jsx").to_string_lossy().replace('\\', "/");
let script = format!(
"var out = [];\ntry {{ $.evalFile('../outside.jsx'); }} catch (e) {{ out.push(String(e)); }}\ntry {{ $.evalFile('{absolute}'); }} catch (e) {{ out.push(String(e)); }}\nvc.log(out);"
);
let r = run(&base, &script).unwrap();
let out = r["log"][0].as_array().unwrap();
assert_eq!(out.len(), 2);
for e in out {
assert!(e.as_str().unwrap().starts_with("Error: script file read refused"), "{e}");
}
// A script given as text has no folder to read from.
let e = Session::new().execute("script.run", &json!({"source": "$.evalFile('x.jsx');"})).unwrap_err().to_string();
assert!(e.contains("script file read refused: x.jsx"), "{e}");
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn script_directives_in_strings_and_block_comments_are_inert() {
let mut s = Session::new();
let source = "#target x\nvar a = '#include \"nope.jsxinc\"';\n/*\n#include \"nope.jsxinc\"\n//@include \"nope.jsxinc\"\n*/\nvar b = 1; // #include \"nope.jsxinc\"\nvar c = 2; #include\nvc.log(a.length + b);";
let e = s.execute("script.run", &json!({"source": source})).unwrap_err().to_string();
// `#` after code on a line is not a preprocessor line: the script has a syntax error there.
assert!(e.contains("line 8"), "{e}");
let source = source.replace("var c = 2; #include\n", "");
assert_eq!(s.execute("script.run", &json!({"source": source})).unwrap()["log"], json!([23]));
}
#[test]
fn script_included_error_has_local_file_and_line() {
let base = folder("errors");
write(&base.join("main/lib.jsxinc"), "var x = 1;\n\n throw new Error('boom');\n");
write(&base.join("main/bad.jsxinc"), "var y = 1;\nvar = 2;\n");
write(&base.join("main/five.jsxinc"), "1;\n2;\n3;\n4;\n5;");
write(&base.join("main/sub/thrower.jsx"), "\n\nthrow new Error('deep');");
let mut s = Session::new();
let report = failed(&base, "var z = 0;\n#include \"lib.jsxinc\"");
assert_eq!((report["line"].as_u64(), report["column"].as_u64()), (Some(3), Some(5)), "{report}");
assert!(report["file"].as_str().unwrap().ends_with("lib.jsxinc"), "{report}");
let report = failed(&base, "#include \"bad.jsxinc\"");
assert_eq!((report["kind"].as_str(), report["line"].as_u64()), (Some("syntax"), Some(2)), "{report}");
assert!(report["file"].as_str().unwrap().ends_with("bad.jsxinc"), "{report}");
// A line of the script after an include keeps its own number.
let report = failed(&base, "#include \"five.jsxinc\"\n throw new Error('after');");
assert_eq!((report["line"].as_u64(), report["column"].as_u64()), (Some(2), Some(3)), "{report}");
assert!(report.get("file").is_none(), "{report}");
let report = failed(&base, "#include \"five.jsxinc\"\nvar = 1;");
assert_eq!((report["kind"].as_str(), report["line"].as_u64()), (Some("syntax"), Some(2)), "{report}");
// An uncaught error in a file $.evalFile ran is at that file's line.
let report = failed(&base, "var a = 1;\n$.evalFile('sub/thrower.jsx');");
assert_eq!(report["line"], 3, "{report}");
assert!(report["file"].as_str().unwrap().ends_with("thrower.jsx"), "{report}");
// So is a file $.evalFile could not run: a syntax error, or a bad preprocessor line.
write(&base.join("main/sub/broken.jsx"), "var a = 1;\n\nvar = 2;");
write(&base.join("main/sub/directive.jsx"), "\n#frobnicate 1");
for (file, line) in [("broken.jsx", 3), ("directive.jsx", 2)] {
let report = failed(&base, &format!("var a = 1;\n$.evalFile('sub/{file}');"));
assert_eq!(report["line"], line, "{report}");
assert!(report["file"].as_str().unwrap().ends_with(file), "{report}");
}
// The console's error line names the file.
let main = base.join("main/main.jsx");
write(&main, "#include \"lib.jsxinc\"");
assert!(s.execute("script.run", &json!({"path": main.to_string_lossy()})).is_err());
let console = s.execute("script.console", &json!({})).unwrap();
let error = console["lines"].as_array().unwrap().iter().find(|l| l["kind"] == "error").unwrap()["text"].as_str().unwrap().to_string();
assert!(error.starts_with("main.jsx: ") && error.contains("lib.jsxinc:3:5: Error: boom"), "{error}");
let _ = std::fs::remove_dir_all(base);
}