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.
This commit is contained in:
Dylan M. Taylor
2026-11-02 20:36:17 -05:00
parent 7870b5916a
commit 202c4a1e2b
4 changed files with 69 additions and 3 deletions
+16
View File
@@ -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)));
}
+3 -3
View File
@@ -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;
};
+2
View File
@@ -409,6 +409,8 @@ fn native_file(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsV
let p = p.to_string_lossy();
Ok(json!(if op == "exists" { s.services.exists(&p) } else { s.services.is_dir(&p) }))
}
// The write gate alone, with no side effect on the file (export wrappers).
"checkWrite" => 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}"))?;
+48
View File
@@ -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]