diff --git a/crates/script/src/lib.rs b/crates/script/src/lib.rs index d1c9568c9..f625cd678 100644 --- a/crates/script/src/lib.rs +++ b/crates/script/src/lib.rs @@ -20,7 +20,7 @@ //! `ShapeLayer`/`CameraLayer`/`LightLayer`, `PropertyGroup`/`Property` (values, keyframes, //! eases, expressions), `MaskPropertyGroup`, `TextDocument`, `Shape`, `KeyframeEase`, //! `MarkerValue`, `RenderQueue`/`RenderQueueItem`/`OutputModule`, `ImportOptions`, `File`/ -//! `Folder`, `$`, `alert`/`writeLn`, and the enums (`BlendingMode`, `KeyframeInterpolationType`, +//! `Folder`, `$` (with `evalFile`), `BridgeTalk` (host identification), `alert`/`writeLn`, and the enums (`BlendingMode`, `KeyframeInterpolationType`, //! `TrackMatteType`, `LightType`, `ParagraphJustification`, `PropertyValueType`…). //! * **Edits are engine commands**: every mutating call runs a command through //! [`Session::execute`](effectcraft_engine::Session::execute), so it is undoable, journaled @@ -56,6 +56,7 @@ pub mod matchnames; mod model; mod runtime; +mod scriptfile; mod socket; mod ui; @@ -90,3 +91,7 @@ mod tests_panels; mod tests_ui; #[cfg(test)] mod tests_ui_more; + +// #760: `$.evalFile` and `BridgeTalk`. +#[cfg(test)] +mod tests_evalfile; diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index f50a968c5..3ea75f707 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -111,9 +111,50 @@ var $ = { get os() { return "EffectCraft"; }, get version() { return "EffectCraft scripting"; }, get locale() { return "en_US"; }, - evalFile: function () { throw __err("$.evalFile is not supported: scripts can't run scripts"); }, + // Evaluates the file in the global scope and returns its last value (`timeout` is ignored). + evalFile: function (file, timeout) { return __evalFile(__str(file)); }, }; +// ------------------------------------------------------------------ BridgeTalk + +// Which application a script runs in, for scripts and frameworks that ask during initialisation. +// EffectCraft names itself rather than posing as another application; messages to other +// applications aren't supported. +function BridgeTalk() { + if (!(this instanceof BridgeTalk)) return new BridgeTalk(); + this.target = ""; + this.sender = BridgeTalk.appSpecifier; + this.body = ""; + this.type = "ExtendScript"; + this.headers = {}; + this.timeout = 0; + this.onResult = null; + this.onError = null; + this.onReceived = null; + this.onTimeout = null; +} +BridgeTalk.prototype.send = function () { + throw __err("BridgeTalk: sending messages to other applications is not supported in EffectCraft"); +}; +BridgeTalk.prototype.sendResult = BridgeTalk.prototype.send; +BridgeTalk.prototype.toString = function () { return "[object BridgeTalk]"; }; +BridgeTalk.appName = "effectcraft"; +Object.defineProperty(BridgeTalk, "appVersion", { get: function () { return __get("app").buildName; } }); +Object.defineProperty(BridgeTalk, "appSpecifier", { get: function () { return "effectcraft-" + BridgeTalk.appVersion; } }); +BridgeTalk.appLocale = "en_US"; +BridgeTalk.appInstance = ""; +BridgeTalk.appStatus = "idle"; +BridgeTalk.onReceive = null; +BridgeTalk.__isSelf = function (spec) { return String(spec === undefined || spec === null ? "" : spec).split("-")[0].toLowerCase() === "effectcraft"; }; +BridgeTalk.isRunning = function (spec) { return BridgeTalk.__isSelf(spec); }; +BridgeTalk.launch = function (spec) { return BridgeTalk.__isSelf(spec); }; +BridgeTalk.getSpecifier = function (name) { return BridgeTalk.__isSelf(name) ? BridgeTalk.appSpecifier : null; }; +BridgeTalk.getDisplayName = function (spec) { return BridgeTalk.__isSelf(spec) ? "EffectCraft" : null; }; +BridgeTalk.getTargets = function () { return [BridgeTalk.appName]; }; +BridgeTalk.getStatus = function (spec) { return spec === undefined || BridgeTalk.__isSelf(spec) ? "IDLE" : "ISNOTINSTALLED"; }; +BridgeTalk.bringToFront = function () {}; +BridgeTalk.pump = function () { return false; }; + // ------------------------------------------------------------------ File / Folder function __join(a, b) { return a.replace(/[\/\\]+$/, "") + "/" + b; } diff --git a/crates/script/src/runtime.rs b/crates/script/src/runtime.rs index 712999ee0..29b8fe6d1 100644 --- a/crates/script/src/runtime.rs +++ b/crates/script/src/runtime.rs @@ -72,11 +72,13 @@ pub(crate) struct Active { /// Running on a script-host thread: modal dialogs hand the session back to the caller /// through this link while they wait. pub(crate) link: Option, + /// The files `$.evalFile` is evaluating, outermost first. + pub(crate) loading: Vec, } impl Active { pub(crate) fn new(session: Session, name: &str) -> Active { - Active { session, output: vec![], groups: vec![], tasks: vec![], next_task: 0, name: name.to_string(), link: None } + Active { session, output: vec![], groups: vec![], tasks: vec![], next_task: 0, name: name.to_string(), link: None, loading: vec![] } } } @@ -291,13 +293,13 @@ fn native_task(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult Option { +pub(crate) fn project_dir(s: &Session) -> Option { let p = s.path.as_deref()?; std::path::Path::new(p).parent().map(|d| d.to_string_lossy().to_string()) } /// Lexically normalized absolute path (`a/./b/../c` → `a/c`). -fn normalize(p: &str) -> std::path::PathBuf { +pub(crate) fn normalize(p: &str) -> std::path::PathBuf { use std::path::{Component, Path, PathBuf}; let path = Path::new(p); let abs = if path.is_absolute() { path.to_path_buf() } else { std::env::current_dir().unwrap_or_default().join(path) }; @@ -405,18 +407,58 @@ fn native_file(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult JsResult { + use crate::scriptfile::{self, MAX_DEPTH}; + let name = arg_string(args, 0, ctx)?; + if name.is_empty() || name == "undefined" { + return Err(throw("$.evalFile needs a file")); + } + let (path, text) = with_active(|a| { + if a.loading.len() >= MAX_DEPTH { + return Err(format!("$.evalFile(\"{name}\"): files are nested too deeply (at most {MAX_DEPTH})")); + } + let s = &a.session; + let root = scriptfile::script_dir(s, &a.name); + let dir = a + .loading + .last() + .and_then(|p| p.parent().map(std::path::Path::to_path_buf)) + .or_else(|| root.clone()) + .or_else(|| project_dir(s).map(|d| normalize(&d))); + let path = scriptfile::resolve(s, &name, dir.as_deref(), &[]).map_err(|e| format!("$.evalFile {e}"))?; + scriptfile::check_read(s, &path, root.as_deref())?; + let text = scriptfile::read(s, &path)?; + check_nesting(&text)?; + a.loading.push(path.clone()); + Ok((path, text)) + }) + .map_err(throw)?; + let r = ctx.eval(Source::from_bytes(text.as_bytes()).with_path(&path)); + let _ = with_active(|a| { + a.loading.pop(); + Ok(()) + }); + r +} + pub(crate) fn new_context() -> Result { let mut ctx = Context::default(); let limits = ctx.runtime_limits_mut(); limits.set_loop_iteration_limit(200_000_000); limits.set_recursion_limit(2000); - let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult); 10] = [ + let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult); 11] = [ (js_string!("__exec"), 2, native_exec), (js_string!("__query"), 2, native_query), (js_string!("__print"), 1, native_print), (js_string!("__undo"), 2, native_undo), (js_string!("__task"), 3, native_task), (js_string!("__file"), 3, native_file), + (js_string!("__evalFile"), 2, native_eval_file), (js_string!("__sock"), 3, crate::socket::native_sock), (js_string!("__uiNewId"), 0, crate::ui::native_new_id), (js_string!("__uiModal"), 1, crate::ui::native_modal), diff --git a/crates/script/src/scriptfile.rs b/crates/script/src/scriptfile.rs new file mode 100644 index 000000000..2b6afcfbe --- /dev/null +++ b/crates/script/src/scriptfile.rs @@ -0,0 +1,65 @@ +//! Script files a running script loads (`#include`, `$.evalFile`): where a name resolves, whether +//! the script may read it, and the limits that keep hostile, huge or cyclic files from running +//! away. + +use std::path::{Path, PathBuf}; + +use effectcraft_engine::Session; + +use crate::runtime::{GATE, normalize, project_dir}; + +/// How deep loaded files may nest (a loaded file loading another…). +pub(crate) const MAX_DEPTH: usize = 32; +/// The largest single file a script may load. +pub(crate) const MAX_FILE_BYTES: usize = 16 << 20; + +/// The folder of the script file `name` runs from: `None` for the Script Console and for scripts +/// run by name or from code, which have no file. +pub(crate) fn script_dir(s: &Session, name: &str) -> Option { + let parent = Path::new(name).parent().filter(|p| !p.as_os_str().is_empty())?; + s.services.exists(name).then(|| normalize(&parent.to_string_lossy())) +} + +/// Resolve `name`: absolute paths as they are; relative ones against `dir` (the loading script's +/// folder), then each of `search`. The first existing file wins; when none exists, the first +/// candidate, so the read error names it. +pub(crate) fn resolve(s: &Session, name: &str, dir: Option<&Path>, search: &[PathBuf]) -> Result { + if Path::new(name).is_absolute() { + return Ok(normalize(name)); + } + let candidates: Vec = dir.into_iter().chain(search.iter().map(PathBuf::as_path)).map(|d| normalize(&d.join(name).to_string_lossy())).collect(); + candidates + .iter() + .find(|c| s.services.exists(&c.to_string_lossy())) + .or(candidates.first()) + .cloned() + .ok_or_else(|| format!("cannot find \"{name}\": the script has no folder to look in (use an absolute path or #includepath)")) +} + +/// The read gate for loaded script files: files under the running script's own folder (`root`) or +/// the project's folder; anywhere with the Allow Scripts to Write Files preference. +pub(crate) fn check_read(s: &Session, path: &Path, root: Option<&Path>) -> Result<(), String> { + if s.prefs.scripting.allow_scripts_write_files { + return Ok(()); + } + let project = project_dir(s).map(|d| normalize(&d)); + if root.into_iter().chain(project.as_deref()).any(|d| path.starts_with(d)) { + return Ok(()); + } + Err(format!("Unable to read {}: scripts can only load files from their own or the project's folder unless {GATE} is on", path.display())) +} + +/// Read a script file as text (a UTF-8 byte order mark dropped), refusing files over +/// [`MAX_FILE_BYTES`]. +pub(crate) fn read(s: &Session, path: &Path) -> Result { + let too_big = || format!("Unable to read {}: script files are limited to {} MiB", path.display(), MAX_FILE_BYTES >> 20); + if std::fs::metadata(path).is_ok_and(|m| m.len() > MAX_FILE_BYTES as u64) { + return Err(too_big()); + } + let bytes = s.services.read_file(&path.to_string_lossy()).map_err(|e| format!("Unable to read {}: {e}", path.display()))?; + if bytes.len() > MAX_FILE_BYTES { + return Err(too_big()); + } + let text = String::from_utf8_lossy(&bytes); + Ok(text.strip_prefix('\u{feff}').unwrap_or(&text).to_string()) +} diff --git a/crates/script/src/tests_evalfile.rs b/crates/script/src/tests_evalfile.rs new file mode 100644 index 000000000..c97a57b6b --- /dev/null +++ b/crates/script/src/tests_evalfile.rs @@ -0,0 +1,99 @@ +//! #760: `$.evalFile` and the `BridgeTalk` host identification. + +use std::path::{Path, PathBuf}; + +use effectcraft_engine::Session; +use serde_json::json; + +use crate::{Outcome, run_code}; + +fn session() -> Session { + let mut s = Session::default(); + crate::install(&mut s); + s +} + +fn tmp(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("ec-evalfile-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn write(path: &Path, text: &str) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, text).unwrap(); +} + +/// Run the script file `main` (its code read from disk, as File ▸ Scripts ▸ Run Script File does). +fn run_file(s: &mut Session, main: &Path) -> Outcome { + let code = std::fs::read_to_string(main).unwrap(); + run_code(s, &code, &main.to_string_lossy()) +} + +#[test] +fn eval_file_evaluates_relative_to_the_calling_script() { + let dir = tmp("ok"); + // lib/a.jsx loads b.jsx next to itself, not next to main.jsx. + write(&dir.join("lib/a.jsx"), "var fromA = $.evalFile('b.jsx') + 1;\nfunction twice(x) { return 2 * x; }\nfromA"); + write(&dir.join("lib/b.jsx"), "var fromB = 20;\nfromB"); + write( + &dir.join("main.jsx"), + "var r = $.evalFile(new File($.fileName).path + '/lib/a.jsx');\nvar s = $.evalFile('lib/b.jsx');\n[r, s, twice(fromB), typeof fromA]", + ); + let mut s = session(); + let o = run_file(&mut s, &dir.join("main.jsx")); + assert!(o.error.is_none(), "{:?}", o.error); + assert_eq!(o.result, json!([21, 20, 40, "number"])); + // Errors in the evaluated file reach the caller, which can catch them. + write(&dir.join("bad.jsx"), "throw new Error('bad file');"); + write(&dir.join("main.jsx"), "var m; try { $.evalFile('bad.jsx'); } catch (e) { m = e.message; } m"); + assert_eq!(run_file(&mut s, &dir.join("main.jsx")).result, json!("bad file")); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn eval_file_is_bounded_and_gated() { + let dir = tmp("hostile"); + // A file that evaluates itself forever stops at the depth limit. + write(&dir.join("loop.jsx"), "$.evalFile('loop.jsx');"); + let mut s = session(); + let e = run_file(&mut s, &dir.join("loop.jsx")).error.expect("an error"); + assert!(e.message.contains("nested too deeply"), "{e:?}"); + // Deeply bracketed files are refused before they reach the parser. + write(&dir.join("deep.jsx"), &"[".repeat(100_000)); + write(&dir.join("main.jsx"), "$.evalFile('deep.jsx')"); + let e = run_file(&mut s, &dir.join("main.jsx")).error.expect("an error"); + assert!(e.message.contains("nested too deeply"), "{e:?}"); + // Missing files are a clear error. + write(&dir.join("main.jsx"), "$.evalFile('missing.jsx')"); + let e = run_file(&mut s, &dir.join("main.jsx")).error.expect("an error"); + assert!(e.message.contains("missing.jsx"), "{e:?}"); + // Files outside the script's (and the project's) folder need the file preference. + write(&dir.join("outside.jsx"), "7"); + write(&dir.join("app/main.jsx"), "$.evalFile('../outside.jsx')"); + let e = run_file(&mut s, &dir.join("app/main.jsx")).error.expect("blocked"); + assert!(e.message.contains("Allow Scripts to Write Files"), "{e:?}"); + s.prefs.scripting.allow_scripts_write_files = true; + let o = run_file(&mut s, &dir.join("app/main.jsx")); + assert_eq!(o.result, json!(7), "{:?}", o.error); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn bridgetalk_identifies_effectcraft() { + let mut s = session(); + let o = run_code( + &mut s, + "[typeof BridgeTalk, BridgeTalk.appName, BridgeTalk.appVersion, BridgeTalk.appSpecifier, BridgeTalk.isRunning('aftereffects'), \ + BridgeTalk.getSpecifier('effectcraft'), BridgeTalk.getStatus('photoshop'), new BridgeTalk().sender]", + "bt.jsx", + ); + assert!(o.error.is_none(), "{:?}", o.error); + let v = env!("CARGO_PKG_VERSION"); + let spec = format!("effectcraft-{v}"); + assert_eq!(o.result, json!(["function", "effectcraft", v, spec, false, spec, "ISNOTINSTALLED", spec])); + // Messages to other applications are unsupported, with a clear error. + let e = run_code(&mut s, "var bt = new BridgeTalk(); bt.target = 'photoshop'; bt.body = '1'; bt.send();", "bt.jsx").error.expect("unsupported"); + assert!(e.message.contains("not supported"), "{e:?}"); +} diff --git a/docs/scripting-parity.md b/docs/scripting-parity.md index fef88efbc..4894b7748 100644 --- a/docs/scripting-parity.md +++ b/docs/scripting-parity.md @@ -19,6 +19,7 @@ our own scripts; no real-world script library has been run against both apps. | Expression editing | Inline field, error bar, pick whip, Expression Language menu, external editors | Inline field that grows, error bar, pick whips (property and value), language menu | partial | External editor support (#364) | | Expression performance | Cached, multi-frame | Per-thread boa contexts | unknown | No benchmark | | Scripting object model | `app`, `Project`, items, layers, properties, keys, eases, render queue, `TextDocument`, `Shape`, markers, `File`/`Folder`, `Socket`, `app.settings` | Same classes, edits run through undoable engine commands; match names table | good | `crates/script` tests (≈ 10 test files) | +| `$.evalFile`, `BridgeTalk` | Evaluate another script file; identify the host application and message other Adobe applications | `$.evalFile` evaluates a file relative to the calling file (nested at most 32 deep, under the scripts' file gate) and returns its last value; `BridgeTalk` names the host honestly (`appName` `effectcraft`, its version); messaging other applications throws "not supported" | partial | `tests_evalfile.rs` (#760); no inter-application messaging | | ScriptUI | Dialogs, palettes, dockable panels, resource strings, `onDraw` / ScriptUIGraphics | Same, dockable panels reopen at launch | good | `ui_scriptui`, `tests_ui*` | | Scripts menu, ScriptUI Panels folder | yes | yes | same | [plugins.md](plugins.md) | | Startup / Shutdown folders | yes | not run | missing | plugins.md |