Merge pull request #842 from Ni-zav/fix/817-script-binary-file-io-s20261011T141200Z-fd2e

fix(script): preserve raw bytes in BINARY File reads, writes, and copies
This commit is contained in:
Brandon Thomas committed 2026-10-12 03:23:13 -04:00
commit 80febbd418
3 files changed
+82 -15

No files matched your search

+5 -9
View File
@@ -179,7 +179,7 @@ File.prototype = {
get path() { var p = this.__path.split(/[\/\\]/); p.pop(); return p.join("/"); },
get parent() { return new Folder(this.path); },
get exists() { return JSON.parse(__file("exists", this.__path)) === true; },
get length() { return this.__buf !== null ? this.__buf.length : (this.exists ? JSON.parse(__file("read", this.__path)).length : 0); },
get length() { return this.__buf !== null ? this.__buf.length : (this.exists ? JSON.parse(__file(this.encoding.toUpperCase() === "BINARY" ? "readBinary" : "read", this.__path)).length : 0); },
get eof() { return this.__buf === null || this.__pos >= this.__buf.length; },
toString: function () { return this.__path; },
open: function (mode) {
@@ -193,9 +193,9 @@ File.prototype = {
this.__buf = "";
return true;
}
this.__buf = JSON.parse(__file("read", this.__path));
this.__buf = JSON.parse(__file(this.encoding.toUpperCase() === "BINARY" ? "readBinary" : "read", this.__path));
} else if (mode === "a") {
this.__buf = this.exists ? JSON.parse(__file("read", this.__path)) : "";
this.__buf = this.exists ? JSON.parse(__file(this.encoding.toUpperCase() === "BINARY" ? "readBinary" : "read", this.__path)) : "";
this.__pos = this.__buf.length;
} else {
// Check the gate now, like After Effects refusing to open a file for writing.
@@ -232,18 +232,14 @@ File.prototype = {
tell: function () { return this.__pos; },
close: function () {
if ((this.__mode === "w" || this.__mode === "a" || this.__mode === "e") && this.__buf !== null) {
__file("write", this.__path, this.__buf);
__file(this.encoding.toUpperCase() === "BINARY" ? "writeBinary" : "write", this.__path, this.__buf);
}
this.__buf = null;
this.__mode = null;
return true;
},
remove: function () { return JSON.parse(__file("remove", this.__path)); },
copy: function (to) {
var data = JSON.parse(__file("read", this.__path));
__file("write", __str(to), data);
return true;
},
copy: function (to) { return JSON.parse(__file("copy", this.__path, __str(to))); },
rename: function () { throw __err("File.rename is not supported"); },
execute: function () { return JSON.parse(__file("execute", this.__path)); },
openDlg: function () { return null; },
+31 -6
View File
@@ -346,8 +346,16 @@ fn check_access(s: &Session, path: &str, write: bool) -> Result<std::path::PathB
Err(format!("Unable to access {path}: scripts can only read files in the project's folder unless {GATE} is on"))
}
/// `__file(op, path, data)`: `read` → text, `write`/`append` → bytes, `exists` → bool, `list` →
/// names, `mkdir`, `remove`, `isDir`.
/// Encode a binary script string as bytes, without silently corrupting code points outside
/// the one-byte range supported by ExtendScript's BINARY encoding.
fn binary_bytes(data: &str) -> Result<Vec<u8>, String> {
data.chars()
.map(|c| u8::try_from(c as u32).map_err(|_| format!("BINARY encoding cannot represent U+{:04X}", c as u32)))
.collect()
}
/// `__file(op, path, data)`: text and one-byte-per-character BINARY reads/writes,
/// byte-for-byte copy, plus file and folder operations.
fn native_file(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let op = arg_string(args, 0, ctx)?;
let path = arg_string(args, 1, ctx)?;
@@ -366,19 +374,36 @@ fn native_file(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsV
let Ok(p) = check_access(s, &path, false) else { return Ok(json!(false)) };
Ok(json!(if op == "exists" { p.exists() } else { p.is_dir() }))
}
"read" => {
"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}"))?;
Ok(json!(String::from_utf8_lossy(&bytes)))
if op == "readBinary" {
// Each byte becomes a single JS character (U+0000..U+00FF).
Ok(json!(bytes.into_iter().map(char::from).collect::<String>()))
} else {
Ok(json!(String::from_utf8_lossy(&bytes)))
}
}
"write" | "append" => {
"write" | "writeBinary" | "append" => {
let p = check_access(s, &path, true)?;
let ps = p.to_string_lossy().to_string();
let mut bytes = if op == "append" { s.services.read_file(&ps).unwrap_or_default() } else { vec![] };
bytes.extend_from_slice(data.as_bytes());
if op == "writeBinary" {
bytes.extend(binary_bytes(&data)?);
} else {
bytes.extend_from_slice(data.as_bytes());
}
s.services.write_file(&ps, &bytes).map_err(|e| format!("Unable to write {path}: {e}"))?;
Ok(json!(bytes.len()))
}
"copy" => {
let src = check_access(s, &path, false)?;
let dst = check_access(s, &data, true)?;
// Copy the original bytes, not UTF-8-decoded script text.
let bytes = s.services.read_file(&src.to_string_lossy()).map_err(|e| format!("Unable to read {path}: {e}"))?;
s.services.write_file(&dst.to_string_lossy(), &bytes).map_err(|e| format!("Unable to write {data}: {e}"))?;
Ok(json!(true))
}
"list" => {
let p = check_access(s, &path, false)?;
let mut names: Vec<String> = std::fs::read_dir(&p)
+46
View File
@@ -730,3 +730,49 @@ fn import_options_sequence_picks_one_still_or_the_run() {
assert_eq!(kind_of(&s, "shot_[0001-0003].png"), FootageKind::Sequence);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn binary_file_io_preserves_all_byte_values() {
let dir = std::env::temp_dir().join(format!("ec-script-binary-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let src = dir.join("sig.bin");
let dst = dir.join("written.bin");
let copied = dir.join("copied.bin");
let original = [0x89u8, 0x50, 0x4e, 0x47, 0x00, 0x7f, 0x80, 0xff, 0x0a];
std::fs::write(&src, original).unwrap();
let mut s = session();
s.path = Some(dir.join("project.ecproj").to_string_lossy().to_string());
s.prefs.scripting.allow_scripts_write_files = true;
let q = |p: &std::path::Path| serde_json::to_string(&p.to_string_lossy()).unwrap();
let read = ok(&mut s, &format!(
"var f = new File({}); f.encoding = 'BINARY'; f.open('r'); var data = f.read(); f.close(); [data.length, data.charCodeAt(0), data.charCodeAt(6), data.charCodeAt(7)]",
q(&src)
));
assert_eq!(read.result, json!([9, 0x89, 0x80, 0xff]));
ok(&mut s, &format!(
"var f = new File({}); f.encoding = 'BINARY'; f.open('w'); f.write(String.fromCharCode(0x89, 0x50, 0x4e, 0x47, 0, 127, 128, 255, 10)); f.close();",
q(&dst)
));
assert_eq!(std::fs::read(&dst).unwrap(), original);
let copy = ok(&mut s, &format!("new File({}).copy({})", q(&src), q(&copied)));
assert_eq!(copy.result, json!(true));
assert_eq!(std::fs::read(&copied).unwrap(), original);
ok(&mut s, &format!(
"var f = new File({}); f.encoding = 'BINARY'; f.open('a'); f.write(String.fromCharCode(0xfe)); f.close();",
q(&dst)
));
assert_eq!(std::fs::read(&dst).unwrap(), [original.as_slice(), &[0xfe]].concat());
let invalid = run_code(
&mut s,
&format!("var f = new File({}); f.encoding = 'BINARY'; f.open('w'); f.write(String.fromCharCode(0x100)); f.close();", q(&dst)),
"invalid-binary.jsx",
);
assert!(invalid.error.is_some(), "code points above 255 must not be truncated");
let _ = std::fs::remove_dir_all(&dir);
}