From 202c4a1e2bbea68fa4555643fbe3a53a17c3d131 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 20:36:17 -0500 Subject: [PATCH] Scripting: export commands a script runs directly respect Allow Scripts to Write Files CompItem.saveFrameToPng and exportAsMotionGraphicsTemplate checked the file-write preference only in their JavaScript wrappers. A script could call comp.saveFrameAs or essential.exportTemplate itself (app.run, __exec) and the native handlers wrote the file with the preference off. Session::refuse_export and write_output_with now refuse when the running command was issued by a script and the preference is off, so every command that writes a path it is given is gated the same way. The wrappers check the gate with a new side-effect-free __file("checkWrite") instead of writing an empty file at the destination. --- crates/engine/src/output_guard.rs | 16 +++++++++++ crates/script/src/prelude.js | 6 ++-- crates/script/src/runtime.rs | 2 ++ crates/script/src/tests.rs | 48 +++++++++++++++++++++++++++++++ 4 files changed, 69 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/output_guard.rs b/crates/engine/src/output_guard.rs index 46c4356ed..73d938a32 100644 --- a/crates/engine/src/output_guard.rs +++ b/crates/engine/src/output_guard.rs @@ -509,12 +509,27 @@ impl Session { /// is not for an export to replace. For callers that write the file themselves; call it /// before the work, not after it. pub fn refuse_export(&self, path: &str) -> crate::Result<()> { + self.refuse_script_write(path)?; match self.blocking(path) { Some(source) => Err(crate::EngineError::Other(source.refusal(path, Use::Export))), None => Ok(()), } } + /// `Err` when a script issued the running command ([`Session::running_for_script`]) and + /// Settings ▸ Scripting & Expressions ▸ Allow Scripts to Write Files is off. The scripting + /// wrappers check this too, but a script can run any command directly (`app.run`, `__exec`), + /// so the commands that write a path they are given check here, through + /// [`Session::refuse_export`] and [`Session::write_output`]. + pub fn refuse_script_write(&self, path: &str) -> crate::Result<()> { + if self.running_for_script() && !self.prefs.scripting.allow_scripts_write_files { + return Err(crate::EngineError::Other(format!( + "Unable to write {path}: scripts can't write files unless Settings ▸ Scripting & Expressions ▸ Allow Scripts to Write Files and Access Network is on" + ))); + } + Ok(()) + } + /// Write `bytes` to `path` through the host's services, unless the project reads the file /// there ([`Session::refuse_export`]). Every command that writes to a path it is given /// writes through this; Save and Save As (the project's own file) do not. @@ -525,6 +540,7 @@ impl Session { /// [`Session::write_output`] against files already found ([`Session::protected_files`]), /// for a command that writes many. pub(crate) fn write_output_with(&self, protected: &Protected, path: &str, bytes: &[u8]) -> crate::Result<()> { + self.refuse_script_write(path)?; if let Some(source) = protected.find(Path::new(path)) { return Err(crate::EngineError::Other(source.refusal(path, Use::Export))); } diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index b9108b2a1..39971b5e4 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -1652,7 +1652,7 @@ CompItem.prototype.constructor = CompItem; P.saveFrameToPng = function (time, file) { var f = __str(file); if (!f) throw __err("saveFrameToPng: missing file"); - __file("write", f, ""); + __file("checkWrite", f); __call("comp.saveFrameAs", { comp: this.__id, path: f, time: __num(time, "time") }); }; P.openInEssentialGraphics = function () { __call("comp.openInEssentialGraphics", { comp: this.__id }); }; @@ -1662,8 +1662,8 @@ CompItem.prototype.constructor = CompItem; if (!/\.[A-Za-z0-9]+$/.test(f)) f += ".ectemplate"; if (!overwrite && JSON.parse(__file("exists", f)) === true) return false; if (this.__controllers().length === 0) return false; - // The file gate applies to templates too. - __file("write", f, ""); + // The file gate applies to templates too (checked without touching the file). + __file("checkWrite", f); __call("essential.exportTemplate", { comp: this.__id, path: f }); return true; }; diff --git a/crates/script/src/runtime.rs b/crates/script/src/runtime.rs index 2c18039e8..6838adf90 100644 --- a/crates/script/src/runtime.rs +++ b/crates/script/src/runtime.rs @@ -409,6 +409,8 @@ fn native_file(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult check_access(s, &path, true).map(|_| json!(true)), "read" | "readBinary" => { let p = check_access(s, &path, false)?; let bytes = s.services.read_file(&p.to_string_lossy()).map_err(|e| format!("Unable to read {path}: {e}"))?; diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index eefa017f0..a3d8c6fea 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -662,6 +662,54 @@ fn save_frame_to_png_writes_behind_the_file_gate() { let _ = std::fs::remove_dir_all(&dir); } +/// A script that runs the export commands itself (`app.run`, `__exec`) meets the same file-write +/// preference as the wrappers; with the preference off nothing is created or replaced. +#[test] +fn export_commands_run_directly_by_a_script_respect_the_file_gate() { + let dir = std::env::temp_dir().join(format!("ec-direct-export-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let png = dir.join("frame.png"); + let tpl = dir.join("card.ectemplate"); + let kept = dir.join("kept.png"); + std::fs::write(&kept, b"original").unwrap(); + let mut s = session(); + s.execute("comp.new", json!({"name": "Card", "width": 64, "height": 64, "duration": 1})).unwrap(); + s.execute("layer.newText", json!({"text": "Hi"})).unwrap(); + ok(&mut s, "var c = app.project.activeItem; c.layer(1).property('ADBE Transform Group').property('ADBE Opacity').addToMotionGraphicsTemplate(c);"); + let q = |p: &std::path::Path| serde_json::to_string(&p.to_string_lossy()).unwrap(); + let comp = s.active_comp_id().unwrap().0; + for (code, target) in [ + (format!("app.run('comp.saveFrameAs', {{comp: {comp}, path: {}}})", q(&png)), &png), + (format!("__exec('comp.saveFrameAs', JSON.stringify({{comp: {comp}, path: {}}}))", q(&png)), &png), + (format!("app.run('essential.exportTemplate', {{comp: {comp}, path: {}}})", q(&tpl)), &tpl), + (format!("app.run('comp.saveFrameAs', {{comp: {comp}, path: {}}})", q(&kept)), &kept), + ] { + let o = run_code(&mut s, &code, "direct.jsx"); + let e = o.error.unwrap_or_else(|| panic!("`{code}` was not refused")); + assert!(e.message.contains("Allow Scripts to Write Files"), "{e:?}"); + if target == &kept { + assert_eq!(std::fs::read(&kept).unwrap(), b"original"); + } else { + assert!(!target.exists(), "{} was written", target.display()); + } + } + // The wrappers check the gate without touching the file: an existing one is left as it was. + let o = run_code(&mut s, &format!("app.project.activeItem.saveFrameToPng(0, new File({}))", q(&kept)), "wrap.jsx"); + assert!(o.error.is_some()); + assert_eq!(std::fs::read(&kept).unwrap(), b"original"); + let o = run_code(&mut s, &format!("app.project.activeItem.exportAsMotionGraphicsTemplate(true, {})", q(&tpl)), "wrap.jsx"); + assert!(o.error.is_some()); + assert!(!tpl.exists()); + // The user, from the UI, is not a script: the same command writes. + s.execute("comp.saveFrameAs", json!({"comp": comp, "path": png.to_string_lossy()})).unwrap(); + assert!(png.exists()); + // With the preference on, the script's direct call writes too. + s.prefs.scripting.allow_scripts_write_files = true; + ok(&mut s, &format!("app.run('essential.exportTemplate', {{comp: {comp}, path: {}}})", q(&tpl))); + assert!(std::fs::metadata(&tpl).unwrap().len() > 100); + let _ = std::fs::remove_dir_all(&dir); +} + /// #1246: with Warn User When Executing Files on, a script that asserts `confirmed` itself still /// gets the question; only the user's answer opens the file. #[test]