#759: #include / //@include / #includepath in scripts

Problem: ExtendScript's preprocessor directives weren't understood, so a
script (or ScriptUI panel, or library such as DuAEF) failed to parse at
its first `#include`; `#target` / `#targetengine` / `#strict` lines broke
parsing too.

Fix: before a script runs, `include::expand` inlines `#include "f"` and
`//@include "f"` in place of the line, resolved against the including
file's folder, then the folders of the latest `#includepath "a;b"`.
Each file is inlined once (which also breaks cycles), nesting is limited
to 32, files are capped at 16 MiB (64 MiB in all), and reads follow the
scripts' file gate (the script's own folder, the project's folder, or
anywhere with Allow Scripts to Write Files). `#target`, `#targetengine`,
`#strict`, `#script` and `#engine` are dropped. Errors in the expanded
code are mapped back to the included file and its line. Scripts without
directives run unchanged. File resolution, the gate and the limits live
in `scriptfile.rs` for `$.evalFile` (#760) to share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-11 12:34:36 +02:00
co-authored by Claude Opus 5.5
parent 9a7d3e7699
commit e29f34029c
7 changed files with 356 additions and 4 deletions
+173
View File
@@ -0,0 +1,173 @@
//! ExtendScript's preprocessor directives, from the JavaScript Tools Guide's description of their
//! behaviour: `#include "file"` (and `//@include "file"`) inlines a file in place of the line,
//! resolved against the including file's folder, then the folders the latest `#includepath "a;b"`
//! (or `//@includepath`) named; `#target`, `#targetengine`, `#strict`, `#script` and `#engine`
//! are dropped. Each file is inlined once (which also breaks include cycles), nesting is limited
//! to [`MAX_DEPTH`], files and their total size are capped, and reads follow the scripts' file
//! gate ([`crate::scriptfile::check_read`]). Directives are recognised on lines of their own.
//!
//! Errors in the expanded code are mapped back to the file and line they came from.
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use effectcraft_engine::Session;
use crate::runtime::ScriptError;
use crate::scriptfile::{self, MAX_DEPTH, MAX_FILE_BYTES};
/// The most text a script and the files it includes may add up to.
const MAX_TOTAL_BYTES: usize = 4 * MAX_FILE_BYTES;
/// A script with its includes inlined.
pub(crate) struct Expanded {
pub(crate) code: String,
/// Per line of `code`: the file (index into `files`) and 1-based line it came from.
lines: Vec<(usize, u32)>,
/// The script itself, then each included file.
files: Vec<String>,
}
impl Expanded {
/// Point an error in the expanded code at the file and line it came from.
pub(crate) fn locate(&self, e: &mut ScriptError) {
if self.files.first() != Some(&e.file) {
return;
}
let Some(&(file, line)) = e.line.and_then(|l| l.checked_sub(1)).and_then(|l| self.lines.get(l as usize)) else { return };
if let Some(name) = self.files.get(file) {
e.file = name.clone();
e.line = Some(line);
}
}
}
enum Directive<'a> {
Include(&'a str),
IncludePath(&'a str),
Ignored,
}
/// The preprocessor directive on `line`, if it is one.
fn directive(line: &str) -> Option<Directive<'_>> {
let t = line.trim();
let (rest, comment) = match t.strip_prefix("//@") {
Some(r) => (r, true),
None => (t.strip_prefix('#')?, false),
};
let end = rest.find(|c: char| !c.is_ascii_alphabetic()).unwrap_or(rest.len());
let (word, arg) = rest.split_at(end);
if !(arg.is_empty() || arg.starts_with(char::is_whitespace) || arg.starts_with(['"', '\''])) {
return None;
}
let arg = unquote(arg);
match word {
"include" => Some(Directive::Include(arg)),
"includepath" => Some(Directive::IncludePath(arg)),
// `//@target aftereffects` and friends are plain comments already.
"target" | "targetengine" | "strict" | "script" | "engine" if !comment => Some(Directive::Ignored),
_ => None,
}
}
/// A directive's argument: `"x"`, `'x'` or `x`, with an optional trailing `;`.
fn unquote(arg: &str) -> &str {
let a = arg.trim();
let a = a.strip_suffix(';').map_or(a, str::trim_end);
for q in ['"', '\''] {
if let Some(inner) = a.strip_prefix(q).and_then(|r| r.split(q).next()) {
return inner;
}
}
a
}
struct Walk<'a> {
s: &'a Session,
/// The running script's folder: the file gate lets it load files below it.
root: Option<PathBuf>,
/// The latest `#includepath`.
search: Vec<PathBuf>,
seen: HashSet<PathBuf>,
total: usize,
out: Expanded,
}
impl Walk<'_> {
fn push(&mut self, line: &str, file: usize, n: usize) {
if !self.out.lines.is_empty() {
self.out.code.push('\n');
}
self.out.code.push_str(line);
self.out.lines.push((file, u32::try_from(n + 1).unwrap_or(u32::MAX)));
}
/// Inline `code` (file `file`, in folder `dir`), `depth` includes deep.
fn walk(&mut self, code: &str, file: usize, dir: Option<&Path>, depth: usize) -> Result<(), ScriptError> {
for (n, line) in code.split('\n').enumerate() {
let err = |w: &Self, message: String| ScriptError {
message,
line: Some(u32::try_from(n + 1).unwrap_or(u32::MAX)),
column: None,
file: w.out.files.get(file).cloned().unwrap_or_default(),
};
match directive(line) {
None => self.push(line, file, n),
Some(Directive::Ignored) => self.push("", file, n),
Some(Directive::IncludePath(paths)) => {
self.search =
paths.split(';').map(str::trim).filter(|p| !p.is_empty()).filter_map(|p| scriptfile::resolve(self.s, p, dir, &[]).ok()).collect();
self.push("", file, n);
}
Some(Directive::Include(name)) => {
if name.is_empty() {
return Err(err(self, "#include needs a file name".into()));
}
let path = scriptfile::resolve(self.s, name, dir, &self.search).map_err(|e| err(self, format!("#include {e}")))?;
if !self.seen.insert(path.clone()) {
// Already inlined (or the file including it): once is enough.
self.push("", file, n);
continue;
}
if depth >= MAX_DEPTH {
return Err(err(self, format!("#include \"{name}\": includes are nested too deeply (at most {MAX_DEPTH})")));
}
scriptfile::check_read(self.s, &path, self.root.as_deref()).map_err(|e| err(self, e))?;
let text = scriptfile::read(self.s, &path).map_err(|e| err(self, e))?;
self.total = self.total.saturating_add(text.len());
if self.total > MAX_TOTAL_BYTES {
return Err(err(self, format!("#include \"{name}\": the included files exceed {} MiB", MAX_TOTAL_BYTES >> 20)));
}
// The included lines take the directive's place.
self.out.files.push(path.to_string_lossy().to_string());
let idx = self.out.files.len().saturating_sub(1);
self.walk(&text, idx, path.parent(), depth + 1)?;
}
}
}
Ok(())
}
}
/// `code` (the script `name`) with its directives applied, or `None` when it has none.
pub(crate) fn expand(s: &Session, code: &str, name: &str) -> Result<Option<Expanded>, ScriptError> {
if !code.split('\n').any(|l| directive(l).is_some()) {
return Ok(None);
}
let root = scriptfile::script_dir(s, name);
let mut w = Walk {
s,
root: root.clone(),
search: vec![],
seen: HashSet::new(),
total: code.len(),
out: Expanded { code: String::with_capacity(code.len()), lines: vec![], files: vec![if name.is_empty() { "script" } else { name }.to_string()] },
};
if root.is_some() {
w.seen.insert(crate::runtime::normalize(name));
}
// A script without a folder (the Script Console, code run by name) looks in the project's.
let dir = root.or_else(|| crate::runtime::project_dir(s).map(|d| crate::runtime::normalize(&d)));
w.walk(code, 0, dir.as_deref(), 0)?;
Ok(Some(w.out))
}
+4
View File
@@ -53,9 +53,11 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![recursion_limit = "256"]
mod include;
pub mod matchnames;
mod model;
mod runtime;
mod scriptfile;
mod socket;
mod ui;
@@ -85,6 +87,8 @@ mod tests;
#[cfg(test)]
mod tests_ease_presets;
#[cfg(test)]
mod tests_include;
#[cfg(test)]
mod tests_panels;
#[cfg(test)]
mod tests_ui;
+18 -2
View File
@@ -291,13 +291,13 @@ fn native_task(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsV
Ok(JsValue::from(id))
}
fn project_dir(s: &Session) -> Option<String> {
pub(crate) fn project_dir(s: &Session) -> Option<String> {
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) };
@@ -587,7 +587,23 @@ pub(crate) fn outcome(output: Vec<String>, result: Result<J, ScriptError>) -> Ou
/// events reach the host through [`crate::dispatch_ui`]); palettes and panels keep their host
/// alive for their handlers. The Script Console (and the web build) run inline, where dialogs
/// don't block.
///
/// ExtendScript's `#include` / `#includepath` are inlined first, and errors point at the file
/// and line they came from ([`crate::include`]).
pub fn run(session: &mut Session, req: &ScriptRequest) -> Outcome {
let expanded = match crate::include::expand(session, req.code, req.name) {
Ok(x) => x,
Err(e) => return Outcome { error: Some(e), ..Default::default() },
};
let Some(x) = expanded else { return run_expanded(session, req) };
let mut out = run_expanded(session, &ScriptRequest { code: &x.code, name: req.name, console: req.console });
if let Some(e) = out.error.as_mut() {
x.locate(e);
}
out
}
fn run_expanded(session: &mut Session, req: &ScriptRequest) -> Outcome {
if let Err(message) = check_nesting(req.code) {
return Outcome { error: Some(ScriptError { message, file: req.name.into(), ..Default::default() }), ..Default::default() };
}
+65
View File
@@ -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<PathBuf> {
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<PathBuf, String> {
if Path::new(name).is_absolute() {
return Ok(normalize(name));
}
let candidates: Vec<PathBuf> = 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<String, String> {
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())
}
+94
View File
@@ -0,0 +1,94 @@
//! #759: ExtendScript's `#include` / `//@include` / `#includepath` directives.
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-include-{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 includes_are_inlined_relative_to_the_script() {
let dir = tmp("ok");
write(&dir.join("lib/math.jsx"), "#include \"util.jsxinc\"\nfunction twice(x) { return 2 * x; }");
write(&dir.join("lib/util.jsxinc"), "// includes its includer back: inlined once only\n#include \"math.jsx\"\nvar unit = 'px';");
write(&dir.join("vendor/extra.jsx"), "var extra = 40;");
write(
&dir.join("main.jsx"),
"#target aftereffects\n#targetengine \"main\"\n#strict on\n//@include 'lib/math.jsx'\n#includepath \"vendor;lib\"\n#include \"extra.jsx\";\n#include \"util.jsxinc\"\n[twice(1) + extra, unit, $.fileName.split('/').pop()]",
);
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!([42, "px", "main.jsx"]));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn errors_point_at_the_file_and_line_they_came_from() {
let dir = tmp("err");
write(&dir.join("lib.jsx"), "var a = 1;\nvar b = 2;\nnotDefined();");
write(&dir.join("main.jsx"), "// main\n#include \"lib.jsx\"\nvar c = 3;");
let mut s = session();
let e = run_file(&mut s, &dir.join("main.jsx")).error.expect("an error");
assert!(e.file.ends_with("lib.jsx") && e.line == Some(3), "{e:?}");
// After the include, lines in the script itself keep their numbers.
write(&dir.join("lib.jsx"), "var a = 1;\nvar b = 2;");
write(&dir.join("main.jsx"), "// main\n#include \"lib.jsx\"\nvar c = 3;\nalsoNotDefined();");
let e = run_file(&mut s, &dir.join("main.jsx")).error.expect("an error");
assert!(e.file.ends_with("main.jsx") && e.line == Some(4), "{e:?}");
// A missing file is a clear error on the directive's line.
write(&dir.join("main.jsx"), "var x = 1;\n#include \"missing.jsx\"");
let e = run_file(&mut s, &dir.join("main.jsx")).error.expect("an error");
assert!(e.message.contains("missing.jsx") && e.line == Some(2), "{e:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn hostile_includes_are_bounded_and_gated() {
let dir = tmp("hostile");
// A chain deeper than the limit is an error, not a stack overflow.
for i in 0..100 {
write(&dir.join(format!("chain/{i}.jsx")), &format!("#include \"{}.jsx\"", i + 1));
}
write(&dir.join("chain/100.jsx"), "1");
let mut s = session();
let e = run_file(&mut s, &dir.join("chain/0.jsx")).error.expect("an error");
assert!(e.message.contains("nested too deeply"), "{e:?}");
// Files outside the script's (and the project's) folder need the file preference.
write(&dir.join("outside.jsx"), "var secret = 7;");
write(&dir.join("app/main.jsx"), "#include \"../outside.jsx\"\nsecret");
let e = run_file(&mut s, &dir.join("app/main.jsx")).error.expect("blocked");
assert!(e.message.contains("Allow Scripts to Write Files"), "{e:?}");
// Code run without a file can't include relative to anything but the project's folder.
let e = run_code(&mut s, "#include \"outside.jsx\"", "console").error.expect("no folder");
assert!(e.message.contains("outside.jsx"), "{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);
}