diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index 18770041..d6d93f27 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -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; }, diff --git a/crates/script/src/runtime.rs b/crates/script/src/runtime.rs index ffcf90af..225790c4 100644 --- a/crates/script/src/runtime.rs +++ b/crates/script/src/runtime.rs @@ -346,8 +346,16 @@ fn check_access(s: &Session, path: &str, write: bool) -> Result Result, 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 { 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 { + "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::())) + } 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 = std::fs::read_dir(&p) diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index b50a7bfb..dd08f2eb 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -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); +}