Merge pull request #6569 from dotCooCoo/feat/script-strict-on

Scripts: #strict on runs the script with strict write checks instead of refusing it
This commit is contained in:
BFlat
2026-11-11 18:45:03 +07:00
committed by GitHub
14 changed files with 300 additions and 39 deletions
File diff suppressed because one or more lines are too long
+23 -12
View File
@@ -1,8 +1,11 @@
//! A script's preprocessor lines, read before it runs, as the public guide lists them
//! (<https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/>):
//! `#include` and `//@include` put the text of another file in place of the line, `#includepath`
//! sets the folders `#include` looks in, `#script` names the run, and `#target` and `#strict off`
//! are accepted. The same lines are read in a file `$.evalFile` runs.
//! sets the folders `#include` looks in, `#script` names the run, `#strict on` turns on the checks
//! of `$.strict` (<https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/>:
//! a write to a read-only property is a runtime error, and objects that take no new properties
//! refuse them), and `#target` and `#strict off` are accepted. The same lines are read in a file
//! `$.evalFile` runs.
//!
//! VectorCraft's own rules:
//!
@@ -13,9 +16,15 @@
//! the `JSINCLUDE` environment variable. A later `#includepath` replaces the list.
//! - Files are read through the script's [`ScriptScope`]: a refused path ends the run, except a
//! `JSINCLUDE` folder outside the scope, which is passed over.
//! - `#target` takes any value and changes nothing. `#targetengine`, `#engine` and `#strict on` are
//! refused as unsupported, and any other `#name` as unknown; `//@name` with another name stays a
//! comment.
//! - `#target` takes any value and changes nothing. `#targetengine` and `#engine` are refused as
//! unsupported, and any other `#name` as unknown; `//@name` with another name stays a comment.
//! - The last `#strict on` or `#strict off` of the script and the files it includes sets the checks
//! for the whole run; without one they are off. A write to a property that has only a getter is
//! checked as a read-only one, the objects that take no new properties are those that are not
//! extensible (`Object.preventExtensions`, `Object.seal`, `Object.freeze`), and the rest of ES5
//! strict mode is left to `'use strict'`. Code that `eval` and `Function` compile takes the
//! checks of the code that compiles it; a file `$.evalFile` runs takes its own `#strict` line if
//! it has one, and otherwise the caller's.
//! - A run reads at most [`MAX_DEPTH`] levels of nested files, [`MAX_FILES`] files,
//! [`MAX_TOTAL_BYTES`] of source in all (the script's own included) and 8 MiB a file, and looks up
//! at most [`MAX_PROBES`] paths. A file may be included again once its earlier inclusion has
@@ -97,12 +106,13 @@ pub(crate) struct Failure {
pub column: u32,
}
/// A source with its preprocessor lines read: the text to run, where its lines came from, and the
/// name its `#script` line gave.
/// A source with its preprocessor lines read: the text to run, where its lines came from, the
/// name its `#script` line gave, and what its last `#strict` line set (`None` without one).
pub(crate) struct Expanded {
pub source: String,
pub map: SourceMap,
pub name: Option<String>,
pub strict: Option<bool>,
}
/// A file being run: its name for people and the folder its relative paths start from (none for a
@@ -130,13 +140,14 @@ pub(crate) struct Files {
}
/// The output being built: its UTF-16 text, the line it is on, the units since that line began,
/// and whether its last unit is a carriage return.
/// whether its last unit is a carriage return, and what the last `#strict` line so far set.
struct Out {
units: Vec<u16>,
line: u32,
column: u32,
after_cr: bool,
map: SourceMap,
strict: Option<bool>,
}
impl Out {
@@ -330,7 +341,7 @@ impl Files {
/// `text`, the file `shown` in folder `dir`, with its preprocessor lines read. With `script`,
/// its `#script` line names the run.
fn expand(&mut self, shown: Arc<str>, dir: Option<PathBuf>, text: &str, script: bool) -> Result<Expanded, Failure> {
let mut out = Out { units: Vec::with_capacity(text.len()), line: 1, column: 0, after_cr: false, map: SourceMap::default() };
let mut out = Out { units: Vec::with_capacity(text.len()), line: 1, column: 0, after_cr: false, map: SourceMap::default(), strict: None };
let mut name = None;
self.file(&mut out, shown, dir.as_deref(), text, script, &mut name)?;
let source = String::from_utf16(&out.units).map_err(|_| Failure {
@@ -341,7 +352,7 @@ impl Files {
line: 0,
column: 0,
})?;
Ok(Expanded { source, map: out.map, name })
Ok(Expanded { source, map: out.map, name, strict: out.strict })
}
/// Adds file `shown` (in folder `dir`, with text `text`) to `out`, its included files in place
@@ -414,8 +425,8 @@ impl Files {
}
}
Some(DirectiveName::Strict) => match value.to_ascii_lowercase().as_str() {
"off" => {}
"on" => return Err(at(FailKind::Syntax, format!("unsupported strict checking: {label} on"))),
"off" => out.strict = Some(false),
"on" => out.strict = Some(true),
_ => return Err(at(FailKind::Syntax, format!("{label} takes on or off"))),
},
Some(DirectiveName::TargetEngine) => return Err(at(FailKind::Syntax, format!("unsupported script directive: {label}"))),
+11 -1
View File
@@ -499,6 +499,8 @@ struct Host {
/// The last error `$.evalFile` raised for a file it could not run, with the file, line and
/// column that error is at.
eval_failure: RefCell<Option<EvalFailure>>,
/// What the last `#strict` line of the file `$.evalFile` last gave set (`None` without one).
eval_strict: Cell<Option<bool>>,
}
/// An error `$.evalFile` raised, as thrown, and the file, line and column it is at.
@@ -957,7 +959,12 @@ fn dollar_object(host: &Rc<Host>) -> Vec<(String, HostItem)> {
let eval_file = {
let h = host.clone();
let end = host.clone();
HostItem::Evaluator { source: function(move |a| eval_file(&h, a)), end: Rc::new(move || end.files.borrow_mut().end_eval()) }
let strict = host.clone();
HostItem::Evaluator {
source: function(move |a| eval_file(&h, a)),
end: Rc::new(move || end.files.borrow_mut().end_eval()),
strict_writes: Rc::new(move || strict.eval_strict.get()),
}
};
vec![
("write".into(), writer(false)),
@@ -1006,6 +1013,7 @@ fn eval_file(host: &Host, args: Vec<Data>) -> std::result::Result<Data, HostErro
return Err(HostError::Syntax(message));
}
host.eval_maps.borrow_mut().push(expanded.map);
host.eval_strict.set(expanded.strict);
Ok(Data::String(expanded.source))
}
@@ -1302,6 +1310,7 @@ impl Session {
script_map: RefCell::default(),
eval_maps: RefCell::default(),
eval_failure: RefCell::default(),
eval_strict: Cell::new(None),
});
let args = to_data(&Value::Object(request.args), &DataLimits::default(), 0, &mut (0, 0))
.map_err(|place| EngineError::BadParams { cmd: "script.run".into(), msg: format!("`args{place}` is nested more than 64 levels deep") })?;
@@ -1311,6 +1320,7 @@ impl Session {
Err(f) => Err(preprocessor_error(f)),
Ok(expanded) => {
*host.script_map.borrow_mut() = expanded.map;
realm.set_strict_writes(expanded.strict == Some(true));
realm
.define_host_object("vc", vc_object(&host, args))
.and_then(|()| realm.define_host_object("$", dollar_object(&host)))
+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]
+1 -1
View File
@@ -472,7 +472,7 @@ fn function_ctor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsRe
Ok(p) => p,
Err(e) => return Err(interp.throw(ErrorType::Syntax, &e.message)),
};
let source = Rc::new(Source { lines: program.lines.clone(), text: units.into(), tag: interp.source_tag });
let source = Rc::new(Source { lines: program.lines.clone(), text: units.into(), tag: interp.source_tag, strict_writes: interp.writes_mode() });
let code = match compile_constructed_function(&program, &source) {
Ok(Some(code)) => code,
Ok(None) => return Err(interp.throw(ErrorType::Syntax, "the parameters or body do not form one function")),
+3
View File
@@ -173,6 +173,9 @@ pub(crate) struct Source {
pub(crate) text: Rc<[u16]>,
/// Which source this is, as [`crate::Realm::error_source`] gives it.
pub(crate) tag: u32,
/// Writes to read-only and getter-only properties, and new properties on objects that are
/// not extensible, throw in this source's code even where it is not strict mode code.
pub(crate) strict_writes: bool,
}
#[derive(Debug)]
+1 -1
View File
@@ -184,7 +184,7 @@ pub(crate) enum Func {
Native { f: NativeFn, kind: NativeKind, constructor: bool, name: &'static str },
Host { f: Rc<HostFn>, name: Rc<str> },
Data { f: DataFn, name: Rc<str>, read: crate::host::ArgRead },
Eval { f: DataFn, end: Rc<dyn Fn()>, name: Rc<str> },
Eval { f: DataFn, end: Rc<dyn Fn()>, strict_writes: Rc<dyn Fn() -> Option<bool>>, name: Rc<str> },
Bound { target: ObjId, this: Value, args: Rc<[Value]> },
}
+18 -6
View File
@@ -111,10 +111,12 @@ pub enum HostItem {
/// the call gives that code's completion value, and its declarations become globals. A result
/// that is not a string is the call's result. `end` is called when the source has run,
/// normally or not. Each source given has the next tag of [`crate::Realm::error_source`],
/// from 1.
/// from 1. `strict_writes`, called after `source`, gives [`crate::Realm::set_strict_writes`]
/// for that source alone; with `None` it takes the calling code's.
Evaluator {
source: HostFunction,
end: Rc<dyn Fn()>,
strict_writes: Rc<dyn Fn() -> Option<bool>>,
},
}
@@ -343,17 +345,26 @@ impl Interp {
/// Calls evaluator `f` ([`HostItem::Evaluator`]): reads its arguments, runs it, and runs the
/// source it gives as global code, as an indirect `eval` does, giving that code's completion
/// value. A result that is not a string is given to the script as it is. `end` is called once
/// the source has run, normally or not.
pub(crate) fn call_evaluator(&mut self, f: &crate::heap::DataFn, end: &Rc<dyn Fn()>, name: &Rc<str>, args: &[Value]) -> JsResult<Value> {
/// the source has run, normally or not. `strict_writes` gives the source's
/// [`crate::bytecode::Source::strict_writes`], or `None` for the calling code's.
pub(crate) fn call_evaluator(
&mut self,
f: &crate::heap::DataFn,
end: &Rc<dyn Fn()>,
strict_writes: &Rc<dyn Fn() -> Option<bool>>,
name: &Rc<str>,
args: &[Value],
) -> JsResult<Value> {
let data = self.read_args(name, args, &ArgRead::Data)?;
let source = match f(data) {
Ok(Data::String(source)) => source,
Ok(d) => return self.write_data(d, 0, false),
Err(e) => return Err(self.host_error(e)?),
};
let writes = strict_writes().unwrap_or_else(|| self.writes_mode());
self.evaluated = self.evaluated.saturating_add(1);
let tag = std::mem::replace(&mut self.source_tag, self.evaluated);
let code = self.encode_source(&source).and_then(|units| self.compile_source(&units, false, crate::bytecode::CodeKind::Eval));
let code = self.encode_source(&source).and_then(|units| self.compile_source_writes(&units, false, writes, crate::bytecode::CodeKind::Eval));
self.source_tag = tag;
let r = match code {
Ok(code) => self.run_global(code, true),
@@ -469,9 +480,10 @@ impl Interp {
HostItem::Converted(list, f) => {
Prop::Data { value: Value::Object(self.host_function(f, &full, ArgRead::Each(list.into()))), flags: ENUMERABLE }
}
HostItem::Evaluator { source, end } => {
HostItem::Evaluator { source, end, strict_writes } => {
let proto = self.intrinsics.function_proto;
let f = self.heap.alloc_object(Object::new(Class::Function(Func::Eval { f: source, end, name: full.into() }), Some(proto)));
let func = Func::Eval { f: source, end, strict_writes, name: full.into() };
let f = self.heap.alloc_object(Object::new(Class::Function(func), Some(proto)));
Prop::Data { value: Value::Object(f), flags: ENUMERABLE }
}
};
+35 -6
View File
@@ -197,6 +197,8 @@ pub(crate) struct Interp {
pub(crate) evaluated: u32,
/// The tag of the source the last run's error was raised in.
pub(crate) error_tag: u32,
/// [`crate::bytecode::Source::strict_writes`] for the scripts the realm runs.
pub(crate) strict_writes: bool,
}
/// The bytes all live [`HeldBytes`] hold, and the bytes dropped holders released that the heap's
@@ -394,6 +396,7 @@ impl Interp {
source_tag: crate::realm::UNKNOWN_SOURCE,
evaluated: 0,
error_tag: crate::realm::UNKNOWN_SOURCE,
strict_writes: false,
};
crate::builtins::install(&mut interp);
interp.fuel = limits.fuel;
@@ -700,6 +703,18 @@ impl Interp {
self.frames.last().is_some_and(|f| f.code.strict)
}
/// Does a property write the running code makes throw when it cannot be made? It does in
/// strict mode code and in code from a source with [`crate::bytecode::Source::strict_writes`].
fn strict_writes(&self) -> bool {
self.frames.last().is_some_and(|f| f.code.strict || f.code.source.strict_writes)
}
/// [`crate::bytecode::Source::strict_writes`] for source compiled now: the running code's,
/// or the realm's between runs.
pub(crate) fn writes_mode(&self) -> bool {
self.frames.last().map_or(self.strict_writes, |f| f.code.source.strict_writes)
}
fn exec(&mut self, op: Op) -> JsResult<()> {
match op {
Op::Undefined => self.push(Value::Undefined),
@@ -918,11 +933,14 @@ impl Interp {
_ => Ok(()),
}
}
Resolved::Obj { object, .. } => self.put(object, name.clone(), v, strict),
Resolved::Obj { object, .. } => {
let throw = self.strict_writes();
self.put(object, name.clone(), v, throw)
}
Resolved::Unresolved if strict => Err(self.not_defined(name)),
Resolved::Unresolved => {
let global = self.global;
self.put(global, name.clone(), v, false)
let (global, throw) = (self.global, self.strict_writes());
self.put(global, name.clone(), v, throw)
}
}
}
@@ -1453,7 +1471,10 @@ impl Interp {
fn put_value(&mut self, base: &Value, k: Key, v: Value) -> JsResult<()> {
let strict = self.strict();
match base {
Value::Object(o) => self.put(*o, k, v, strict),
Value::Object(o) => {
let throw = self.strict_writes();
self.put(*o, k, v, throw)
}
Value::Undefined | Value::Null => {
let message = format!(
"cannot set property '{}' of {}",
@@ -1869,7 +1890,9 @@ impl Interp {
// keeps itself reachable.
return Ok(Prepared::Done(self.call_data(&f, &name, &args, &read)?));
}
Func::Eval { f, end, name } => return Ok(Prepared::Done(self.call_evaluator(&f, &end, &name, &args)?)),
Func::Eval { f, end, strict_writes, name } => {
return Ok(Prepared::Done(self.call_evaluator(&f, &end, &strict_writes, &name, &args)?));
}
Func::Host { f: host, .. } => {
let js_args: Vec<crate::JsValue> = args.iter().map(|v| self.to_js_value(v)).collect();
return match host(&js_args) {
@@ -2138,11 +2161,17 @@ impl Interp {
/// Parses and compiles source for eval code or a script, counting it against the source
/// limit.
pub(crate) fn compile_source(&mut self, units: &[u16], strict: bool, kind: CodeKind) -> Result<Rc<Code>, CompileError> {
let writes = self.writes_mode();
self.compile_source_writes(units, strict, writes, kind)
}
/// [`Interp::compile_source`], with [`crate::bytecode::Source::strict_writes`] set to `writes`.
pub(crate) fn compile_source_writes(&mut self, units: &[u16], strict: bool, writes: bool, kind: CodeKind) -> Result<Rc<Code>, CompileError> {
self.source_units = self.source_units.saturating_add(units.len());
let remaining = self.options.max_source_units.saturating_sub(self.source_units.saturating_sub(units.len()));
let options = Options { strict, max_source_units: remaining, ..self.options.clone() };
let program = crate::parse_utf16(units, &options).map_err(CompileError::Syntax)?;
let source = Rc::new(Source { lines: program.lines.clone(), text: units.into(), tag: self.source_tag });
let source = Rc::new(Source { lines: program.lines.clone(), text: units.into(), tag: self.source_tag, strict_writes: writes });
compile_program(&program, &source, kind).map_err(|e| match e {
CompileFail::Syntax(s) => CompileError::Syntax(s),
CompileFail::Internal(m) => CompileError::Internal(m),
+56 -1
View File
@@ -691,6 +691,61 @@ fn a_strict_realm_runs_scripts_as_strict_mode_code() {
assert_eq!(realm.run("(function () { return typeof this; })()"), Ok(JsValue::String("object".into())));
}
#[test]
fn strict_writes_are_separate_from_es5_strict() {
let setup = "var ro = {}; Object.defineProperty(ro, 'x', {value: 1}); \
var getter = {get g() { return 1; }}; var shut = Object.preventExtensions({a: 1}); var open = {};";
let mut realm = Realm::new();
realm.set_strict_writes(true);
realm.run(setup).unwrap();
for (src, why) in [
("ro.x = 2", "cannot assign to property 'x': it is read-only"),
("ro['x'] += 1", "cannot assign to property 'x': it is read-only"),
("ro.x++", "cannot assign to property 'x': it is read-only"),
("getter.g = 2", "cannot assign to property 'g': it has only a getter"),
("shut.b = 2", "cannot assign to property 'b': the object is not extensible"),
("Object.create(ro).x = 2", "cannot assign to property 'x': it is read-only"),
("NaN = 1", "cannot assign to property 'NaN': it is read-only"),
("(function () { ro.x = 2; })()", "cannot assign to property 'x': it is read-only"),
("eval('ro.x = 2')", "cannot assign to property 'x': it is read-only"),
("(0, eval)('ro.x = 2')", "cannot assign to property 'x': it is read-only"),
("Function('ro.x = 2')()", "cannot assign to property 'x': it is read-only"),
] {
let e = realm.run(src).unwrap_err();
assert_eq!(e.kind, RunErrorKind::Thrown, "{src}");
assert!(e.message.contains("TypeError") && e.message.contains(why), "{src}: {}", e.message);
}
// Writes that can be made, and the rest of strict mode, are as before.
assert_eq!(realm.run("open.n = 1; shut.a = 5; open.n + shut.a"), Ok(JsValue::Number(6.0)));
assert_eq!(realm.run("with ({w: 2}) { w } "), Ok(JsValue::Number(2.0)));
assert_eq!(realm.run("(function (a, a) { return a; })(1, 2)"), Ok(JsValue::Number(2.0)));
assert_eq!(realm.run("010"), Ok(JsValue::Number(8.0)));
assert_eq!(realm.run("undeclared = 3; undeclared"), Ok(JsValue::Number(3.0)));
assert_eq!(realm.run("(function () { return typeof this; })()"), Ok(JsValue::String("object".into())));
assert_eq!(realm.run("delete Object.prototype"), Ok(JsValue::Bool(false)));
assert_eq!(realm.run("'abc'.length = 1; 'abc'.extra = 1; 1"), Ok(JsValue::Number(1.0)), "a primitive's properties as in sloppy mode");
// 'use strict' keeps all of ES5 strict mode.
assert_eq!(realm.run("'use strict'; with ({}) {}").map_err(|e| e.kind), Err(RunErrorKind::Syntax));
assert_eq!(realm.run("'use strict'; delete Object.prototype").map_err(|e| e.kind), Err(RunErrorKind::Thrown));
// Off, the same writes are ignored, as in sloppy mode code.
realm.set_strict_writes(false);
assert_eq!(realm.run("ro.x = 2; getter.g = 2; shut.b = 2; NaN = 1; ro.x + getter.g + String(shut.b)"), Ok(JsValue::String("2undefined".into())));
// A function compiled with the writes on keeps them when called from code without.
let mut realm = Realm::new();
realm.set_strict_writes(true);
realm.run(&format!("{setup} function w() {{ ro.x = 2; }}")).unwrap();
realm.set_strict_writes(false);
assert!(realm.run("w()").unwrap_err().message.contains("it is read-only"));
assert_eq!(realm.run("ro.x = 3; ro.x"), Ok(JsValue::Number(1.0)));
// An undeclared name added to a global object that is not extensible.
let mut realm = Realm::new();
realm.set_strict_writes(true);
let e = realm.run("Object.preventExtensions(this); newName = 1").unwrap_err();
assert!(e.message.contains("cannot assign to property 'newName': the object is not extensible"), "{}", e.message);
let mut realm = Realm::new();
assert_eq!(realm.run("Object.preventExtensions(this); newName = 1; typeof newName"), Ok(JsValue::String("undefined".into())));
}
#[test]
fn an_apply_that_throws_while_reading_its_arguments_keeps_nothing_alive() {
// Each call reads a fresh array at index 0 before index 1 throws; the arrays must not stay
@@ -774,7 +829,7 @@ fn deep_nesting_compiles_on_a_small_stack() {
// ---- the verifier ----
fn code(ops: Vec<Op>) -> Code {
let source = Rc::new(Source { lines: crate::LineTable::new(&[]), text: Rc::from(Vec::<u16>::new()), tag: 0 });
let source = Rc::new(Source { lines: crate::LineTable::new(&[]), text: Rc::from(Vec::<u16>::new()), tag: 0, strict_writes: false });
Code {
positions: vec![0; ops.len()],
ops,
+8
View File
@@ -174,6 +174,14 @@ impl Realm {
self.unbroken_fuel = fuel;
}
/// With `on`, the scripts run next (and the code they define and evaluate) throw a
/// `TypeError` when they assign to a read-only or getter-only property or add a property to
/// an object that is not extensible, as strict mode code does, without the rest of strict
/// mode. Off by default.
pub fn set_strict_writes(&mut self, on: bool) {
self.interp.strict_writes = on;
}
pub fn limits(&self) -> &Limits {
&self.interp.limits
}
+1 -1
View File
@@ -687,7 +687,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi
## G6 Automation and scripting
- **Missing:** the document object model for scripts and script directives (#1183 is
- **Missing:** the document object model for scripts and the `#targetengine` and `#engine` script directives (#1183 is
open). File › Scripts › Other Script… and `script.run` run JavaScript files over the command registry,
File › Scripts lists the scripts of the Scripts folder, and
Window › Script Console shows their output, errors and run reports. Actions, Batch, Variables (text, visibility, image and graph), data set CSV import and
+14 -3
View File
@@ -1595,9 +1595,20 @@ VectorCraft reads them by these rules of its own:
folder of the file that includes. A later `#includepath` replaces the list.
- `#script "name"` names the run in the report and the Script Console, unless `name` is given. A `#script` line in an
included file does not rename the run.
- `#target` takes any value and does not change how the script runs. `#strict off` is taken.
- `#strict on`, `#targetengine` and `#engine` end the run with an unsupported-directive error, and any other `#name`
with an unknown-directive error. A `//@` line with another name is a comment.
- `#target` takes any value and does not change how the script runs.
- `#strict on` turns on the checks the dollar-object help page gives for `$.strict`: an assignment to a read-only
property throws a `TypeError`, and so does adding a property to an object that takes no new ones. By VectorCraft's
own rules, an assignment to a property with only a getter is checked as a read-only one, and the objects that take
no new properties are those that are not extensible: objects passed to `Object.preventExtensions`, `Object.seal` or
`Object.freeze`, and the frozen `vc` and `$` objects. Other objects take new properties as before. The rest of ES5
strict mode (`with`, undeclared variables, `this` in a plain call) is unchanged; a script asks for it with
`'use strict'`. `#strict off` turns the checks off, and they are off in a script without either line.
- The last `#strict` line of the script and the files it includes sets the checks for the whole run. Code that `eval`
and `Function` compile takes the checks of the code that compiles it. A file `$.evalFile` runs takes the checks of
its own last `#strict` line, for its code and the functions it defines, or the caller's when it has none; the
caller's code keeps its own. Any value other than `on` or `off` ends the run with `#strict takes on or off`.
- `#targetengine` and `#engine` end the run with an unsupported-directive error, and any other `#name` with an
unknown-directive error. A `//@` line with another name is a comment.
- A script run from a file reads its included files and the files `$.evalFile` runs only in that file's folder and
the folders below it. A path with a `..` name, a path that leads out of the folder through a link or a junction, a
Windows device name or stream, and a computer on the network are refused, and so is every path for a script given as
+2 -1
View File
@@ -732,7 +732,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- The control server writes each reply through a 64 KiB buffer. On Windows 11 it sends the 17 MB `document.inspect` reply of a document with 50,000 objects on one layer to a client over loopback in 0.09 s instead of 34 s.
- Scripts: File › Scripts › Other Script… and `script.run` run a JavaScript file against the open documents as one transaction with one undo step for each changed document. A failed or stopped run puts every document back. `crates/engine/src/tests/script_run.rs`.
- Scripts: Window › Script Console shows each run's `$.write` and `$.writeln` output, `vc.log` entries, uncaught error with the script's name, line and column, and a report of its time, host calls and slowest commands, keeping at most 2,048 lines and counting the lines it dropped. `script.run`'s report carries the same data, and `script.console`, `script.console.clear` and `ui.copyScriptConsole` read, clear and copy it. `crates/engine/src/tests/script_console.rs`.
- Scripts: a run reads its preprocessor lines before any of it runs: `#include` and `//@include` put another file in place of the line, looked for beside the including file, in the `#includepath` folders and in `JSINCLUDE`; `#script` names the run; `#target` and `#strict off` are taken, and `#strict on`, `#targetengine` and `#engine` are refused. `$.evalFile` runs a file with the same globals and returns its last value, and `$.fileName` and `$.includePath` give the file being run and the include folders. A script run from a file reads these files only in its folder and below, without `..` names or links that lead out, and an error in one is reported at its own file, line and column ([preprocessor directives](https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/), [the `$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/); `crates/engine/src/tests/script_include.rs`).
- Scripts: a run reads its preprocessor lines before any of it runs: `#include` and `//@include` put another file in place of the line, looked for beside the including file, in the `#includepath` folders and in `JSINCLUDE`; `#script` names the run; `#target` and `#strict off` are taken, `#strict on` turns on strict write checks (below), and `#targetengine` and `#engine` are refused. `$.evalFile` runs a file with the same globals and returns its last value, and `$.fileName` and `$.includePath` give the file being run and the include folders. A script run from a file reads these files only in its folder and below, without `..` names or links that lead out, and an error in one is reported at its own file, line and column ([preprocessor directives](https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/), [the `$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/); `crates/engine/src/tests/script_include.rs`).
- Scripts: `#strict on` (and `//@strict on`) runs the script with the checks the [`$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/) help page gives for `$.strict`: 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 (`Object.preventExtensions`, `seal`, `freeze`, and the frozen `vc` and `$` objects), the last `#strict` line of the script and its includes sets the whole run, a file `$.evalFile` runs keeps its own line's checks for its code, and the rest of ES5 strict mode stays with `'use strict'`. Without the line, runs are unchanged. `crates/engine/src/tests/script_include.rs` (`strict_on_*`, `strict_mode_in_eval_file_is_scoped`), `crates/js/src/interp_tests.rs` (`strict_writes_are_separate_from_es5_strict`).
- Scripts folder: File › Scripts lists the `.js` and `.jsx` files of the `Scripts` folder next to the preferences, each named after its file without the extension, with each subfolder as a submenu, above Check Script… and Other Script… (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html). VectorCraft's own rules: the folder is read again when the window becomes active and when the File menu opens, links are not followed, and one read stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms. `script.listInstalled` returns the listing. `crates/engine/src/tests/script_folder.rs`, `crates/ui-egui/src/tests/scripts_menu.rs`.
- Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`.
- Parameter keys: the engine refuses a key that a command's params description doesn't name before the command runs, in every build and for every caller, including the app's own calls, the commands a command runs and the steps of `command.batch` and `action.batch`. An action recorded by an older version still plays from the Actions panel and the Batch dialog (`Session::replay`). `crates/engine/tests/it/param_keys.rs`.