Scripts: #strict on runs the script with strict write checks instead of refusing it

`#strict on` and `//@strict on` no longer end the run as unsupported. They
turn on the checks the dollar-object help page gives for `$.strict`
(https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/):
an assignment to a read-only property throws a TypeError, and so does a new
property on an object that takes none.

VectorCraft's own rules: a getter-only property counts as read-only; the
objects that take no new properties are the non-extensible ones
(preventExtensions, seal, freeze, and the frozen vc and $ objects); the last
`#strict` line of the script and its includes sets the whole run, and runs
without one are unchanged; eval and Function take the checks of the code
that compiles them; a file $.evalFile runs keeps its own line's checks for
its code and functions, or takes the caller's without one. The rest of ES5
strict mode stays with 'use strict'.

The js crate carries the setting per compiled source
(Realm::set_strict_writes, and a strict_writes callback on
HostItem::Evaluator), and applies it only to property assignments.
This commit is contained in:
dotCooCoo
2026-11-11 02:04:49 -08:00
parent e1c199a184
commit 08eca05731
13 changed files with 299 additions and 38 deletions
+126 -5
View File
@@ -194,7 +194,6 @@ fn script_target_other_app_does_not_route() {
("#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"),
@@ -223,12 +222,134 @@ fn script_name_comes_from_the_script_line() {
let _ = std::fs::remove_dir_all(base);
}
/// The script's `ro`, with a read-only `x`.
const READ_ONLY: &str = "var ro = {};\nObject.defineProperty(ro, 'x', {value: 1});\n";
#[test]
fn script_strict_on_is_refused() {
fn strict_on_runs_and_read_only_write_throws() {
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");
for head in ["#strict on\n", "//@strict on\n", "#strict ON\n", "#strict off\n#strict on\n"] {
let source = format!("{head}{READ_ONLY}vc.log(1);\n ro.x = 2;\nvc.log(2);");
let e = s.execute("script.run", &json!({"source": source})).unwrap_err().to_string();
assert!(e.contains("TypeError: cannot assign to property 'x': it is read-only"), "{head}: {e}");
let report = s.script_report().unwrap();
assert_eq!(report["kind"], "thrown", "{head}");
assert_eq!(report["log"], json!([1]), "{head}: the script ran up to the write");
let line = head.lines().count() as u64 + 4;
assert_eq!((report["line"].as_u64(), report["column"].as_u64()), (Some(line), Some(3)), "{head}: {report}");
}
// The error can be caught, and every kind of assignment is checked.
let source = format!(
"#strict on\n{READ_ONLY}var out = [];\nfunction t(f) {{ try {{ f(); out.push('wrote'); }} catch (e) {{ out.push(e.name); }} }}\nt(function () {{ ro.x = 2; }});\nt(function () {{ ro['x'] += 1; }});\nt(function () {{ ro.x++; }});\nt(function () {{ undefined = 1; }});\nt(function () {{ [].concat.length = 0; }});\nvc.log(out);\nvc.log(ro.x);"
);
let r = s.execute("script.run", &json!({"source": source})).unwrap();
assert_eq!(r["log"], json!([["TypeError", "TypeError", "TypeError", "TypeError", "TypeError"], 1]));
// Without the line, or with `#strict off` last, the write is ignored as before.
for head in ["", "#strict off\n", "#strict on\n#strict off\n"] {
let r = s.execute("script.run", &json!({"source": format!("{head}{READ_ONLY}ro.x = 2;\nvc.log(ro.x);")})).unwrap();
assert_eq!(r["log"], json!([1]), "{head}");
}
}
#[test]
fn strict_on_rejects_getter_only_and_nonextensible_additions() {
let mut s = Session::new();
let source = "#strict on\nvar out = [];\nfunction t(f) { try { f(); out.push('wrote'); } catch (e) { out.push(String(e)); } }\n\
var g = {get v() { return 1; }}, set = {set v(x) { this.got = x; }};\n\
t(function () { g.v = 2; });\n\
t(function () { Object.preventExtensions({}).n = 1; });\n\
t(function () { Object.seal({a: 1}).n = 1; });\n\
t(function () { Object.freeze({a: 1}).a = 2; });\n\
t(function () { vc.extra = 1; });\n\
var open = {};\nt(function () { open.n = 1; });\nt(function () { set.v = 3; });\nt(function () { Object.seal({a: 1}).a = 2; });\n\
vc.log(out);\nvc.log(open.n);\nvc.log(set.got);";
let r = s.execute("script.run", &json!({"source": source})).unwrap();
assert_eq!(
r["log"],
json!([
[
"TypeError: cannot assign to property 'v': it has only a getter",
"TypeError: cannot assign to property 'n': the object is not extensible",
"TypeError: cannot assign to property 'n': the object is not extensible",
"TypeError: cannot assign to property 'a': it is read-only",
"TypeError: cannot assign to property 'extra': the object is not extensible",
"wrote",
"wrote",
"wrote"
],
1,
3
])
);
let r = s.execute("script.run", &json!({"source": source.replacen("#strict on", "#strict off", 1)})).unwrap();
assert_eq!(r["log"][0], json!(["wrote", "wrote", "wrote", "wrote", "wrote", "wrote", "wrote", "wrote"]), "off, nothing is refused");
}
#[test]
fn strict_on_does_not_enable_es5_early_errors() {
let mut s = Session::new();
let source = "#strict on\nwith ({w: 1}) { vc.log(w); }\nfunction f(a, a) { return a; }\nvc.log(f(1, 2));\nvc.log(010);\nundeclared = 4;\nvc.log(undeclared);\n\
vc.log((function () { return typeof this; })());\nvc.log(delete Object.prototype);\nvar eval2 = 5;\nvc.log((function () { arguments = 6; return arguments; })());";
let r = s.execute("script.run", &json!({"source": source})).unwrap();
assert_eq!(r["log"], json!([1, 2, 8, 4, "object", false, 6]));
// 'use strict' still makes the code strict mode code.
let e = s.execute("script.run", &json!({"source": "#strict on\n'use strict';\nundeclared2 = 1;"})).unwrap_err().to_string();
assert!(e.contains("ReferenceError"), "{e}");
let e = s.execute("script.run", &json!({"source": "#strict on\n'use strict';\nwith ({}) {}"})).unwrap_err().to_string();
assert_eq!(s.script_report().unwrap()["kind"], "syntax", "{e}");
}
#[test]
fn strict_on_in_include_controls_expanded_run() {
let base = folder("strict");
write(&base.join("main/on.jsxinc"), "#strict on\nvar inc = 1;");
write(&base.join("main/off.jsxinc"), "//@strict off\n");
write(&base.join("main/writes.jsxinc"), &format!("{READ_ONLY}\n ro.x = 2;\n"));
// An included file's line sets the checks for the script's own code too.
let report = failed(&base, &format!("#include \"on.jsxinc\"\n{READ_ONLY}ro.x = 2;"));
assert!(report["message"].as_str().unwrap().contains("it is read-only"), "{report}");
assert_eq!(report["line"], 4, "{report}");
assert!(report.get("file").is_none(), "{report}");
// A write in an included file is reported at its own line.
let report = failed(&base, "#strict on\n#include \"writes.jsxinc\"");
assert!(report["message"].as_str().unwrap().contains("it is read-only"), "{report}");
assert_eq!((report["line"].as_u64(), report["column"].as_u64()), (Some(4), Some(3)), "{report}");
assert!(report["file"].as_str().unwrap().ends_with("writes.jsxinc"), "{report}");
// The last line of the expanded run wins, wherever it is.
let r = run(&base, &format!("#strict on\n#include \"off.jsxinc\"\n{READ_ONLY}ro.x = 2;\nvc.log(ro.x);")).unwrap();
assert_eq!(r["log"], json!([1]));
let report = failed(&base, &format!("#include \"off.jsxinc\"\n#strict on\n{READ_ONLY}ro.x = 2;"));
assert!(report["message"].as_str().unwrap().contains("it is read-only"), "{report}");
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn strict_mode_in_eval_file_is_scoped() {
let base = folder("strict-eval");
write(
&base.join("main/on.jsx"),
&format!(
"#strict on\n{READ_ONLY}function w(o) {{ o.x = 2; }}\nvar inner;\ntry {{ ro.x = 2; inner = 'wrote'; }} catch (e) {{ inner = e.name; }}\n"
),
);
write(&base.join("main/off.jsx"), &format!("#strict off\n{READ_ONLY}ro.x = 2;\n'off ran';"));
write(&base.join("main/plain.jsx"), &format!("{READ_ONLY}ro.x = 2;\n'plain ran';"));
// A file's own `#strict on` holds for its code, its functions included, and not the caller's.
let r = run(
&base,
"$.evalFile('on.jsx');\nvar after;\ntry { ro.x = 3; after = 'ignored'; } catch (e) { after = e.name; }\nvar called;\ntry { w(ro); called = 'wrote'; } catch (e) { called = e.name; }\nvc.log([inner, after, called]);",
)
.unwrap();
assert_eq!(r["log"], json!([["TypeError", "ignored", "TypeError"]]));
// A file without the line takes the caller's checks, and its own `#strict off` turns them off.
let r = run(&base, "#strict on\nvc.log($.evalFile('off.jsx'));\nvar e1;\ntry { ro.x = 2; } catch (e) { e1 = e.name; }\nvc.log(e1);").unwrap();
assert_eq!(r["log"], json!(["off ran", "TypeError"]), "the caller keeps its own checks");
let report = failed(&base, "#strict on\n$.evalFile('plain.jsx');");
assert!(report["message"].as_str().unwrap().contains("it is read-only"), "{report}");
assert!(report["file"].as_str().unwrap().ends_with("plain.jsx"), "{report}");
assert_eq!(report["line"], 3, "{report}");
assert_eq!(run(&base, "vc.log($.evalFile('plain.jsx'));").unwrap()["log"], json!(["plain ran"]));
let _ = std::fs::remove_dir_all(base);
}
#[test]