diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index cb97caffc8..13fa42f4d2 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -32,7 +32,7 @@ pub fn specs() -> Vec { "Run Script", [], None, - "{path?: a .js or .jsx file | source?: the script's text (exactly one of the two), name?: the name errors and the report use (default: the script's #script line, else the path's file name, or \"script\"), args?: {…} (at most 1 MiB as JSON; vc.args in the script), answers?: [{kind: alert|confirm|prompt, value: \"ok\" (alert) | true or false (confirm) | text or null (prompt)}] (for a run with nobody to ask: the answers to its dialogs, in order; at most 10000 and 1 MiB as JSON; refused in the app, where the person answers; an empty object is an empty list), maxFuel?: whole number from 1 to 100000000000 (instructions in all; default 1000000000)} run a JavaScript file against the open documents, through the vc object: vc.run(id, params) runs a command and returns its result, a failed command throws an error the script can catch and puts back the documents and the session state it changed, while any file it wrote stays written and is named; vc.document is a snapshot of the active document or null; vc.commands lists the commands a script may run; vc.log(value) adds to the report and the Script Console (script.console); $.write(...values) writes the values to the Script Console as String(value) gives them, and $.writeln(...values) does the same and ends the line; $.evalFile(path, timeout?) runs a file with the same globals and returns its last value (timeout, in milliseconds from 0, is taken and not waited on), $.fileName is the file being run (empty for text) and $.includePath the #includepath list; alert(message[, title, errorIcon]), confirm(message[, noAsDflt, title]) and prompt(message, preset[, title]) open a dialog with a Stop button (default title \"Script Alert\"; message and preset as String(value) gives them; errorIcon and noAsDflt as Boolean(value) gives them; No is the default with noAsDflt): alert returns undefined, confirm true for Yes and false for No, prompt the field's text for OK and null for Cancel; in the app the run waits at the call for the person's answer and Stop ends it as script.stop does, a dialog cannot open inside a callback of a built-in, a getter, a conversion or a file $.evalFile runs and throws there (an #include file's code is the script's own and can), and a run with nobody to ask takes the next of answers, and ends when none is left or the next is for another kind of dialog, or when the script ends with answers unused; vc.args is args. Before anything runs, the script's preprocessor lines (# or //@ at the start of a line, outside strings and comments) are read: #include \"file\" and //@include put that file's text in place of the line, looked for beside the file that includes it, then in each #includepath folder in turn (separated by ;, relative to that file's folder; a later #includepath replaces the list), then in each JSINCLUDE folder; #script names the run; #target takes any value and changes nothing; #strict on makes an assignment to a read-only or getter-only property, or a new property on an object that is not extensible (preventExtensions, seal, freeze, vc, $), throw a TypeError, without the rest of ES5 strict mode, and #strict off turns that off (the last #strict line of the script and its includes sets the run; off without one; a $.evalFile file's own line holds for its code); #targetengine and #engine are refused as unsupported, any other #name as unknown, and another //@name is a comment. A script run from a file reads its includes and $.evalFile's files only in that file's folder and the folders below it, refusing a path with a .. name or a link that leads out (a JSINCLUDE folder outside is passed over); a script given as text reads none. An error in another file is at that file's line and column; vc.api.version and vc.api.has(feature) describe the API. The run is one transaction: on success each changed document gets one undo step, Run Script, and each preference it changed keeps its value, and on an uncaught error, a limit or script.stop every open document and the session go back as they were; files a command wrote before that stay written and are named. While it runs, other commands that change anything end with a busy error. Commands that change anything outside the documents (script.*, except script.check, plugin.*, prefs.*, actionSet.*, shortcutSet.*, help.*, print.*, library.*, importMatches.*, edit.undoGroup.*, file.recovery.*, stroke.widthProfile.*, saving, importing or deleting a preset, text.addFontFiles, text.findFontFiles, text.rescanFonts, links.findFiles, file.place.queue, color.loadProfile, color.removeProfile, file.print, view.proofSetup, view.proofColors, view.overprintPreview, view.separationsPreview) throw. VectorCraft's own limits: 8 MiB of source a file and 32 MiB in all, 64 included or evaluated files 16 deep, 1024 paths looked up for them, 256 MiB of heap (128 MiB on the web), 2000000 host calls, 200000000 instructions without a pause, 64 levels, 16 MiB and 1048576 entries for a value crossing to or from a command, 10000 log entries and 4 MiB of log, 16 KiB of a dialog's message, 256 bytes of its title and 16 KiB of a prompt's preset (past these a RangeError), and 100000 objects and 512 MiB of new document data, past which a person is asked to continue or stop and a run with nobody to ask ends → {ok: true, name, changed: [document names], written: [paths], log: [values], fuelUsed, hostCalls, runTimeMs (the time the script ran, its commands included, waits between frames and for a person's answer left out; 0 without a clock), timingAvailable (false on the web), hostCallCounts: {vcRun, documentReads, commandListReads, vcLog, consoleWrites}, slowestCommands: [{id, calls, totalMs, maxMs}] (at most 5, the longest in all first, then by id), console: {lines: [text], droppedLines, truncatedLines} (the run's own output, vc.log lines and error), dialogs: [{kind, message, title, errorIcon (alert), noAsDefault (confirm), preset (prompt), answer: \"ok\" | true | false | text | null | \"stop\" | \"missing\"}] (in the order they opened, at most 10000 and 1 MiB), dialogCount, dialogsTruncated}, or, in the app, {running: true, name} and the report later in ui.inspect's scriptReport; a run that fails ends with its error, and its report there also has kind, message, line, column, file (when the error is in another file than the script), fuelLeft, fuelUsed, hostCalls and the fields above", + "{path?: a .js or .jsx file | source?: the script's text (exactly one of the two), name?: the name errors and the report use (default: the script's #script line, else the path's file name, or \"script\"), args?: {…} (at most 1 MiB as JSON; vc.args in the script), answers?: [{kind: alert|confirm|prompt, value: \"ok\" (alert) | true or false (confirm) | text or null (prompt)}] (for a run with nobody to ask: the answers to its dialogs, in order; at most 10000 and 1 MiB as JSON; refused in the app, where the person answers; an empty object is an empty list), maxFuel?: whole number from 1 to 100000000000 (instructions in all; default 1000000000)} run a JavaScript file against the open documents, through the vc object: vc.run(id, params) runs a command and returns its result, a failed command throws an error the script can catch and puts back the documents and the session state it changed, while any file it wrote stays written and is named; vc.document is a snapshot of the active document or null; vc.commands lists the commands a script may run; vc.log(value) adds to the report and the Script Console (script.console); $.write(...values) writes the values to the Script Console as String(value) gives them, and $.writeln(...values) does the same and ends the line; $.evalFile(path, timeout?) runs a file with the same globals and returns its last value (timeout, in milliseconds from 0, is taken and not waited on), $.fileName is the file being run (empty for text) and $.includePath the #includepath list; alert(message[, title, errorIcon]), confirm(message[, noAsDflt, title]) and prompt(message, preset[, title]) open a dialog with a Stop button (default title \"Script Alert\"; message and preset as String(value) gives them; errorIcon and noAsDflt as Boolean(value) gives them; No is the default with noAsDflt): alert returns undefined, confirm true for Yes and false for No, prompt the field's text for OK and null for Cancel; in the app the run waits at the call for the person's answer and Stop ends it as script.stop does, a dialog cannot open inside a callback of a built-in, a getter, a conversion or a file $.evalFile runs and throws there (an #include file's code is the script's own and can), and a run with nobody to ask takes the next of answers, and ends when none is left or the next is for another kind of dialog, or when the script ends with answers unused; vc.args is args. Before anything runs, the script's preprocessor lines (# or //@ at the start of a line, outside strings and comments) are read: #include \"file\" and //@include put that file's text in place of the line, looked for beside the file that includes it, then in each #includepath folder in turn (separated by ;, relative to that file's folder; a later #includepath replaces the list), then in each JSINCLUDE folder; #script names the run; #target takes any value and changes nothing; #strict on makes an assignment to a read-only or getter-only property, or a new property on an object that is not extensible (preventExtensions, seal, freeze, vc, $), throw a TypeError, without the rest of ES5 strict mode, and #strict off turns that off (the last #strict line of the script and its includes sets the run; off without one; a $.evalFile file's own line holds for its code); #targetengine and #engine are refused as unsupported, any other #name as unknown, and another //@name is a comment. A script run from a file reads its includes and $.evalFile's files only in that file's folder and the folders below it, refusing a path with a .. name or a link that leads out (a JSINCLUDE folder outside is passed over); a script given as text reads none. File(path) and Folder(path) name files and folders and read them in the same folders, and inside the automation read roots when they are set: exists, length, created, modified, getFiles(mask or function; at most 2048 entries), resolve, and open('r') with read, readln, readch, seek, tell and close; a path outside, a network or device path and a link that leads out give false or null with error \"Script file access denied\", a file is read whole at most 8 MiB (checked from its size first) and a run reads at most 32 MiB, and writing, the file dialogs and execute return false or null with error set. An error in another file is at that file's line and column; vc.api.version and vc.api.has(feature) describe the API. The run is one transaction: on success each changed document gets one undo step, Run Script, and each preference it changed keeps its value, and on an uncaught error, a limit or script.stop every open document and the session go back as they were; files a command wrote before that stay written and are named. While it runs, other commands that change anything end with a busy error. Commands that change anything outside the documents (script.*, except script.check, plugin.*, prefs.*, actionSet.*, shortcutSet.*, help.*, print.*, library.*, importMatches.*, edit.undoGroup.*, file.recovery.*, stroke.widthProfile.*, saving, importing or deleting a preset, text.addFontFiles, text.findFontFiles, text.rescanFonts, links.findFiles, file.place.queue, color.loadProfile, color.removeProfile, file.print, view.proofSetup, view.proofColors, view.overprintPreview, view.separationsPreview) throw. VectorCraft's own limits: 8 MiB of source a file and 32 MiB in all, 64 included or evaluated files 16 deep, 1024 paths looked up for them, 256 MiB of heap (128 MiB on the web), 2000000 host calls, 200000000 instructions without a pause, 64 levels, 16 MiB and 1048576 entries for a value crossing to or from a command, 10000 log entries and 4 MiB of log, 16 KiB of a dialog's message, 256 bytes of its title and 16 KiB of a prompt's preset (past these a RangeError), and 100000 objects and 512 MiB of new document data, past which a person is asked to continue or stop and a run with nobody to ask ends → {ok: true, name, changed: [document names], written: [paths], log: [values], fuelUsed, hostCalls, runTimeMs (the time the script ran, its commands included, waits between frames and for a person's answer left out; 0 without a clock), timingAvailable (false on the web), hostCallCounts: {vcRun, documentReads, commandListReads, vcLog, consoleWrites, fileCalls, fileExecutes}, slowestCommands: [{id, calls, totalMs, maxMs}] (at most 5, the longest in all first, then by id), console: {lines: [text], droppedLines, truncatedLines} (the run's own output, vc.log lines and error), dialogs: [{kind, message, title, errorIcon (alert), noAsDefault (confirm), preset (prompt), answer: \"ok\" | true | false | text | null | \"stop\" | \"missing\"}] (in the order they opened, at most 10000 and 1 MiB), dialogCount, dialogsTruncated}, or, in the app, {running: true, name} and the report later in ui.inspect's scriptReport; a run that fails ends with its error, and its report there also has kind, message, line, column, file (when the error is in another file than the script), fuelLeft, fuelUsed, hostCalls and the fields above", always, run ), diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 3f0a641e2e..27ebc9c40d 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -430,6 +430,133 @@ impl ScriptScope { } Ok((real, bytes)) } + + /// The first granted folder, as people read it (`None` for a script given as text). + pub(crate) fn folder(&self) -> Option { + self.roots.first().map(|r| crate::cmd::findfiles::plain(r.clone())) + } + + /// Does the absolute path `path` name a granted folder or a place inside one, by its names + /// alone, without regard to case? A path that does not is refused before the file system is + /// asked anything about it. + fn names_inside(&self, path: &Path) -> bool { + let lower = |c: Component| c.as_os_str().to_string_lossy().to_lowercase(); + path.is_absolute() + && self.roots.iter().any(|r| { + let root = crate::cmd::findfiles::plain(r.clone()); + let mut names = path.components(); + root.components().all(|c| names.next().is_some_and(|n| lower(n) == lower(c))) + }) + } + + /// The real path of the file or folder at `path`, which must be absolute: refused as + /// [`ScriptScope::read`] refuses a path, and also when its names alone place it outside every + /// granted folder, in which case the file system is not asked about it. + pub(crate) fn locate(&self, path: &Path) -> Result { + if self.roots.is_empty() || Self::refused_by_name(path) || !self.names_inside(path) { + return Err(ScriptRead::Refused); + } + probed(); + if check_read(&path.to_string_lossy()).is_err() { + return Err(ScriptRead::Refused); + } + let real = match std::fs::canonicalize(path) { + Ok(real) => real, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Err(if std::fs::symlink_metadata(path).is_ok() { ScriptRead::Refused } else { ScriptRead::Missing }); + } + Err(e) => return Err(ScriptRead::Failed(e.to_string())), + }; + if !self.roots.iter().any(|r| inside(&real, r)) { + return Err(ScriptRead::Refused); + } + Ok(real) + } + + /// What the file or folder at `path` is, located as [`ScriptScope::locate`] locates it. + pub(crate) fn stat(&self, path: &Path) -> Result { + let real = self.locate(path)?; + let meta = std::fs::metadata(&real).map_err(|e| ScriptRead::Failed(e.to_string()))?; + let link = std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink()); + #[cfg(windows)] + let hidden = std::os::windows::fs::MetadataExt::file_attributes(&meta) & 2 != 0; + #[cfg(not(windows))] + let hidden = real.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')); + Ok(ScriptEntry { + dir: meta.is_dir(), + file: meta.is_file(), + len: meta.len(), + created: meta.created().ok(), + modified: meta.modified().ok(), + hidden, + readonly: meta.permissions().readonly(), + link, + }) + } + + /// The names in the folder at `path` that `keep` keeps, with whether each is a folder, in the + /// order of their names. A name whose links lead out of every granted folder, or out of the + /// automation read roots in force, is left out. More + /// than `max` names kept, or more than [`MAX_LISTED`] names looked at, is + /// [`ScriptRead::TooLarge`]. + pub(crate) fn list(&self, path: &Path, max: usize, keep: impl Fn(&str) -> bool) -> Result, ScriptRead> { + let real = self.locate(path)?; + let entries = std::fs::read_dir(&real).map_err(|e| ScriptRead::Failed(e.to_string()))?; + let mut out = Vec::new(); + for (n, entry) in entries.enumerate() { + if n >= MAX_LISTED { + return Err(ScriptRead::TooLarge); + } + let Ok(entry) = entry else { continue }; + let name = entry.file_name().to_string_lossy().into_owned(); + if !keep(&name) { + continue; + } + let Ok(target) = std::fs::canonicalize(entry.path()) else { continue }; + if !self.roots.iter().any(|r| inside(&target, r)) || check_read(&entry.path().to_string_lossy()).is_err() { + continue; + } + if out.len() >= max { + return Err(ScriptRead::TooLarge); + } + out.push((name, target.is_dir())); + } + out.sort(); + Ok(out) + } +} + +/// The most names [`ScriptScope::list`] looks at in one folder. +pub(crate) const MAX_LISTED: usize = 65_536; + +/// A file or folder as [`ScriptScope::stat`] gives it. +#[derive(Clone, Debug)] +pub(crate) struct ScriptEntry { + pub dir: bool, + /// A regular file. + pub file: bool, + pub len: u64, + pub created: Option, + pub modified: Option, + pub hidden: bool, + pub readonly: bool, + /// The path itself is a symbolic link. + pub link: bool, +} + +thread_local! { + /// How many paths [`ScriptScope::locate`] has asked the file system about on this thread. + static PROBES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +fn probed() { + PROBES.with(|p| p.set(p.get().saturating_add(1))); +} + +/// How many paths [`ScriptScope::locate`] has asked the file system about on this thread. +#[cfg(test)] +pub(crate) fn script_probes() -> usize { + PROBES.with(std::cell::Cell::get) } #[cfg(test)] diff --git a/crates/engine/src/script/files.rs b/crates/engine/src/script/files.rs new file mode 100644 index 0000000000..3f53814992 --- /dev/null +++ b/crates/engine/src/script/files.rs @@ -0,0 +1,1001 @@ +//! `File` and `Folder`: a script's objects for files and folders, as the help pages describe them +//! (, +//! and +//! ). The classes are +//! built-ins of the realm ([`Realm::define_file_classes`]); the host functions here make every +//! path check and every read. +//! +//! An object holds an absolute path: a relative path is taken from `Folder.current`, `~` is the home +//! folder, `%xx` escapes are read as UTF-8, and on Windows both `/` and `\` separate names and +//! `/c/…` names drive C. Naming a path does not look anything up. +//! +//! VectorCraft's own rules: +//! +//! - A script reads only inside its [`ScriptScope`]: a script run from a file reads in that file's +//! folder and the folders below it, and a script given as text reads nothing. A path whose names +//! lie outside, a Windows network or device path (`\\host\share`, `\\?\`, `\\.\`), and a path that +//! leads out through a link are refused before or instead of opening anything: `exists` is false, +//! the dates are `null`, `getFiles` gives `null`, and `error` is [`ACCESS_DENIED`]. The automation +//! read roots apply as well, so a run with roots in force reads only inside both. +//! - `File(path)` without `new` gives a `Folder` for an existing folder, and `Folder(path)` gives a +//! `File` for an existing file, only where the path may be read. +//! - Opening reads the whole file, at most [`MAX_FILE_BYTES`], checked from its size before any of +//! it is read; a run reads at most [`MAX_RUN_BYTES`] in all and ends past that, and has at most +//! [`MAX_OPEN`] files open at once. +//! - The encoding of an opened file is the one its byte order mark gives (UTF-8, UTF-16LE or +//! UTF-16BE), otherwise the one the script set, otherwise UTF-8 when the bytes are valid UTF-8, +//! otherwise CP1252. `read`, `readln` and `readch` count characters and `seek` and `tell` bytes; +//! `readln` and `readch` take CR, LF, CRLF and LFCR as one line end, and `readch` gives a line end +//! as `"\n"`. A character the encoding cannot decode fails the read with [`UNDECODABLE`] and +//! leaves the position where it was. +//! - `getFiles` lists at most [`MAX_ENTRIES`] entries, sorted by name, and looks at most +//! [`crate::file_access::MAX_LISTED`] names; a mask matches `*` and `?` without regard to case, +//! in at most [`MAX_MASK_STEPS`] steps for one listing. +//! - A path longer than [`MAX_PATH_CHARS`] characters is refused before it is split into names. +//! - Writing (`open` with `w`, `e` or `a`, `write`, `copy`, `remove`, `rename`, `create` and the +//! property setters), the file and folder dialogs and `execute` are not available: they return +//! `false` or `null` and set `error`. `execute` is counted in the report's `fileExecutes`. +//! - In a wasm build every lookup fails with [`NO_WEB`]. + +use std::collections::HashMap; +use std::path::Path; +use std::rc::Rc; + +use vectorcraft_js::{Data, HostError, HostItem, Realm, RunError}; + +use super::{Host, bump}; +use crate::file_access::{ScriptRead, ScriptScope}; + +/// The most bytes of one file a script opens. +pub const MAX_FILE_BYTES: usize = 8 << 20; +/// The most bytes of files one run opens, all of them counted together. +pub const MAX_RUN_BYTES: usize = 32 << 20; +/// The most entries `getFiles` gives. +pub const MAX_ENTRIES: usize = 2048; +/// The most files a run has open at once. +pub const MAX_OPEN: usize = 256; +/// The most characters of a path a script gives. +pub const MAX_PATH_CHARS: usize = 32_767; +/// The most steps one `getFiles` listing spends matching its mask against names. +pub const MAX_MASK_STEPS: usize = 1 << 24; + +pub(crate) const ACCESS_DENIED: &str = "Script file access denied"; +pub(crate) const TOO_LARGE: &str = "Script file exceeds 8 MiB"; +pub(crate) const NOT_OPEN: &str = "Script file is not open"; +pub(crate) const UNDECODABLE: &str = "Script file cannot be decoded"; +pub(crate) const NOT_REGULAR: &str = "Script file is not a regular file"; +pub(crate) const NOT_FOUND: &str = "Script file does not exist"; +pub(crate) const READ_FAILED: &str = "Script file could not be read"; +pub(crate) const LISTING_TOO_LARGE: &str = "Script folder has more than 2,048 entries"; +pub(crate) const BAD_MODE: &str = "Script file open mode must be r, w, e or a"; +pub(crate) const NO_WRITES: &str = "Script file writing is not available"; +pub(crate) const NO_PICKER: &str = "Script file dialogs are not available"; +pub(crate) const NO_EXECUTE: &str = "Script files cannot be opened in another application"; +pub(crate) const NO_WEB: &str = "Script file access is unavailable on the web"; +pub(crate) const TOO_MANY_OPEN: &str = "Script has 256 files open"; +pub(crate) const PATH_TOO_LONG: &str = "Script file path exceeds 32,767 characters"; +pub(crate) const MASK_TOO_COSTLY: &str = "Script folder mask takes too many steps to match"; +const OVER_RUN_BYTES: &str = "the script opened more file data than one run may open (32 MiB)"; + +/// The script's file state: its scope, where `Folder.current` starts, the files it has open and how +/// much it has read. +pub(crate) struct Disk { + scope: ScriptScope, + home: String, + start: String, + open: HashMap, + next: u32, + temps: u32, + bytes: usize, +} + +impl Disk { + /// The state of a run whose own files are read in `scope`. + pub(crate) fn new(scope: &ScriptScope) -> Self { + let home = home_folder(); + let start = scope.folder().map_or_else(|| home.clone(), |p| p.to_string_lossy().into_owned()); + Self { scope: scope.clone(), home, start, open: HashMap::new(), next: 1, temps: 0, bytes: 0 } + } + + /// The path `text` names from the folder `base`, a platform path; `text`'s URI escapes are + /// decoded when `uri` is set. + fn pathname(&self, text: &str, base: &str, uri: bool) -> Pathname { + let root = Pathname::root(WINDOWS); + let home = Pathname::parse(&self.home, &root, &root, WINDOWS, false); + let base = Pathname::parse(base, &home, &home, WINDOWS, false); + Pathname::parse(text, &base, &home, WINDOWS, uri) + } + + /// The path `path` as the scope takes it, or why it is refused by its form alone. + fn checked<'a>(&self, path: &'a str) -> Result<&'a Path, &'static str> { + if cfg!(target_arch = "wasm32") { + return Err(NO_WEB); + } + if too_long(path) { + return Err(PATH_TOO_LONG); + } + let p = Path::new(path); + if !p.is_absolute() || (WINDOWS && (path.starts_with(r"\\") || path.starts_with("//"))) { + return Err(ACCESS_DENIED); + } + Ok(p) + } + + fn stat(&self, path: &str) -> Result, &'static str> { + match self.scope.stat(self.checked(path)?) { + Ok(e) => Ok(Some(e)), + Err(ScriptRead::Missing) => Ok(None), + Err(ScriptRead::Refused | ScriptRead::TooLarge) => Err(ACCESS_DENIED), + Err(ScriptRead::Failed(_)) => Err(READ_FAILED), + } + } + + fn open_file(&mut self, path: &str, encoding: &str) -> Result { + let fail = |m: &str| Err(HostError::Error(m.to_string())); + if self.open.len() >= MAX_OPEN { + return fail(TOO_MANY_OPEN); + } + let entry = match self.stat(path) { + Ok(Some(e)) => e, + Ok(None) => return fail(NOT_FOUND), + Err(m) => return fail(m), + }; + if !entry.file { + return fail(NOT_REGULAR); + } + if entry.len > MAX_FILE_BYTES as u64 { + return fail(TOO_LARGE); + } + if self.bytes.saturating_add(entry.len as usize) > MAX_RUN_BYTES { + return Err(HostError::Abort(OVER_RUN_BYTES.to_string())); + } + let bytes = match self.scope.read(Path::new(path), MAX_FILE_BYTES) { + Ok((_, bytes)) => bytes, + Err(ScriptRead::TooLarge) => return fail(TOO_LARGE), + Err(ScriptRead::Missing) => return fail(NOT_FOUND), + Err(ScriptRead::Refused) => return fail(ACCESS_DENIED), + Err(ScriptRead::Failed(_)) => return fail(READ_FAILED), + }; + if self.bytes.saturating_add(bytes.len()) > MAX_RUN_BYTES { + return Err(HostError::Abort(OVER_RUN_BYTES.to_string())); + } + self.bytes += bytes.len(); + let file = Open::new(bytes, encoding).ok_or_else(|| HostError::Error(UNDECODABLE.to_string()))?; + let id = self.next; + self.next = self.next.wrapping_add(1).max(1); + self.open.insert(id, file); + Ok(id) + } +} + +/// Whether paths are read as Windows reads them. +const WINDOWS: bool = cfg!(windows); + +/// Does `path` have more than [`MAX_PATH_CHARS`] characters? +fn too_long(path: &str) -> bool { + path.len() > MAX_PATH_CHARS && path.chars().nth(MAX_PATH_CHARS).is_some() +} + +/// The home folder, as the environment gives it. +fn home_folder() -> String { + let var = if WINDOWS { "USERPROFILE" } else { "HOME" }; + std::env::var(var).unwrap_or_else(|_| if WINDOWS { r"C:\".to_string() } else { "/".to_string() }) +} + +/// The folders `Folder.temp`, `Folder.desktop` and the others name, `null` where there is none. +fn locations(home: &str) -> Data { + let env = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty()); + let join = |a: &str, b: &str| Path::new(a).join(b).to_string_lossy().into_owned(); + let exe = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_string_lossy().into_owned())); + let temp = std::env::temp_dir().to_string_lossy().into_owned(); + let (user_data, app_data, common, system, trash) = if WINDOWS { + (env("APPDATA"), env("PROGRAMDATA"), env("CommonProgramFiles"), env("SystemRoot"), None) + } else if cfg!(target_os = "macos") { + ( + Some(join(home, "Library/Application Support")), + Some("/Library/Application Support".to_string()), + Some("/Library/Application Support".to_string()), + Some("/System".to_string()), + Some(join(home, ".Trash")), + ) + } else { + let data = env("XDG_DATA_HOME").unwrap_or_else(|| join(home, ".local/share")); + (Some(data.clone()), Some("/usr/share".to_string()), Some("/usr/share".to_string()), Some("/".to_string()), Some(join(&data, "Trash"))) + }; + let s = |v: Option| v.map_or(Data::Null, Data::String); + let web = cfg!(target_arch = "wasm32"); + let places = vec![ + ("appData", app_data), + ("appPackage", exe.clone()), + ("commonFiles", common), + ("desktop", Some(join(home, "Desktop"))), + ("myDocuments", Some(join(home, "Documents"))), + ("startup", exe), + ("system", system), + ("temp", Some(temp)), + ("trash", trash), + ("userData", user_data), + ]; + Data::Object(places.into_iter().map(|(k, v)| (k.to_string(), if web { Data::Null } else { s(v) })).collect()) +} + +/// The start of an absolute path. +#[derive(Clone, Debug, PartialEq, Eq)] +enum Root { + /// `/`. + Unix, + /// A Windows drive letter, in upper case. + Drive(char), + /// A Windows share: `\\host\share`. + Share(String, String), + /// A Windows `\\?\` or `\\.\` path, kept as given. + Device(String), +} + +/// An absolute path: its root and its names, with `.` and `..` resolved. +#[derive(Clone, Debug, PartialEq, Eq)] +struct Pathname { + root: Root, + names: Vec, +} + +impl Pathname { + fn root(windows: bool) -> Self { + Self { root: if windows { Root::Drive('C') } else { Root::Unix }, names: Vec::new() } + } + + /// The path `text` names, relative ones taken from `base` and `~` from `home`, with its URI + /// escapes decoded when `uri` is set. + fn parse(text: &str, base: &Pathname, home: &Pathname, windows: bool, uri: bool) -> Self { + let decoded = if uri { decode(text) } else { text.to_string() }; + let t = if windows { decoded.replace('\\', "/") } else { decoded }; + let (start, rest): (Pathname, &str) = if windows && (t.starts_with("//?/") || t.starts_with("//./")) { + return Self { root: Root::Device(t.replace('/', "\\")), names: Vec::new() }; + } else if let Some(r) = t.strip_prefix("//").filter(|_| windows) { + let mut parts = r.splitn(3, '/'); + let host = parts.next().unwrap_or_default().to_string(); + let share = parts.next().unwrap_or_default().to_string(); + (Self { root: Root::Share(host, share), names: Vec::new() }, parts.next().unwrap_or_default()) + } else if windows && t.len() >= 2 && t.as_bytes()[0].is_ascii_alphabetic() && t.as_bytes()[1] == b':' { + (Self { root: Root::Drive(t.as_bytes()[0].to_ascii_uppercase() as char), names: Vec::new() }, t.get(2..).unwrap_or_default()) + } else if let Some(r) = t.strip_prefix('/') { + let (first, after) = r.split_once('/').unwrap_or((r, "")); + if windows && first.len() == 1 && first.as_bytes()[0].is_ascii_alphabetic() { + (Self { root: Root::Drive(first.as_bytes()[0].to_ascii_uppercase() as char), names: Vec::new() }, after) + } else if windows { + let root = match &base.root { + Root::Drive(_) | Root::Share(..) => base.root.clone(), + _ => Root::Drive('C'), + }; + (Self { root, names: Vec::new() }, r) + } else { + (Self { root: Root::Unix, names: Vec::new() }, r) + } + } else if t == "~" || t.starts_with("~/") { + (home.clone(), t.get(1..).unwrap_or_default()) + } else { + (base.clone(), t.as_str()) + }; + let mut out = start; + for name in rest.split('/') { + match name { + "" | "." => {} + ".." => { + out.names.pop(); + } + n => out.names.push(n.to_string()), + } + } + out + } + + /// The path as the platform writes it. + fn fs(&self) -> String { + match &self.root { + Root::Unix => format!("/{}", self.names.join("/")), + Root::Drive(c) => format!("{c}:\\{}", self.names.join("\\")), + Root::Share(host, share) => self.names.iter().fold(format!(r"\\{host}\{share}"), |s, n| format!("{s}\\{n}")), + Root::Device(raw) => raw.clone(), + } + } + + /// The path in URI notation, each name escaped. + fn uri(&self) -> String { + let start = match &self.root { + Root::Unix => String::new(), + Root::Drive(c) => format!("/{}", c.to_ascii_lowercase()), + Root::Share(host, share) => format!("//{}/{}", encode(host), encode(share)), + Root::Device(raw) => return raw.replace('\\', "/"), + }; + let names: String = self.names.iter().map(|n| format!("/{}", encode(n))).collect(); + if start.is_empty() && names.is_empty() { "/".to_string() } else { start + &names } + } + + fn parent(&self) -> Option { + let mut p = self.clone(); + p.names.pop().map(|_| p) + } + + /// `self` relative to the folder `base`, in URI notation; the whole URI when they have different + /// roots. + fn relative_to(&self, base: &Pathname, windows: bool) -> String { + if self.root != base.root { + return self.uri(); + } + let same = |a: &String, b: &String| if windows { a.to_lowercase() == b.to_lowercase() } else { a == b }; + let common = self.names.iter().zip(&base.names).take_while(|(a, b)| same(a, b)).count(); + let mut parts: Vec = vec!["..".to_string(); base.names.len() - common]; + parts.extend(self.names.iter().skip(common).map(|n| encode(n))); + if parts.is_empty() { ".".to_string() } else { parts.join("/") } + } + + /// The parts of `File` and `Folder` objects that come from the path alone. + fn data(&self) -> Data { + let s = |v: String| Data::String(v); + Data::Object(vec![ + ("fs".into(), s(self.fs())), + ("uri".into(), s(self.uri())), + ("name".into(), s(self.names.last().map(|n| encode(n)).unwrap_or_default())), + ("display".into(), s(self.names.last().cloned().unwrap_or_default())), + ("path".into(), s(self.parent().map(|p| p.uri()).unwrap_or_default())), + ("parent".into(), self.parent().map_or(Data::Null, |p| Data::String(p.fs()))), + ]) + } +} + +/// `s` with every character but letters, digits and `-_.!~*'()` written as `%xx` escapes of its +/// UTF-8 bytes (RFC 2396). +fn encode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + if b.is_ascii_alphanumeric() || b"-_.!~*'()".contains(&b) { + out.push(b as char); + } else { + out.push_str(&format!("%{b:02X}")); + } + } + out +} + +/// `s` with its `%xx` escapes read as UTF-8 bytes; `s` as it is when they do not make UTF-8. +fn decode(s: &str) -> String { + let b = s.as_bytes(); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while i < b.len() { + let hex = |c: u8| (c as char).to_digit(16); + match (b[i], b.get(i + 1).copied().and_then(hex), b.get(i + 2).copied().and_then(hex)) { + (b'%', Some(h), Some(l)) => { + out.push((h * 16 + l) as u8); + i += 3; + } + (c, ..) => { + out.push(c); + i += 1; + } + } + } + String::from_utf8(out).unwrap_or_else(|_| s.to_string()) +} + +/// A `getFiles` mask, where `*` stands for any run of characters and `?` for one, without regard +/// to case, read once for a whole listing. +struct Mask { + /// The mask in lower case, each run of `*` written as one. + chars: Vec, + /// How many of its characters are not `*`: a name with fewer characters does not match. + fixed: usize, +} + +impl Mask { + fn new(mask: &str) -> Self { + let mut chars: Vec = Vec::new(); + for c in mask.to_lowercase().chars() { + if !(c == '*' && chars.last() == Some(&'*')) { + chars.push(c); + } + } + let fixed = chars.iter().filter(|c| **c != '*').count(); + Self { chars, fixed } + } + + /// Does `name` match, taking a step from `steps` for each character compared? `None` when the + /// steps run out first. + fn matches(&self, name: &str, steps: &mut usize) -> Option { + let n: Vec = name.to_lowercase().chars().collect(); + if n.len() < self.fixed { + return Some(false); + } + matched(&self.chars, &n, steps) + } +} + +/// Does `n` match the lower-case mask `m`? `None` when `steps` run out first. +fn matched(m: &[char], n: &[char], steps: &mut usize) -> Option { + let (mut i, mut j, mut star, mut mark) = (0usize, 0usize, None, 0usize); + while j < n.len() { + *steps = steps.checked_sub(1)?; + match m.get(i) { + Some('*') => { + star = Some(i); + mark = j; + i += 1; + } + Some(c) if *c == '?' || *c == n[j] => { + i += 1; + j += 1; + } + _ => match star { + Some(s) => { + i = s + 1; + mark += 1; + j = mark; + } + None => return Some(false), + }, + } + } + Some(m[i..].iter().all(|c| *c == '*')) +} + +/// A character encoding an opened file is read in. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Encoding { + Utf8, + Ascii, + Binary, + Latin1, + Cp1252, + Utf16Le, + Utf16Be, +} + +/// The characters CP1252 gives bytes 0x80 to 0x9F; the five it leaves undefined stand for +/// themselves. +const CP1252_HIGH: [u16; 32] = [ + 0x20AC, 0x0081, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0x008D, 0x017D, 0x008F, 0x0090, 0x2018, + 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x009D, 0x017E, 0x0178, +]; + +impl Encoding { + fn parse(name: &str) -> Option { + Some(match name.trim().to_ascii_uppercase().as_str() { + "UTF-8" | "UTF8" => Self::Utf8, + "ASCII" | "US-ASCII" => Self::Ascii, + "BINARY" => Self::Binary, + "ISO-8859-1" | "ISO8859-1" | "LATIN1" | "LATIN-1" => Self::Latin1, + "CP1252" | "WINDOWS-1252" => Self::Cp1252, + "UTF-16LE" | "UTF16LE" => Self::Utf16Le, + "UTF-16" | "UTF16" | "UTF-16BE" | "UTF16BE" | "UCS-2" => Self::Utf16Be, + _ => return None, + }) + } + + fn name(self) -> &'static str { + match self { + Self::Utf8 => "UTF-8", + Self::Ascii => "ASCII", + Self::Binary => "BINARY", + Self::Latin1 => "ISO-8859-1", + Self::Cp1252 => "CP1252", + Self::Utf16Le => "UTF-16LE", + Self::Utf16Be => "UTF-16BE", + } + } +} + +/// An opened file: its bytes, the byte position and the encoding it is read in. +struct Open { + bytes: Vec, + pos: usize, + encoding: Encoding, + name: String, +} + +impl Open { + /// `bytes` opened with the encoding `asked` (empty: none asked); `None` when `asked` is not an + /// encoding and the file has no byte order mark. + fn new(bytes: Vec, asked: &str) -> Option { + let (encoding, pos) = if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) { + (Encoding::Utf8, 3) + } else if bytes.starts_with(&[0xFF, 0xFE]) { + (Encoding::Utf16Le, 2) + } else if bytes.starts_with(&[0xFE, 0xFF]) { + (Encoding::Utf16Be, 2) + } else if !asked.is_empty() { + (Encoding::parse(asked)?, 0) + } else if std::str::from_utf8(&bytes).is_ok() { + (Encoding::Utf8, 0) + } else { + (Encoding::Cp1252, 0) + }; + Some(Self { bytes, pos, encoding, name: encoding.name().to_string() }) + } + + /// The character at byte `at` and its length in bytes; `Ok(None)` at the end, `Err` where the + /// encoding cannot decode it. + fn char_at(&self, at: usize) -> Result, ()> { + let Some(&b) = self.bytes.get(at) else { return Ok(None) }; + let encoding = self.encoding; + let unit = |i: usize| -> Option { + let pair = self.bytes.get(i..i + 2)?; + Some(if encoding == Encoding::Utf16Le { u16::from_le_bytes([pair[0], pair[1]]) } else { u16::from_be_bytes([pair[0], pair[1]]) }) + }; + let c = match encoding { + Encoding::Ascii if b < 0x80 => (b as char, 1), + Encoding::Ascii => return Err(()), + Encoding::Binary | Encoding::Latin1 => (b as char, 1), + Encoding::Cp1252 => { + let code = if (0x80..0xA0).contains(&b) { u32::from(CP1252_HIGH[usize::from(b - 0x80)]) } else { u32::from(b) }; + (char::from_u32(code).ok_or(())?, 1) + } + Encoding::Utf8 => { + let len = match b { + 0x00..=0x7F => 1, + 0xC2..=0xDF => 2, + 0xE0..=0xEF => 3, + 0xF0..=0xF4 => 4, + _ => return Err(()), + }; + let s = self.bytes.get(at..at + len).and_then(|s| std::str::from_utf8(s).ok()).ok_or(())?; + (s.chars().next().ok_or(())?, len) + } + Encoding::Utf16Le | Encoding::Utf16Be => { + let first = unit(at).ok_or(())?; + if (0xD800..0xDC00).contains(&first) { + let second = unit(at + 2).filter(|u| (0xDC00..0xE000).contains(u)).ok_or(())?; + let code = 0x10000 + ((u32::from(first) - 0xD800) << 10) + (u32::from(second) - 0xDC00); + (char::from_u32(code).ok_or(())?, 4) + } else { + (char::from_u32(u32::from(first)).ok_or(())?, 2) + } + } + }; + Ok(Some(c)) + } + + /// Up to `max` characters from the position (all of them for `None`). + fn read(&mut self, max: Option) -> Result { + let (mut at, mut out) = (self.pos, String::new()); + let mut n = 0; + while max.is_none_or(|m| n < m) { + let Some((c, len)) = self.char_at(at)? else { break }; + out.push(c); + at += len; + n += 1; + } + self.pos = at; + Ok(out) + } + + /// The position after a line end that began with `c` and continues at `at` (CR, LF, CRLF or + /// LFCR). A character there that cannot be decoded is left for the next read. + fn past_line_end(&self, c: char, at: usize) -> usize { + let other = if c == '\r' { '\n' } else { '\r' }; + match self.char_at(at) { + Ok(Some((n, len))) if n == other => at + len, + _ => at, + } + } + + /// The characters up to the next line end, which is passed over. + fn read_line(&mut self) -> Result { + let (mut at, mut out) = (self.pos, String::new()); + while let Some((c, len)) = self.char_at(at)? { + at += len; + if c == '\r' || c == '\n' { + at = self.past_line_end(c, at); + break; + } + out.push(c); + } + self.pos = at; + Ok(out) + } + + /// The next character; a line end is one `"\n"`. + fn read_char(&mut self) -> Result { + let Some((c, len)) = self.char_at(self.pos)? else { return Ok(String::new()) }; + let mut at = self.pos + len; + let c = if c == '\r' || c == '\n' { + at = self.past_line_end(c, at); + '\n' + } else { + c + }; + self.pos = at; + Ok(c.to_string()) + } +} + +/// `{text}` for a read that worked, `{error}` for one that did not. +fn text_result(r: Result) -> Data { + match r { + Ok(t) => Data::Object(vec![("text".into(), Data::String(t))]), + Err(()) => error(UNDECODABLE), + } +} + +fn error(message: &str) -> Data { + Data::Object(vec![("error".into(), Data::String(message.to_string()))]) +} + +fn arg_text(a: &[Data], i: usize) -> String { + match a.get(i) { + Some(Data::String(s)) => s.clone(), + _ => String::new(), + } +} + +fn arg_number(a: &[Data], i: usize) -> Option { + match a.get(i) { + Some(Data::Number(n)) => Some(*n), + _ => None, + } +} + +fn millis(t: Option) -> Data { + t.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map_or(Data::Null, |d| Data::Number(d.as_millis() as f64)) +} + +/// One host function of `__vcFiles`, counted and charged as a host call. +fn call(host: &Rc, f: fn(&Host, &[Data]) -> Result) -> HostItem { + let h = host.clone(); + HostItem::Function(Rc::new(move |a| { + bump(&h.counts.file_calls); + let r = f(&h, &a)?; + h.charge_call(super::data_size(&r).saturating_add(a.iter().map(super::data_size).sum()))?; + Ok(r) + })) +} + +fn handle(a: &[Data]) -> u32 { + arg_number(a, 0).filter(|n| n.is_finite() && *n >= 0.0 && *n <= f64::from(u32::MAX)).map_or(0, |n| n as u32) +} + +fn with_open(host: &Host, a: &[Data], f: impl FnOnce(&mut Open) -> Data) -> Data { + match host.disk.borrow_mut().open.get_mut(&handle(a)) { + Some(file) => f(file), + None => error(NOT_OPEN), + } +} + +/// The first two arguments as path text, refused with a `RangeError` when either is too long. +fn two_paths(a: &[Data]) -> Result<(String, String), HostError> { + let (path, base) = (arg_text(a, 0), arg_text(a, 1)); + if too_long(&path) || too_long(&base) { + return Err(HostError::Range(PATH_TOO_LONG.to_string())); + } + Ok((path, base)) +} + +fn parse(host: &Host, a: &[Data]) -> Result { + let (path, base) = two_paths(a)?; + let platform = matches!(a.get(2), Some(Data::Bool(true))); + let disk = host.disk.borrow(); + Ok(disk.pathname(&path, &base, !platform).data()) +} + +/// The platform path `a[0]` relative to the platform path `a[1]`. +fn relative(host: &Host, a: &[Data]) -> Result { + let (path, base) = two_paths(a)?; + let disk = host.disk.borrow(); + let base = disk.pathname(&base, &disk.start, false); + Ok(Data::String(disk.pathname(&path, &disk.start, false).relative_to(&base, WINDOWS))) +} + +fn stat(host: &Host, a: &[Data]) -> Result { + Ok(match host.disk.borrow().stat(&arg_text(a, 0)) { + Err(m) => error(m), + Ok(None) => Data::Object(vec![("exists".into(), Data::Bool(false))]), + Ok(Some(e)) => Data::Object(vec![ + ("exists".into(), Data::Bool(true)), + ("dir".into(), Data::Bool(e.dir)), + ("file".into(), Data::Bool(e.file)), + ("length".into(), Data::Number(e.len as f64)), + ("created".into(), millis(e.created)), + ("modified".into(), millis(e.modified)), + ("hidden".into(), Data::Bool(e.hidden)), + ("readonly".into(), Data::Bool(e.readonly)), + ("alias".into(), Data::Bool(e.link)), + ]), + }) +} + +fn open(host: &Host, a: &[Data]) -> Result { + match host.disk.borrow_mut().open_file(&arg_text(a, 0), &arg_text(a, 1)) { + Ok(id) => Ok(Data::Object(vec![("id".into(), Data::Number(f64::from(id)))])), + Err(HostError::Error(m)) => Ok(error(&m)), + Err(e) => Err(e), + } +} + +fn close(host: &Host, a: &[Data]) -> Result { + Ok(Data::Bool(host.disk.borrow_mut().open.remove(&handle(a)).is_some())) +} + +fn read(host: &Host, a: &[Data]) -> Result { + let max = arg_number(a, 1).filter(|n| *n >= 0.0).map(|n| n as usize); + Ok(with_open(host, a, |f| text_result(f.read(max)))) +} + +fn readln(host: &Host, a: &[Data]) -> Result { + Ok(with_open(host, a, |f| text_result(f.read_line()))) +} + +fn readch(host: &Host, a: &[Data]) -> Result { + Ok(with_open(host, a, |f| text_result(f.read_char()))) +} + +fn seek(host: &Host, a: &[Data]) -> Result { + let (Some(pos), Some(mode)) = (arg_number(a, 1), arg_number(a, 2)) else { return Err(HostError::Type(BAD_SEEK.into())) }; + Ok(with_open(host, a, |f| { + let base = match mode as u8 { + 1 => f.pos as f64, + 2 => f.bytes.len() as f64, + _ => 0.0, + }; + let to = base + pos.trunc(); + if !(0.0..=f.bytes.len() as f64).contains(&to) { + return Data::Bool(false); + } + f.pos = to as usize; + Data::Bool(true) + })) +} + +const BAD_SEEK: &str = "File.seek: takes a position and a mode"; + +fn tell(host: &Host, a: &[Data]) -> Result { + Ok(with_open(host, a, |f| Data::Number(f.pos as f64))) +} + +fn eof(host: &Host, a: &[Data]) -> Result { + Ok(Data::Bool(host.disk.borrow().open.get(&handle(a)).is_none_or(|f| f.pos >= f.bytes.len()))) +} + +fn encoding(host: &Host, a: &[Data]) -> Result { + Ok(with_open(host, a, |f| Data::String(f.name.clone()))) +} + +fn set_encoding(host: &Host, a: &[Data]) -> Result { + let name = arg_text(a, 1); + Ok(with_open(host, a, |f| match Encoding::parse(&name) { + Some(e) => { + f.encoding = e; + f.name = e.name().to_string(); + Data::Object(vec![]) + } + None => error(UNDECODABLE), + })) +} + +fn list(host: &Host, a: &[Data]) -> Result { + let disk = host.disk.borrow(); + let path = arg_text(a, 0); + let mask = Mask::new(&arg_text(a, 1)); + let checked = match disk.checked(&path) { + Ok(p) => p, + Err(m) => return Ok(error(m)), + }; + let steps = std::cell::Cell::new(MAX_MASK_STEPS); + let spent = std::cell::Cell::new(false); + let keep = |name: &str| { + let mut left = steps.get(); + let kept = mask.matches(name, &mut left); + steps.set(left); + spent.set(spent.get() || kept.is_none()); + kept.unwrap_or(false) + }; + let listed = disk.scope.list(checked, MAX_ENTRIES, keep); + if spent.get() { + return Ok(error(MASK_TOO_COSTLY)); + } + let found = match listed { + Ok(found) => found, + Err(ScriptRead::TooLarge) => return Ok(error(LISTING_TOO_LARGE)), + Err(ScriptRead::Missing) => return Ok(error(NOT_FOUND)), + Err(ScriptRead::Refused) => return Ok(error(ACCESS_DENIED)), + Err(ScriptRead::Failed(_)) => return Ok(error(READ_FAILED)), + }; + let entries = found + .into_iter() + .map(|(name, dir)| { + let fs = Path::new(&path).join(&name).to_string_lossy().into_owned(); + Data::Object(vec![("fs".into(), Data::String(fs)), ("dir".into(), Data::Bool(dir))]) + }) + .collect(); + Ok(Data::Object(vec![("entries".into(), Data::Array(entries))])) +} + +fn resolve(host: &Host, a: &[Data]) -> Result { + let disk = host.disk.borrow(); + let path = arg_text(a, 0); + let target = match disk.stat(&path) { + Err(m) => return Ok(error(m)), + Ok(Some(e)) if e.link => disk.checked(&path).ok().and_then(|p| disk.scope.locate(p).ok()), + Ok(_) => None, + }; + Ok(match target { + Some(real) => { + let dir = real.is_dir(); + let fs = crate::cmd::findfiles::plain(real).to_string_lossy().into_owned(); + Data::Object(vec![("fs".into(), Data::String(fs)), ("dir".into(), Data::Bool(dir))]) + } + None => Data::Object(vec![("fs".into(), Data::Null)]), + }) +} + +fn execute(host: &Host, _: &[Data]) -> Result { + bump(&host.counts.file_executes); + Ok(error(NO_EXECUTE)) +} + +fn temp_name(host: &Host, _: &[Data]) -> Result { + let mut disk = host.disk.borrow_mut(); + disk.temps += 1; + let name = format!("vc{:05}.tmp", disk.temps); + Ok(Data::String(std::env::temp_dir().join(name).to_string_lossy().into_owned())) +} + +/// Adds `File` and `Folder` to the realm. +pub(super) fn install(realm: &mut Realm, host: &Rc) -> Result<(), RunError> { + let (start, home) = { + let disk = host.disk.borrow(); + (disk.pathname(&disk.start, &disk.start, false).fs(), disk.home.clone()) + }; + let value = |s: &str| HostItem::Value(Data::String(s.to_string())); + let text = HostItem::Object(vec![ + ("notOpen".into(), value(NOT_OPEN)), + ("noWrites".into(), value(NO_WRITES)), + ("noPicker".into(), value(NO_PICKER)), + ("badMode".into(), value(BAD_MODE)), + ]); + let pure = |f: fn(&str) -> Data| HostItem::Function(Rc::new(move |a| Ok(f(&arg_text(&a, 0))))); + let system = if WINDOWS { + "Windows" + } else if cfg!(target_os = "macos") { + "Macintosh" + } else { + "Unix" + }; + let items = vec![ + ("text".into(), text), + ("system".into(), value(system)), + ("lineFeed".into(), value(if WINDOWS { "Windows" } else { "Unix" })), + ("current".into(), HostItem::Function(Rc::new(move |_| Ok(Data::String(start.clone()))))), + ("locations".into(), HostItem::Function(Rc::new(move |_| Ok(locations(&home))))), + ("encode".into(), pure(|s| Data::String(encode(s)))), + ("decode".into(), pure(|s| Data::String(decode(s)))), + ("encodingAvailable".into(), pure(|s| Data::Bool(Encoding::parse(s).is_some()))), + ("parse".into(), call(host, parse)), + ("relative".into(), call(host, relative)), + ("tempName".into(), call(host, temp_name)), + ("stat".into(), call(host, stat)), + ("open".into(), call(host, open)), + ("close".into(), call(host, close)), + ("read".into(), call(host, read)), + ("readln".into(), call(host, readln)), + ("readch".into(), call(host, readch)), + ("seek".into(), call(host, seek)), + ("tell".into(), call(host, tell)), + ("eof".into(), call(host, eof)), + ("encoding".into(), call(host, encoding)), + ("setEncoding".into(), call(host, set_encoding)), + ("list".into(), call(host, list)), + ("resolve".into(), call(host, resolve)), + ("execute".into(), call(host, execute)), + ]; + realm.define_file_classes(items) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn p(text: &str, base: &str, windows: bool) -> Pathname { + let root = Pathname::root(windows); + let home = Pathname::parse(if windows { r"C:\Users\me" } else { "/home/me" }, &root, &root, windows, false); + let base = Pathname::parse(base, &home, &home, windows, false); + Pathname::parse(text, &base, &home, windows, true) + } + + #[test] + fn windows_paths_take_both_separators_drives_and_uri_names() { + let f = p(r"C:\art\one two.txt", r"C:\work", true); + assert_eq!((f.fs(), f.uri()), (r"C:\art\one two.txt".to_string(), "/c/art/one%20two.txt".to_string())); + assert_eq!(p("/d/x/y.txt", r"C:\work", true).fs(), r"D:\x\y.txt"); + assert_eq!(p("c:/a/./b/../c", r"C:\work", true).fs(), r"C:\a\c"); + assert_eq!(p("sub/f.txt", r"C:\work", true).fs(), r"C:\work\sub\f.txt"); + assert_eq!(p("~/notes.txt", r"C:\work", true).fs(), r"C:\Users\me\notes.txt"); + assert_eq!(p(r"\\host\share\x.txt", r"C:\work", true).fs(), r"\\host\share\x.txt"); + assert_eq!(p(r"\\?\C:\x.txt", r"C:\work", true).root, Root::Device(r"\\?\C:\x.txt".into())); + assert_eq!(p("caf%C3%A9.txt", r"C:\w", true).fs(), "C:\\w\\caf\u{e9}.txt"); + assert_eq!(p("..\\..\\..\\..", r"C:\w", true).fs(), r"C:\"); + assert_eq!(p("C:", r"D:\w", true).uri(), "/c"); + } + + #[test] + fn unix_paths_keep_backslashes_in_names() { + assert_eq!(p(r"a\b.txt", "/work", false).fs(), r"/work/a\b.txt"); + assert_eq!(p("/x/../y", "/work", false).fs(), "/y"); + assert_eq!(p("~", "/work", false).fs(), "/home/me"); + assert_eq!(p("/", "/work", false).uri(), "/"); + } + + #[test] + fn relative_uris_climb_to_the_common_folder() { + let base = p("/a/b", "/", false); + assert_eq!(p("/a/b/c d.txt", "/", false).relative_to(&base, false), "c%20d.txt"); + assert_eq!(p("/a/x.txt", "/", false).relative_to(&base, false), "../x.txt"); + assert_eq!(p("/a/b", "/", false).relative_to(&base, false), "."); + assert_eq!(p("D:/x", "C:/", true).relative_to(&p("C:/a", "C:/", true), true), "/d/x"); + } + + #[test] + fn uri_escapes_are_utf8_both_ways() { + assert_eq!(encode("caf\u{e9} 1"), "caf%C3%A9%201"); + assert_eq!(encode("a-_.!~*'()/b"), "a-_.!~*'()%2Fb"); + assert_eq!(decode("caf%C3%A9%20x"), "caf\u{e9} x"); + assert_eq!(decode("100%"), "100%"); + assert_eq!(decode("%E9"), "%E9", "a lone Latin-1 escape is not UTF-8"); + } + + #[test] + fn masks_match_stars_and_question_marks_without_case() { + let matches = |mask: &str, name: &str| Mask::new(mask).matches(name, &mut { usize::MAX }) == Some(true); + assert!(matches("*.JSX", "a.jsx")); + assert!(matches("a?c*", "abcdef")); + assert!(!matches("*.txt", "a.jsx")); + assert!(matches("*", "")); + assert!(matches("*a*b", "xaxxb")); + assert!(matches("a***b", "ab")); + assert!(!matches("?", "")); + } + + #[test] + fn masks_stop_when_their_steps_run_out() { + let mask = Mask::new(&format!("*{}b", "a".repeat(100))); + let name = "a".repeat(200); + let mut steps = 1000; + assert_eq!(mask.matches(&name, &mut steps), None); + assert_eq!(steps, 0); + let mut steps = 1000; + assert_eq!(Mask::new("*.jsx").matches("a.jsx", &mut steps), Some(true)); + assert!(steps > 990); + } + + #[test] + fn decoding_counts_characters_and_takes_four_line_ends() { + let mut f = Open::new(b"\xEF\xBB\xBFa\xC3\xA9\r\nb\n\rc\rd\ne".to_vec(), "").unwrap(); + assert_eq!((f.name.as_str(), f.pos), ("UTF-8", 3), "the byte order mark is passed over"); + assert_eq!(f.read(Some(2)).unwrap(), "a\u{e9}"); + assert_eq!(f.pos, 6, "two characters, three bytes"); + assert_eq!(f.read_line().unwrap(), "", "CRLF is one line end"); + assert_eq!(f.read_line().unwrap(), "b", "LFCR is one line end"); + assert_eq!(f.read_line().unwrap(), "c"); + assert_eq!(f.read_char().unwrap(), "d"); + assert_eq!(f.read_char().unwrap(), "\n"); + assert_eq!(f.read(None).unwrap(), "e"); + assert_eq!(f.read_char().unwrap(), ""); + let mut b = Open::new(vec![0xE8, 0x41], "BINARY").unwrap(); + assert_eq!(b.read(None).unwrap(), "\u{e8}A"); + let mut a = Open::new(vec![0x41, 0xE8], "ascii").unwrap(); + assert!(a.read(None).is_err()); + assert_eq!(a.pos, 0, "a failed read leaves the position"); + let mut c = Open::new(vec![0x80, 0xE9], "").unwrap(); + assert_eq!(c.name, "CP1252"); + assert_eq!(c.read(None).unwrap(), "\u{20ac}\u{e9}"); + let mut u = Open::new(vec![0xFF, 0xFE, 0x3D, 0xD8, 0x00, 0xDE, 0x41, 0x00], "").unwrap(); + assert_eq!(u.name, "UTF-16LE"); + assert_eq!(u.read(None).unwrap(), "\u{1f600}A"); + assert!(Open::new(b"x".to_vec(), "KLINGON").is_none()); + let mut l = Open::new(vec![b'a', b'\n', 0xE8, b'\r', 0xE8], "ASCII").unwrap(); + assert_eq!(l.read_line().unwrap(), "a", "a line end before a byte ASCII cannot read still ends the line"); + assert!(l.read_line().is_err()); + assert_eq!(l.pos, 2); + l.pos = 3; + assert_eq!(l.read_char().unwrap(), "\n"); + assert_eq!(l.pos, 4); + } +} diff --git a/crates/engine/src/script/include.rs b/crates/engine/src/script/include.rs index 70bfeac56a..6cf0c60bef 100644 --- a/crates/engine/src/script/include.rs +++ b/crates/engine/src/script/include.rs @@ -251,6 +251,11 @@ impl Files { self.include_path.join(";") } + /// The folders the run's own files are read from. + pub(crate) fn scope(&self) -> &ScriptScope { + &self.scope + } + /// The script's own text, `text`, with its preprocessor lines read. pub(crate) fn expand_script(&mut self, text: &str) -> Result { self.bytes = self.bytes.saturating_add(text.len()); diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index 78002422d1..05f1387552 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -35,6 +35,8 @@ //! //! `alert`, `confirm` and `prompt` open dialogs ([`dialogs`]): in the app the run waits for the //! person's answer, and a run with nobody to ask takes its answers from `script.run`'s `answers`. +//! +//! `File` and `Folder` read files inside the script's own folder ([`files`]). pub mod console; pub mod dialogs; @@ -56,6 +58,7 @@ use crate::{ Clipboard, DocState, DrawMode, EngineError, Journal, NodeId, PaintDefaults, Result, Session, cmd, command_specs, guard, waits_for_tool_edit, }; +pub mod files; pub mod folder; /// The most bytes of source a run takes, as a file or as `source`. @@ -492,6 +495,8 @@ struct Host { dialogs: dialogs::Dialogs, /// The files the run reads: its includes and `$.evalFile`'s. files: RefCell, + /// What `File` and `Folder` have open and have read. + disk: RefCell, /// Where the lines of the script's expanded source came from. script_map: RefCell, /// The same for each source `$.evalFile` gave, in order. @@ -511,6 +516,10 @@ struct Counts { command_list_reads: Cell, vc_log: Cell, console_writes: Cell, + /// Calls of `File` and `Folder` that reach the host ([`files`]). + file_calls: Cell, + /// `execute` calls, which open nothing. + file_executes: Cell, } impl Counts { @@ -521,6 +530,8 @@ impl Counts { "commandListReads": self.command_list_reads.get(), "vcLog": self.vc_log.get(), "consoleWrites": self.console_writes.get(), + "fileCalls": self.file_calls.get(), + "fileExecutes": self.file_executes.get(), }) } } @@ -1082,7 +1093,7 @@ fn kind_id(kind: &RunErrorKind) -> &'static str { /// `message`, with what to do about a failure that comes from a script written for another program. fn explained(kind: &RunErrorKind, message: &str) -> String { - const HOST_OBJECTS: &[&str] = &["app", "File", "Folder", "Window", "BridgeTalk", "activeDocument", "documents"]; + const HOST_OBJECTS: &[&str] = &["app", "Window", "BridgeTalk", "activeDocument", "documents"]; let names_host_object = message.strip_prefix("ReferenceError: ").is_some_and(|m| HOST_OBJECTS.iter().any(|o| m.split_whitespace().next() == Some(o))); if *kind == RunErrorKind::Thrown && names_host_object { @@ -1311,6 +1322,7 @@ impl Session { commands: RefCell::default(), run_nanos: Cell::new(0), dialogs: dialogs::Dialogs::new(self.script_frames, &realm, request.answers), + disk: RefCell::new(files::Disk::new(files.scope())), files: RefCell::new(files), script_map: RefCell::default(), eval_maps: RefCell::default(), @@ -1330,6 +1342,7 @@ impl Session { .define_host_object("vc", vc_object(&host, args)) .and_then(|()| realm.define_host_object("$", dollar_object(&host))) .and_then(|()| dialogs::install(&mut realm, &host)) + .and_then(|()| files::install(&mut realm, &host)) .and_then(|()| realm.start(&expanded.source)) .map_err(|e| located(&host, realm.error_source(), realm.error_mark(), &e)) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2af42d2920..19cf098da8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -154,6 +154,7 @@ mod script_console; mod script_dialogs; mod script_folder; mod script_include; +mod script_io; mod script_run; mod scripts; mod select_ids_first; diff --git a/crates/engine/src/tests/script_console.rs b/crates/engine/src/tests/script_console.rs index ba57d96bdf..d70770a36f 100644 --- a/crates/engine/src/tests/script_console.rs +++ b/crates/engine/src/tests/script_console.rs @@ -137,7 +137,10 @@ fn the_result_and_the_report_agree_and_count_each_kind_of_host_call() { try { vc.run('no.such', {}); } catch (e) {} var d = vc.document; var c = vc.commands; vc.log({a: 1}); vc.log('two\\nlines'); $.writeln('w');"; let r = run(&mut s, source).unwrap(); assert_eq!(&r, s.script_report().unwrap()); - assert_eq!(r["hostCallCounts"], json!({"vcRun": 3, "documentReads": 1, "commandListReads": 1, "vcLog": 2, "consoleWrites": 1})); + assert_eq!( + r["hostCallCounts"], + json!({"vcRun": 3, "documentReads": 1, "commandListReads": 1, "vcLog": 2, "consoleWrites": 1, "fileCalls": 0, "fileExecutes": 0}) + ); assert_eq!(r["timingAvailable"], true); assert!(r["runTimeMs"].is_u64()); let slow = r["slowestCommands"].as_array().unwrap(); diff --git a/crates/engine/src/tests/script_io.rs b/crates/engine/src/tests/script_io.rs new file mode 100644 index 0000000000..2dc1253c1b --- /dev/null +++ b/crates/engine/src/tests/script_io.rs @@ -0,0 +1,398 @@ +//! `File` and `Folder` in a script: their classes and names, reading inside the script's own folder, +//! the paths refused before anything is looked up, the read limits, and the parts that write, which +//! are not available. + +use std::path::{Path, PathBuf}; + +use serde_json::{Value, json}; + +use crate::file_access::{AutomationRoots, confine, script_probes}; +use crate::*; + +const DENIED: &str = "Script file access denied"; + +/// A fresh folder for test `name`, with a `main` folder inside it for the script. +fn folder(name: &str) -> PathBuf { + let base = std::env::temp_dir().join(format!("vc-script-io-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(base.join("main")).unwrap(); + base +} + +fn write(path: &Path, bytes: &[u8]) { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).unwrap(); + } + std::fs::write(path, bytes).unwrap(); +} + +/// Runs `main/main.jsx` in `base`, with the text `text`. +fn run(base: &Path, text: &str) -> Value { + let main = base.join("main/main.jsx"); + write(&main, text.as_bytes()); + Session::new().execute("script.run", &json!({"path": main.to_string_lossy()})).unwrap() +} + +/// The values the run of `text` logged. +fn logged(base: &Path, text: &str) -> Value { + run(base, text)["log"].clone() +} + +#[test] +fn file_constructor_identity_and_folder_path() { + let base = folder("identity"); + write(&base.join("main/data.txt"), b"x"); + std::fs::create_dir_all(base.join("main/sub")).unwrap(); + let log = logged( + &base, + "var a = new File('data.txt'), b = new File('data.txt');\n\ + vc.log([a === b, a instanceof File, a instanceof Folder, File('sub') instanceof Folder, new File('sub') instanceof File,\n\ + Folder('data.txt') instanceof File, new Folder('data.txt') instanceof Folder, File('nope') instanceof File,\n\ + a.parent instanceof Folder, a.name, a.parent.fsName === Folder.current.fsName, String(a) === a.absoluteURI,\n\ + a.exists, new File('nope').exists, a.length, a.created instanceof Date, Folder('sub').exists, new File().exists]);", + ); + assert_eq!(log, json!([[false, true, false, true, true, true, true, true, true, "data.txt", true, true, true, false, 1, true, true, false]])); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn file_names_are_uri_encoded_and_current_relative() { + let base = folder("names"); + std::fs::create_dir_all(base.join("main/sub")).unwrap(); + let log = logged( + &base, + "var f = new File('caf%C3%A9 x.txt');\n\ + var sep = File.fs === 'Windows' ? '\\\\' : '/';\n\ + vc.log([f.name, f.displayName, f.relativeURI, f.fsName === Folder.current.fsName + sep + 'caf\\u00e9 x.txt',\n\ + File.encode('\\u00e9 /'), File.decode('%C3%A9'), new File('sub/../sub/./y.txt').relativeURI,\n\ + new File('~/z').fsName === new Folder('~').fsName + sep + 'z', new File('a/b').path === new Folder('a').absoluteURI]);\n\ + Folder.current = new Folder('sub');\n\ + vc.log([new File('w').parent.name, new File('w').relativeURI, new File('../w').getRelativeURI(Folder.current.parent)]);\n\ + var l = new File('literal%2520name.txt'); Folder.current = new Folder('p%2541');\n\ + vc.log([l.displayName, l.name, l.absoluteURI.slice(-20), l.fsName === new File(l).fsName, Folder.current.displayName, new File('x').parent.displayName]);", + ); + assert_eq!( + log, + json!([ + ["caf%C3%A9%20x.txt", "caf\u{e9} x.txt", "caf%C3%A9%20x.txt", true, "%C3%A9%20%2F", "\u{e9}", "sub/y.txt", true, true], + ["sub", "w", "w"], + ["literal%20name.txt", "literal%2520name.txt", "literal%2520name.txt", true, "p%41", "p%41"] + ]) + ); + if cfg!(windows) { + let log = logged(&base, "vc.log([new File('/c/a b/x.txt').fsName, new File('C:/a/x.txt').absoluteURI, new File('d:\\\\q').fsName]);"); + assert_eq!(log, json!([["C:\\a b\\x.txt", "/c/a/x.txt", "D:\\q"]])); + } + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_file_without_scope_cannot_probe_home() { + let base = folder("home"); + let source = "var f = File('~/.ssh/id_test'); var g = new Folder('~');\n\ + vc.log([f instanceof File, f.exists, f.open('r'), f.error, g.getFiles(), g.error, f.modified, f.length]);"; + let denied = json!([[true, false, false, "Script file access denied", null, "Script file access denied", null, 0]]); + let before = script_probes(); + let r = Session::new().execute("script.run", &json!({"source": source})).unwrap(); + assert_eq!(r["log"], denied, "a script given as text reads nothing"); + assert_eq!(script_probes(), before, "nothing was looked up"); + // A script run from a file is held to its folder. + let before = script_probes(); + assert_eq!(logged(&base, source), denied); + assert_eq!(script_probes(), before, "the home folder lies outside the script's folder by its names alone"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_file_unc_rejected_before_lookup() { + let base = folder("unc"); + let before = script_probes(); + let log = logged( + &base, + "var out = [];\n\ + var paths = ['\\\\\\\\host\\\\s\\\\x.txt', '//host/s/x.txt', '\\\\\\\\?\\\\C:\\\\x.txt', '\\\\\\\\.\\\\pipe\\\\x'];\n\ + for (var i = 0; i < paths.length; i++) { var f = new File(paths[i]); out.push(f.exists, f.open('r'), new Folder(paths[i]).getFiles()); }\n\ + vc.log(out);", + ); + assert_eq!(log, json!([[false, false, null, false, false, null, false, false, null, false, false, null]])); + assert_eq!(script_probes(), before, "no network or device path was looked up"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_file_link_escape_refused() { + let base = folder("links"); + write(&base.join("outside/secret.txt"), b"secret"); + write(&base.join("main/own.txt"), b"own"); + std::fs::create_dir_all(base.join("main/inner")).unwrap(); + // A folder link: a symbolic link, or on Windows a junction, which needs no privilege. + let link_folder = |target: &Path, link: &Path| { + #[cfg(unix)] + std::os::unix::fs::symlink(target, link).unwrap(); + #[cfg(windows)] + { + let made = std::process::Command::new("cmd").args(["/C", "mklink", "/J"]).arg(link).arg(target).output().unwrap(); + assert!(made.status.success(), "mklink /J: {}", String::from_utf8_lossy(&made.stderr)); + } + }; + link_folder(&base.join("outside"), &base.join("main").join("away")); + link_folder(&base.join("main").join("inner"), &base.join("main").join("near")); + let log = logged( + &base, + "var away = new Folder('away'); var s = new File('away/secret.txt');\n\ + vc.log([away.exists, away.getFiles(), away.error, away.resolve(), s.exists, s.open('r'), s.error,\n\ + Folder.current.getFiles().map(function (f) { return f.name; }), new Folder('near').alias, new Folder('near').resolve().name]);", + ); + assert_eq!(log, json!([[false, null, DENIED, null, false, false, DENIED, ["inner", "main.jsx", "near", "own.txt"], true, "inner"]])); + #[cfg(unix)] + let made = |target: &Path, link: &Path| std::os::unix::fs::symlink(target, link).is_ok(); + #[cfg(windows)] + let made = |target: &Path, link: &Path| std::os::windows::fs::symlink_file(target, link).is_ok(); + #[cfg(not(any(unix, windows)))] + let made = |_: &Path, _: &Path| false; + // Windows needs Developer Mode or elevation to make a link to a file. + if !made(&base.join("outside/secret.txt"), &base.join("main/leak.txt")) { + eprintln!("skipped: links to files can't be made here"); + let _ = std::fs::remove_dir_all(base); + return; + } + assert!(made(&base.join("main/own.txt"), &base.join("main/alias.txt"))); + let log = logged( + &base, + "var leak = new File('leak.txt');\n\ + vc.log([leak.exists, leak.open('r'), leak.error, leak.resolve(), new File('away/secret.txt').exists, new Folder('away').getFiles(),\n\ + Folder.current.getFiles().map(function (f) { return f.name; }), new File('alias.txt').alias, new File('alias.txt').resolve().name]);", + ); + assert_eq!(log, json!([[false, false, DENIED, null, false, null, ["alias.txt", "inner", "main.jsx", "near", "own.txt"], true, "own.txt"]])); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_file_oversize_refused_before_read() { + let base = folder("oversize"); + let big = std::fs::File::create(base.join("main/big.bin")).unwrap(); + big.set_len(9 << 20).unwrap(); + drop(big); + write(&base.join("main/small.txt"), b"ok"); + let r = + run(&base, "var f = new File('big.bin'); var s = new File('small.txt'); vc.log([f.length, f.open('r'), f.error, s.open('r'), s.read()]);"); + assert_eq!(r["log"], json!([[9437184, false, "Script file exceeds 8 MiB", true, "ok"]])); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_files_read_at_most_32_mib_in_a_run() { + let base = folder("total"); + write(&base.join("main/seven.bin"), &vec![b'a'; 7 << 20]); + let mut s = Session::new(); + let main = base.join("main/main.jsx"); + write(&main, b"var f = new File('seven.bin'); for (var i = 0; i < 5; i++) { f.open('r'); vc.log(i); }"); + let e = s.execute("script.run", &json!({"path": main.to_string_lossy()})); + assert!(e.is_err(), "the fifth open passes 32 MiB"); + let report = s.script_report().unwrap(); + assert_eq!(report["log"], json!([0, 1, 2, 3])); + assert!(report["message"].as_str().unwrap().contains("32 MiB"), "{report}"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_files_have_at_most_256_open() { + let base = folder("open"); + write(&base.join("main/empty.txt"), b""); + let log = logged( + &base, + "var all = [], fails = 0, error = '';\n\ + for (var i = 0; i < 300; i++) { var f = new File('empty.txt'); if (f.open('r')) all.push(f); else { fails++; error = f.error; } }\n\ + var closed = all[0].close(); var again = new File('empty.txt');\n\ + vc.log([all.length, fails, error, closed, again.open('r'), all[1].open('r'), new File('empty.txt').open('r')]);", + ); + assert_eq!(log, json!([[256, 44, "Script has 256 files open", true, true, true, false]])); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_folder_getfiles_stays_inside_scope() { + let base = folder("list"); + for name in ["a.jsx", "b.txt", "C.JSX"] { + write(&base.join("main").join(name), b""); + } + std::fs::create_dir_all(base.join("main/sub")).unwrap(); + write(&base.join("outside.jsx"), b""); + let log = logged( + &base, + "var d = Folder.current; var names = function (l) { var o = []; for (var i = 0; i < l.length; i++) o.push(l[i].name); return o; };\n\ + vc.log(names(d.getFiles('*.jsx')));\n\ + vc.log(names(d.getFiles(function (f) { return f instanceof Folder; })));\n\ + var up = d.parent; vc.log([names(d.getFiles('?.txt')), d.getFiles().length, up.getFiles(), up.error]);\n\ + try { d.getFiles(function () { throw new Error('stop'); }); } catch (e) { vc.log(e.message); }\n\ + vc.log([new Folder('nope').getFiles(), d.getFiles('*.jsx')[0] instanceof File]);", + ); + assert_eq!(log, json!([["C.JSX", "a.jsx", "main.jsx"], ["sub"], [["b.txt"], 5, null, DENIED], "stop", [null, true]])); + let many = base.join("main/many"); + for i in 0..=crate::script::files::MAX_ENTRIES { + write(&many.join(format!("{i:04}.txt")), b""); + } + let log = logged(&base, "var m = new Folder('many'); vc.log([m.getFiles(), m.error, m.getFiles('0001.*').length]);"); + assert_eq!(log, json!([[null, "Script folder has more than 2,048 entries", 1]])); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_file_bom_and_line_read() { + let base = folder("lines"); + write(&base.join("main/lines.txt"), b"\xEF\xBB\xBFone\r\ntwo\n\rthree\rfour\nfin"); + write(&base.join("main/e.txt"), "\u{e9}t\u{e9}".as_bytes()); + let log = logged( + &base, + "var f = new File('lines.txt'); var out = [f.eof, f.open('r'), f.encoding, f.tell()];\n\ + var lines = []; while (!f.eof) lines.push(f.readln()); out.push(lines, f.tell());\n\ + out.push(f.seek(3), f.read(3), f.seek(-3, 2), f.read(), f.seek(1, 1), f.seek(100), f.seek(-1), f.tell());\n\ + f.seek(6); out.push(f.readch(), f.readch(), f.tell(), f.close(), f.read(), f.error, f.eof);\n\ + var e = new File('e.txt'); e.open(); out.push(e.read(1), e.tell(), e.read(5), e.tell());\n\ + vc.log(out);", + ); + assert_eq!( + log, + json!([[ + true, + true, + "UTF-8", + 3, + ["one", "two", "three", "four", "fin"], + 27, + true, + "one", + true, + "fin", + false, + false, + false, + 27, + "\n", + "t", + 9, + true, + "", + "Script file is not open", + true, + "\u{e9}", + 2, + "t\u{e9}", + 5 + ]]) + ); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_file_encoding_binary_ascii() { + let base = folder("encodings"); + write(&base.join("main/bytes.bin"), &[0xE8, 0x41]); + write(&base.join("main/euro.txt"), &[0x80]); + let log = logged( + &base, + "var b = new File('bytes.bin'); b.encoding = 'BINARY'; b.open('r'); var t = b.read();\n\ + var a = new File('bytes.bin'); a.encoding = 'ASCII'; a.open('r');\n\ + var out = [t.charCodeAt(0), t.charAt(1), b.encoding, a.read(), a.error, a.tell()];\n\ + a.encoding = 'binary'; out.push(a.encoding, a.read().length);\n\ + var c = new File('euro.txt'); c.open('r'); out.push(c.encoding, c.read().charCodeAt(0));\n\ + var k = new File('bytes.bin'); k.encoding = 'KLINGON'; out.push(k.open('r'), k.error);\n\ + out.push(File.isEncodingAvailable('utf-8'), Folder.isEncodingAvailable('BINARY'), File.isEncodingAvailable('KLINGON'));\n\ + vc.log(out);\n\ + var m = new File('bytes.bin'); m.encoding = 'BINARY'; m.open('r'); m.encoding = 'KLINGON'; vc.log([m.error, m.encoding, m.read().length]);\n\ + var r = new File('bytes.bin'); r.open('r'); r.encoding = 'ASCII'; r.close(); vc.log([r.encoding, r.open('r'), r.read(), r.error]);", + ); + let undecodable = "Script file cannot be decoded"; + assert_eq!(log[0], json!([232, "A", "BINARY", "", undecodable, 0, "BINARY", 2, "CP1252", 0x20AC, false, undecodable, true, true, false])); + assert_eq!(log[1], json!([undecodable, "BINARY", 2]), "a name that is not an encoding leaves the open file's encoding"); + assert_eq!(log[2], json!(["ASCII", true, "", undecodable]), "an encoding set while open is kept after close"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_file_writes_dialogs_and_execute_are_not_available() { + let base = folder("writes"); + write(&base.join("main/keep.txt"), b"keep"); + let r = run( + &base, + "var f = new File('keep.txt'); var n = new File('new.txt');\n\ + var out = [n.open('w'), n.error, f.open('a'), f.open('e'), f.open('x'), f.error, f.write('x'), f.remove(), f.copy('c.txt'), f.rename('r.txt')];\n\ + out.push(new Folder('made').create(), f.openDlg(), f.error, File.openDialog(), Folder.selectDialog(), f.execute(), f.error);\n\ + f.hidden = true; out.push(f.error, f.hidden); f.error = ''; out.push(f.error);\n\ + vc.log(out);", + ); + assert_eq!( + r["log"], + json!([[ + false, + "Script file writing is not available", + false, + false, + false, + "Script file open mode must be r, w, e or a", + false, + false, + false, + false, + false, + null, + "Script file dialogs are not available", + null, + null, + false, + "Script files cannot be opened in another application", + "Script file writing is not available", + false, + "" + ]]) + ); + assert_eq!(r["hostCallCounts"]["fileExecutes"], 1); + assert!(r["hostCallCounts"]["fileCalls"].as_u64().unwrap() > 0); + assert_eq!(std::fs::read(base.join("main/keep.txt")).unwrap(), b"keep"); + assert!(!base.join("main/new.txt").exists() && !base.join("main/made").exists() && !base.join("main/c.txt").exists()); + let _ = std::fs::remove_dir_all(base); +} + +/// A run with automation roots in force reads inside its folder and the read roots, and a run given +/// as text reads nothing even where the roots allow it. +#[test] +fn script_files_read_inside_the_automation_read_roots() { + let base = folder("roots"); + write(&base.join("main/data.txt"), b"data"); + let source = "var f = new File('data.txt'); vc.log([f.exists, f.open('r'), f.error, f.read()]);"; + let roots = AutomationRoots::new(&[&base.join("main")], &[]).unwrap(); + assert_eq!(confine(roots.as_ref(), || logged(&base, source)), json!([[true, true, "", "data"]])); + let text = format!("var f = new File({:?}); vc.log([f.exists, f.error]);", base.join("main/data.txt").to_string_lossy()); + let r = confine(roots.as_ref(), || Session::new().execute("script.run", &json!({"source": text})).unwrap()); + assert_eq!(r["log"], json!([[false, "Script file access denied"]])); + let _ = std::fs::remove_dir_all(base); +} + +/// A path longer than 32,767 characters is refused before it is split into names. +#[test] +fn script_file_path_length_is_bounded() { + let base = folder("longpath"); + let log = logged( + &base, + "var s = 'a/'; while (s.length < 40000) s += s;\n\ + var out = []; try { new File(s); } catch (e) { out.push(e.name, e.message); }\n\ + try { new Folder('x').getRelativeURI(s); } catch (e) { out.push(e.name); }\n\ + out.push(new File(s.slice(0, 30000)).exists); vc.log(out);", + ); + assert_eq!(log, json!([["RangeError", "Script file path exceeds 32,767 characters", "RangeError", false]])); + let _ = std::fs::remove_dir_all(base); +} + +/// An error `File` or `Folder` throws is reported at the script's line that called it. +#[test] +fn script_file_errors_are_at_the_calling_line() { + let base = folder("errors"); + let mut s = Session::new(); + let main = base.join("main/main.jsx"); + write(&main, b"\n\n\nnew File('x').read(-1);"); + assert!(s.execute("script.run", &json!({"path": main.to_string_lossy()})).is_err()); + let report = s.script_report().unwrap(); + assert!(report["message"].as_str().unwrap().contains("RangeError: File.read: the count must be a finite number"), "{report}"); + assert_eq!(report["line"].as_u64(), Some(4), "{report}"); + let _ = std::fs::remove_dir_all(base); +} diff --git a/crates/js/src/file_classes.rs b/crates/js/src/file_classes.rs new file mode 100644 index 0000000000..cc22f1834d --- /dev/null +++ b/crates/js/src/file_classes.rs @@ -0,0 +1,864 @@ +//! `File` and `Folder`, the classes the help pages +//! and +//! describe, as built-ins +//! whose file system work a host does ([`crate::Realm::define_file_classes`]). +//! +//! The constructors build their objects with and without `new`: `new File(path)` is always a +//! `File` and `new Folder(path)` always a `Folder`, while `File(path)` gives a `Folder` when the +//! host's `stat` says the path is a folder, and `Folder(path)` a `File` when it is a file. Two +//! objects for one path are different objects. An object keeps its path in the host's platform +//! form, its `error` text, the id of the file it has open (or -1), the encoding set before `open` +//! and its line feed, in properties that are not enumerable; every name the getters give comes from +//! the host's `parse` of that path. `Folder.current` is kept in the realm and starts at the host's +//! `current`. +//! +//! A method whose host call fails sets `error` and returns `false`, `null` or an empty string. A +//! count or position that is not a finite number, a negative count and a seek mode other than 0, 1 +//! or 2 throw a `RangeError`; a method called on an object that is not a `File` or `Folder` throws +//! a `TypeError`. `getFiles` with a function calls it with each entry and keeps those it returns a +//! true value for; every entry takes fuel. + +use crate::HostItem; +use crate::builtins::{arg, constructor, data, method, native, put_at}; +use crate::heap::{CONFIGURABLE, Class, HIDDEN, NativeFn, NativeKind, OBJECT_BYTES, ObjId, Object, PROP_BYTES, Prop, key, string_bytes}; +use crate::interp::{Abrupt, ErrorType, Interp, JsResult}; +use crate::value::Value; + +/// The objects of `File` and `Folder`, kept by the realm, and `Folder.current`. +#[derive(Clone, Debug)] +pub(crate) struct Classes { + file_proto: ObjId, + folder_proto: ObjId, + host: ObjId, + current: Value, +} + +impl Classes { + /// The objects a collection keeps. + pub(crate) fn objects(&self) -> [ObjId; 3] { + [self.file_proto, self.folder_proto, self.host] + } +} + +const PATH: &str = "__vcPath"; +const ERROR: &str = "__vcError"; +const HANDLE: &str = "__vcHandle"; +const ENCODING: &str = "__vcEncoding"; +const LINE_FEED: &str = "__vcLineFeed"; + +/// The locations `Folder` has as properties, each read from the host's `locations` when it is read. +const PLACES: [&str; 10] = ["appData", "appPackage", "commonFiles", "desktop", "myDocuments", "startup", "system", "temp", "trash", "userData"]; + +fn classes(i: &Interp) -> JsResult { + i.files.clone().ok_or_else(|| Abrupt::Internal("File and Folder are not installed".into())) +} + +/// Calls the host function `name` with `args`. +fn host(i: &mut Interp, name: &str, args: &[Value]) -> JsResult { + let c = classes(i)?; + let f = i.get_value(&Value::Object(c.host), &key(name))?; + i.call(&f, &Value::Undefined, args) +} + +/// The host's message `name`. +fn message(i: &mut Interp, name: &str) -> JsResult { + let c = classes(i)?; + let text = i.get_value(&Value::Object(c.host), &key("text"))?; + i.get_value(&text, &key(name)) +} + +fn field(i: &mut Interp, v: &Value, name: &str) -> JsResult { + i.get_value(v, &key(name)) +} + +/// The value of the own data property `name` of `o`, `undefined` when it has none. +fn slot(i: &Interp, o: ObjId, name: &str) -> JsResult { + Ok(match i.get_own_property(o, &key(name))? { + Some(Prop::Data { value, .. }) => value, + _ => Value::Undefined, + }) +} + +fn set_slot(i: &mut Interp, o: ObjId, name: &str, value: Value) -> JsResult<()> { + if let Value::String(s) = &value { + i.rooted(std::slice::from_ref(&value), |i| i.reserve_heap(PROP_BYTES + string_bytes(s.len())))?; + } + i.define(o, key(name), Prop::Data { value, flags: HIDDEN }) +} + +/// The object `this` and its path, or a `TypeError` naming `what` when it is not a `File` or +/// `Folder`. +fn this_path(i: &mut Interp, this: &Value, what: &str) -> JsResult<(ObjId, Value)> { + if let Some(o) = this.as_object() + && let path @ Value::String(_) = slot(i, o, PATH)? + { + return Ok((o, path)); + } + Err(i.type_error(&format!("{what}: the object is not a File or a Folder"))) +} + +/// The path `x` names, and whether it is already in the platform's form: the path of a `File` or +/// `Folder`, or `String(x)`, which may hold URI escapes. +fn path_text(i: &mut Interp, x: &Value) -> JsResult<(Value, bool)> { + if let Some(o) = x.as_object() + && let path @ Value::String(_) = slot(i, o, PATH)? + { + return Ok((path, true)); + } + let s = i.to_str(x)?; + Ok((Value::String(s), false)) +} + +/// The host's `parse` of the path `x` names, from `Folder.current`. +fn parse(i: &mut Interp, x: &Value) -> JsResult { + let (text, platform) = path_text(i, x)?; + parse_text(i, text, platform) +} + +/// The host's `parse` of `text`, from `Folder.current`; its URI escapes are decoded unless it is +/// already a `platform` path. +fn parse_text(i: &mut Interp, text: Value, platform: bool) -> JsResult { + let current = classes(i)?.current; + host(i, "parse", &[text, current, Value::Bool(platform)]) +} + +fn parsed(i: &mut Interp, this: &Value, what: &str, name: &str) -> JsResult { + let (_, path) = this_path(i, this, what)?; + let p = parse_text(i, path, true)?; + i.rooted(std::slice::from_ref(&p), |i| field(i, &p, name)) +} + +/// A new object with prototype `proto` for the path `fs`, already in the platform's form. +fn instance(i: &mut Interp, proto: ObjId, fs: Value) -> JsResult { + let line_feed = host_value(i, "lineFeed")?; + i.rooted(&[fs.clone(), line_feed.clone()], |i| { + i.reserve_heap(OBJECT_BYTES + 5 * PROP_BYTES)?; + let o = i.heap.alloc_object(Object::new(Class::Ordinary, Some(proto))); + i.rooted(&[Value::Object(o)], |i| { + set_slot(i, o, PATH, fs.clone())?; + set_slot(i, o, ERROR, Value::str(""))?; + set_slot(i, o, HANDLE, Value::Number(-1.0))?; + set_slot(i, o, ENCODING, Value::str(""))?; + set_slot(i, o, LINE_FEED, line_feed.clone())?; + Ok(Value::Object(o)) + }) + }) +} + +fn host_value(i: &mut Interp, name: &str) -> JsResult { + let c = classes(i)?; + i.get_value(&Value::Object(c.host), &key(name)) +} + +/// The host's `stat` of the object's path, or `None` after setting `error` when it failed. +fn stat(i: &mut Interp, this: &Value, what: &str) -> JsResult> { + let (o, path) = this_path(i, this, what)?; + let r = host(i, "stat", &[path])?; + i.rooted(std::slice::from_ref(&r), |i| { + let error = field(i, &r, "error")?; + if matches!(error, Value::String(_)) { + set_slot(i, o, ERROR, error)?; + return Ok(None); + } + Ok(Some(r.clone())) + }) +} + +/// The field `name` of the object's `stat`, when it exists, or `missing`. +fn stat_field(i: &mut Interp, this: &Value, what: &str, name: &str, missing: Value) -> JsResult { + let Some(r) = stat(i, this, what)? else { return Ok(missing) }; + i.rooted(std::slice::from_ref(&r), |i| { + if !field(i, &r, "exists")?.truthy() && name != "exists" { + return Ok(missing); + } + field(i, &r, name) + }) +} + +/// The result of a host read: its `text`, or `failed` after setting `error`. +fn text_result(i: &mut Interp, o: ObjId, r: Value, failed: Value) -> JsResult { + i.rooted(std::slice::from_ref(&r), |i| { + let error = field(i, &r, "error")?; + if matches!(error, Value::String(_)) { + set_slot(i, o, ERROR, error)?; + return Ok(failed); + } + field(i, &r, "text") + }) +} + +/// The id of the object's open file, or `None` after setting `error` when it has none. +fn opened(i: &mut Interp, o: ObjId) -> JsResult> { + let h = slot(i, o, HANDLE)?; + if matches!(h, Value::Number(n) if n >= 0.0) { + return Ok(Some(h)); + } + let m = message(i, "notOpen")?; + set_slot(i, o, ERROR, m)?; + Ok(None) +} + +fn is_open(i: &Interp, o: ObjId) -> JsResult> { + let h = slot(i, o, HANDLE)?; + Ok(matches!(h, Value::Number(n) if n >= 0.0).then_some(h)) +} + +/// Closes the object's open file, if it has one. +fn close_open(i: &mut Interp, o: ObjId) -> JsResult<()> { + if let Some(h) = is_open(i, o)? { + host(i, "close", &[h])?; + } + set_slot(i, o, HANDLE, Value::Number(-1.0)) +} + +fn date(i: &mut Interp, ms: Value) -> JsResult { + let Value::Number(t) = ms else { return Ok(Value::Null) }; + i.reserve_heap(OBJECT_BYTES)?; + let proto = i.intrinsics.date_proto; + Ok(Value::Object(i.heap.alloc_object(Object::new(Class::Date(t), Some(proto))))) +} + +/// A count of characters: `None` for `undefined`, else a whole number 0 or more. +fn count(i: &mut Interp, v: &Value, what: &str) -> JsResult> { + if matches!(v, Value::Undefined) { + return Ok(None); + } + let n = i.to_number(v)?; + if !n.is_finite() || n < 0.0 { + return Err(i.throw(ErrorType::Range, &format!("{what}: the count must be a finite number, 0 or more"))); + } + Ok(Some(n.floor())) +} + +// ---- constructors ---- + +fn file_ctor(i: &mut Interp, _: Value, args: &[Value], construct: bool) -> JsResult { + make(i, args, construct, true) +} + +fn folder_ctor(i: &mut Interp, _: Value, args: &[Value], construct: bool) -> JsResult { + make(i, args, construct, false) +} + +/// `new File(path)`, `File(path)`, `new Folder(path)` or `Folder(path)`. +fn make(i: &mut Interp, args: &[Value], construct: bool, file: bool) -> JsResult { + let c = classes(i)?; + let (own, other) = if file { (c.file_proto, c.folder_proto) } else { (c.folder_proto, c.file_proto) }; + let (path, platform) = match arg(args, 0) { + Value::Undefined if file => (host(i, "tempName", &[])?, true), + Value::Undefined => (c.current.clone(), true), + v => (v, false), + }; + let fs = i.rooted(std::slice::from_ref(&path), |i| { + let p = if platform { parse_text(i, path.clone(), true)? } else { parse(i, &path)? }; + i.rooted(std::slice::from_ref(&p), |i| field(i, &p, "fs")) + })?; + let proto = if construct { + own + } else { + let st = i.rooted(std::slice::from_ref(&fs), |i| host(i, "stat", std::slice::from_ref(&fs)))?; + let flip = i.rooted(&[fs.clone(), st.clone()], |i| { + let error = field(i, &st, "error")?; + let exists = field(i, &st, "exists")?.truthy(); + let dir = field(i, &st, "dir")?.truthy(); + Ok(matches!(error, Value::Undefined) && exists && dir == file) + })?; + if flip { other } else { own } + }; + instance(i, proto, fs) +} + +// ---- both classes ---- + +fn absolute_uri(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + parsed(i, &this, "absoluteURI", "uri") +} + +fn fs_name(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + Ok(this_path(i, &this, "fsName")?.1) +} + +fn relative_uri(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (_, path) = this_path(i, &this, "relativeURI")?; + let current = classes(i)?.current; + host(i, "relative", &[path, current]) +} + +fn name(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + parsed(i, &this, "name", "name") +} + +fn display_name(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + parsed(i, &this, "displayName", "display") +} + +fn path(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + parsed(i, &this, "path", "path") +} + +fn parent(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let up = parsed(i, &this, "parent", "parent")?; + if matches!(up, Value::Null | Value::Undefined) { + return Ok(Value::Null); + } + let proto = classes(i)?.folder_proto; + instance(i, proto, up) +} + +fn exists(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + Ok(Value::Bool(stat_field(i, &this, "exists", "exists", Value::Bool(false))?.truthy())) +} + +fn created(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let ms = stat_field(i, &this, "created", "created", Value::Null)?; + date(i, ms) +} + +fn modified(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let ms = stat_field(i, &this, "modified", "modified", Value::Null)?; + date(i, ms) +} + +fn alias(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let Some(r) = stat(i, &this, "alias")? else { return Ok(Value::Bool(false)) }; + let v = i.rooted(std::slice::from_ref(&r), |i| field(i, &r, "alias"))?; + Ok(Value::Bool(v.truthy())) +} + +fn get_error(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "error")?; + slot(i, o, ERROR) +} + +fn set_error(i: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "error")?; + let v = match arg(args, 0) { + Value::Undefined | Value::Null => Value::str(""), + v => Value::String(i.to_str(&v)?), + }; + set_slot(i, o, ERROR, v)?; + Ok(Value::Undefined) +} + +fn get_relative_uri(i: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let (_, path) = this_path(i, &this, "getRelativeURI")?; + let base = match arg(args, 0) { + Value::Undefined => classes(i)?.current, + b => { + let p = i.rooted(std::slice::from_ref(&path), |i| parse(i, &b))?; + i.rooted(std::slice::from_ref(&p), |i| field(i, &p, "fs"))? + } + }; + host(i, "relative", &[path, base]) +} + +fn change_path(i: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "changePath")?; + close_open(i, o)?; + let p = parse(i, &arg(args, 0))?; + let fs = i.rooted(std::slice::from_ref(&p), |i| field(i, &p, "fs"))?; + set_slot(i, o, PATH, fs)?; + Ok(Value::Bool(true)) +} + +fn resolve(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, path) = this_path(i, &this, "resolve")?; + let r = host(i, "resolve", &[path])?; + let (error, fs, dir) = i.rooted(std::slice::from_ref(&r), |i| Ok((field(i, &r, "error")?, field(i, &r, "fs")?, field(i, &r, "dir")?)))?; + if matches!(error, Value::String(_)) { + set_slot(i, o, ERROR, error)?; + return Ok(Value::Null); + } + if !matches!(fs, Value::String(_)) { + return Ok(Value::Null); + } + let c = classes(i)?; + instance(i, if dir.truthy() { c.folder_proto } else { c.file_proto }, fs) +} + +fn execute(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, path) = this_path(i, &this, "execute")?; + let r = host(i, "execute", &[path])?; + let error = i.rooted(std::slice::from_ref(&r), |i| field(i, &r, "error"))?; + set_slot(i, o, ERROR, error)?; + Ok(Value::Bool(false)) +} + +fn to_string(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + parsed(i, &this, "toString", "uri") +} + +/// A method that writes: it sets `error` and returns `false`. +fn no_writes(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "write")?; + let m = message(i, "noWrites")?; + set_slot(i, o, ERROR, m)?; + Ok(Value::Bool(false)) +} + +/// A setter that writes: it sets `error` and changes nothing. +fn no_writes_setter(i: &mut Interp, this: Value, args: &[Value], construct: bool) -> JsResult { + no_writes(i, this, args, construct)?; + Ok(Value::Undefined) +} + +/// A dialog method: it sets `error` and returns `null`. +fn no_picker(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "dialog")?; + let m = message(i, "noPicker")?; + set_slot(i, o, ERROR, m)?; + Ok(Value::Null) +} + +fn null_static(_: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult { + Ok(Value::Null) +} + +fn text_static(i: &mut Interp, name: &str, args: &[Value]) -> JsResult { + let s = i.to_str(&arg(args, 0))?; + host(i, name, &[Value::String(s)]) +} + +fn encode(i: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + text_static(i, "encode", args) +} + +fn decode(i: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + text_static(i, "decode", args) +} + +fn encoding_available(i: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + text_static(i, "encodingAvailable", args) +} + +// ---- File ---- + +fn length(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let Some(r) = stat(i, &this, "length")? else { return Ok(Value::Number(0.0)) }; + i.rooted(std::slice::from_ref(&r), |i| { + let regular = field(i, &r, "exists")?.truthy() && field(i, &r, "file")?.truthy(); + if regular { field(i, &r, "length") } else { Ok(Value::Number(0.0)) } + }) +} + +fn hidden(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + Ok(Value::Bool(stat_field(i, &this, "hidden", "hidden", Value::Bool(false))?.truthy())) +} + +fn readonly(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + Ok(Value::Bool(stat_field(i, &this, "readonly", "readonly", Value::Bool(false))?.truthy())) +} + +fn four_question_marks(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + this_path(i, &this, "creator")?; + Ok(Value::str("????")) +} + +fn eof(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "eof")?; + match is_open(i, o)? { + Some(h) => host(i, "eof", &[h]), + None => Ok(Value::Bool(true)), + } +} + +fn get_line_feed(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "lineFeed")?; + slot(i, o, LINE_FEED) +} + +fn set_line_feed(i: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "lineFeed")?; + let v = i.to_str(&arg(args, 0))?; + set_slot(i, o, LINE_FEED, Value::String(v))?; + Ok(Value::Undefined) +} + +fn get_encoding(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "encoding")?; + if let Some(h) = is_open(i, o)? { + return host(i, "encoding", &[h]); + } + Ok(match slot(i, o, ENCODING)? { + Value::String(s) if s.len() != 0 => Value::String(s), + _ => Value::str("UTF-8"), + }) +} + +fn set_encoding(i: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "encoding")?; + let v = Value::String(i.to_str(&arg(args, 0))?); + match is_open(i, o)? { + Some(h) => { + let r = i.rooted(std::slice::from_ref(&v), |i| host(i, "setEncoding", &[h, v.clone()]))?; + let error = i.rooted(std::slice::from_ref(&r), |i| field(i, &r, "error"))?; + if matches!(error, Value::String(_)) { + set_slot(i, o, ERROR, error)?; + } else { + set_slot(i, o, ENCODING, v)?; + } + } + None => set_slot(i, o, ENCODING, v)?, + } + Ok(Value::Undefined) +} + +fn open(i: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let (o, path) = this_path(i, &this, "open")?; + let mode = match arg(args, 0) { + Value::Undefined => "r".to_string(), + m => i.to_str(&m)?.to_rust_string().to_lowercase(), + }; + let refusal = match mode.as_str() { + "r" => None, + "w" | "e" | "a" => Some("noWrites"), + _ => Some("badMode"), + }; + if let Some(name) = refusal { + let m = message(i, name)?; + set_slot(i, o, ERROR, m)?; + return Ok(Value::Bool(false)); + } + close_open(i, o)?; + let encoding = slot(i, o, ENCODING)?; + let r = host(i, "open", &[path, encoding])?; + let (error, id) = i.rooted(std::slice::from_ref(&r), |i| Ok((field(i, &r, "error")?, field(i, &r, "id")?)))?; + if matches!(error, Value::String(_)) { + set_slot(i, o, ERROR, error)?; + return Ok(Value::Bool(false)); + } + set_slot(i, o, HANDLE, id)?; + Ok(Value::Bool(true)) +} + +fn close(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "close")?; + if opened(i, o)?.is_none() { + return Ok(Value::Bool(false)); + } + close_open(i, o)?; + Ok(Value::Bool(true)) +} + +fn read(i: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "read")?; + let n = count(i, &arg(args, 0), "File.read")?; + let Some(h) = opened(i, o)? else { return Ok(Value::str("")) }; + let r = host(i, "read", &[h, n.map_or(Value::Number(-1.0), Value::Number)])?; + text_result(i, o, r, Value::str("")) +} + +fn read_line(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "readln")?; + let Some(h) = opened(i, o)? else { return Ok(Value::str("")) }; + let r = host(i, "readln", &[h])?; + text_result(i, o, r, Value::str("")) +} + +fn read_char(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "readch")?; + let Some(h) = opened(i, o)? else { return Ok(Value::str("")) }; + let r = host(i, "readch", &[h])?; + text_result(i, o, r, Value::str("")) +} + +fn seek(i: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "seek")?; + let pos = i.to_number(&arg(args, 0))?; + if !pos.is_finite() { + return Err(i.throw(ErrorType::Range, "File.seek: the position must be a finite number")); + } + let mode = match arg(args, 1) { + Value::Undefined => 0.0, + m => i.to_number(&m)?, + }; + if mode != 0.0 && mode != 1.0 && mode != 2.0 { + return Err(i.throw(ErrorType::Range, "File.seek: the mode must be 0, 1 or 2")); + } + let Some(h) = opened(i, o)? else { return Ok(Value::Bool(false)) }; + host(i, "seek", &[h, Value::Number(pos.floor()), Value::Number(mode)]) +} + +fn tell(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "tell")?; + let Some(h) = opened(i, o)? else { return Ok(Value::Number(-1.0)) }; + host(i, "tell", &[h]) +} + +// ---- Folder ---- + +fn get_files(i: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let (o, path) = this_path(i, &this, "getFiles")?; + let mask = arg(args, 0); + let call = i.is_callable(&mask); + let pattern = if call || matches!(mask, Value::Undefined) { Value::str("*") } else { Value::String(i.to_str(&mask)?) }; + let r = host(i, "list", &[path, pattern])?; + i.rooted(&[r.clone(), mask.clone()], |i| { + let error = field(i, &r, "error")?; + if matches!(error, Value::String(_)) { + set_slot(i, o, ERROR, error)?; + return Ok(Value::Null); + } + let entries = field(i, &r, "entries")?; + let length = field(i, &entries, "length")?; + let n = i.to_number(&length)?; + i.reserve_heap(OBJECT_BYTES)?; + let proto = i.intrinsics.array_proto; + let out = i.heap.alloc_object(Object::new(Class::Array { length: 0, length_writable: true }, Some(proto))); + i.rooted(&[Value::Object(out)], |i| { + let c = classes(i)?; + let mut kept = 0.0; + let mut at = 0.0; + while at < n { + i.use_fuel()?; + let e = i.get_value(&entries, &crate::value::number_to_str(at).flat())?; + let (fs, dir) = i.rooted(std::slice::from_ref(&e), |i| Ok((field(i, &e, "fs")?, field(i, &e, "dir")?.truthy())))?; + let item = instance(i, if dir { c.folder_proto } else { c.file_proto }, fs)?; + let keep = if call { + i.rooted(std::slice::from_ref(&item), |i| i.call(&mask, &Value::Undefined, std::slice::from_ref(&item)))?.truthy() + } else { + true + }; + if keep { + put_at(i, out, kept, item)?; + kept += 1.0; + } + at += 1.0; + } + Ok(Value::Object(out)) + }) + }) +} + +fn get_current(i: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult { + let c = classes(i)?; + instance(i, c.folder_proto, c.current) +} + +fn set_current(i: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let p = parse(i, &arg(args, 0))?; + let fs = i.rooted(std::slice::from_ref(&p), |i| field(i, &p, "fs"))?; + if let Some(c) = i.files.as_mut() { + c.current = fs; + } + Ok(Value::Undefined) +} + +/// `Folder.`: a new `Folder` for the host's location `place`, or `null`. +fn place(i: &mut Interp, place: &str) -> JsResult { + let all = host(i, "locations", &[])?; + let fs = i.rooted(std::slice::from_ref(&all), |i| field(i, &all, place))?; + if !matches!(fs, Value::String(_)) { + return Ok(Value::Null); + } + let proto = classes(i)?.folder_proto; + instance(i, proto, fs) +} + +macro_rules! places { + ($($f:ident => $name:literal),* $(,)?) => { + $(fn $f(i: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult { + place(i, $name) + })* + /// The getter of each of [`PLACES`], in its order. + const PLACE_GETTERS: [NativeFn; 10] = [$($f),*]; + }; +} + +places!( + app_data => "appData", + app_package => "appPackage", + common_files => "commonFiles", + desktop => "desktop", + my_documents => "myDocuments", + startup => "startup", + system => "system", + temp => "temp", + trash => "trash", + user_data => "userData", +); + +// ---- install ---- + +/// Defines the accessor `name` on `on`, not enumerable, as the help pages' objects have them. +fn accessor(i: &mut Interp, on: ObjId, name: &'static str, get: NativeFn, set: Option) -> JsResult<()> { + let g = native(i, name, get, 0, NativeKind::Plain, false)?; + i.rooted(&[Value::Object(g)], |i| { + let s = match set { + Some(f) => Some(native(i, name, f, 1, NativeKind::Plain, false)?), + None => None, + }; + i.define(on, key(name), Prop::Accessor { get: Some(g), set: s, flags: CONFIGURABLE }) + }) +} + +/// What `File` and `Folder` both have. +fn common(i: &mut Interp, ctor: ObjId, proto: ObjId) -> JsResult<()> { + accessor(i, proto, "absoluteURI", absolute_uri, None)?; + accessor(i, proto, "fullName", absolute_uri, None)?; + accessor(i, proto, "fsName", fs_name, None)?; + accessor(i, proto, "relativeURI", relative_uri, None)?; + accessor(i, proto, "name", name, None)?; + accessor(i, proto, "displayName", display_name, None)?; + accessor(i, proto, "path", path, None)?; + accessor(i, proto, "parent", parent, None)?; + accessor(i, proto, "exists", exists, None)?; + accessor(i, proto, "created", created, None)?; + accessor(i, proto, "modified", modified, None)?; + accessor(i, proto, "alias", alias, None)?; + accessor(i, proto, "error", get_error, Some(set_error))?; + method(i, proto, "toString", to_string, 0)?; + method(i, proto, "getRelativeURI", get_relative_uri, 1)?; + method(i, proto, "changePath", change_path, 1)?; + method(i, proto, "resolve", resolve, 0)?; + method(i, proto, "execute", execute, 0)?; + method(i, proto, "remove", no_writes, 0)?; + method(i, proto, "rename", no_writes, 1)?; + method(i, ctor, "encode", encode, 1)?; + method(i, ctor, "decode", decode, 1)?; + method(i, ctor, "isEncodingAvailable", encoding_available, 1)?; + let system = host_value(i, "system")?; + data(i, ctor, "fs", system, HIDDEN) +} + +fn file_members(i: &mut Interp, ctor: ObjId, proto: ObjId) -> JsResult<()> { + accessor(i, proto, "length", length, Some(no_writes_setter))?; + accessor(i, proto, "hidden", hidden, Some(no_writes_setter))?; + accessor(i, proto, "readonly", readonly, Some(no_writes_setter))?; + accessor(i, proto, "creator", four_question_marks, None)?; + accessor(i, proto, "type", four_question_marks, None)?; + accessor(i, proto, "eof", eof, None)?; + accessor(i, proto, "lineFeed", get_line_feed, Some(set_line_feed))?; + accessor(i, proto, "encoding", get_encoding, Some(set_encoding))?; + method(i, proto, "open", open, 1)?; + method(i, proto, "close", close, 0)?; + method(i, proto, "read", read, 1)?; + method(i, proto, "readln", read_line, 0)?; + method(i, proto, "readch", read_char, 0)?; + method(i, proto, "seek", seek, 2)?; + method(i, proto, "tell", tell, 0)?; + method(i, proto, "write", no_writes, 1)?; + method(i, proto, "writeln", no_writes, 1)?; + method(i, proto, "copy", no_writes, 1)?; + method(i, proto, "createAlias", no_writes, 1)?; + method(i, proto, "openDlg", no_picker, 0)?; + method(i, proto, "saveDlg", no_picker, 0)?; + method(i, ctor, "openDialog", null_static, 0)?; + method(i, ctor, "saveDialog", null_static, 0) +} + +fn folder_members(i: &mut Interp, ctor: ObjId, proto: ObjId) -> JsResult<()> { + method(i, proto, "getFiles", get_files, 1)?; + method(i, proto, "create", no_writes, 0)?; + method(i, proto, "selectDlg", no_picker, 0)?; + method(i, ctor, "selectDialog", null_static, 0)?; + accessor(i, ctor, "current", get_current, Some(set_current))?; + for (place, get) in PLACES.into_iter().zip(PLACE_GETTERS) { + accessor(i, ctor, place, get, None)?; + } + Ok(()) +} + +/// Adds `File` and `Folder` to the global object, their work done by the host functions in +/// `items` ([`crate::Realm::define_file_classes`]). +pub(crate) fn install(i: &mut Interp, items: Vec<(String, HostItem)>) -> JsResult<()> { + let host_obj = i.host_object("File", items)?; + let object_proto = i.intrinsics.object_proto; + i.rooted(&[Value::Object(host_obj)], |i| { + i.reserve_heap(2 * OBJECT_BYTES)?; + let file_proto = i.heap.alloc_object(Object::new(Class::Ordinary, Some(object_proto))); + let folder_proto = i.heap.alloc_object(Object::new(Class::Ordinary, Some(object_proto))); + i.files = Some(Classes { file_proto, folder_proto, host: host_obj, current: Value::str("") }); + let start = host(i, "current", &[])?; + if let Some(c) = i.files.as_mut() { + c.current = start; + } + let file = native(i, "File", file_ctor, 1, NativeKind::Plain, true)?; + constructor(i, "File", file, file_proto)?; + let folder = native(i, "Folder", folder_ctor, 1, NativeKind::Plain, true)?; + constructor(i, "Folder", folder, folder_proto)?; + common(i, file, file_proto)?; + common(i, folder, folder_proto)?; + file_members(i, file, file_proto)?; + folder_members(i, folder, folder_proto) + }) +} + +#[cfg(test)] +mod tests { + use std::rc::Rc; + + use crate::{Data, HostItem, JsValue, Realm}; + + /// A host whose paths are `/` and a name, where `/d` is a folder holding `a` and `b` and every + /// other path is missing. + fn realm() -> Realm { + let f = |g: fn(&[Data]) -> Data| HostItem::Function(Rc::new(move |a: Vec| Ok(g(&a)))); + let text = |a: &[Data], i: usize| match a.get(i) { + Some(Data::String(s)) => s.clone(), + _ => String::new(), + }; + let obj = |fields: Vec<(&str, Data)>| Data::Object(fields.into_iter().map(|(k, v)| (k.to_string(), v)).collect()); + let parse = move |a: &[Data]| { + let t = text(a, 0); + let fs = if t.starts_with('/') { t } else { format!("/{t}") }; + let name = fs.trim_start_matches('/').to_string(); + obj(vec![ + ("fs", Data::String(fs.clone())), + ("uri", Data::String(fs)), + ("name", Data::String(name.clone())), + ("display", Data::String(name)), + ("path", Data::String("/".into())), + ("parent", Data::String("/".into())), + ]) + }; + let messages = ["notOpen", "noWrites", "noPicker", "badMode"].map(|k| (k.to_string(), HostItem::Value(Data::String(k.into())))); + let stat = move |a: Vec| -> Result { + let dir = text(&a, 0) == "/d"; + Ok(obj(vec![("exists", Data::Bool(dir)), ("dir", Data::Bool(dir))])) + }; + let list = move |_: Vec| -> Result { + let e = |n: &str| obj(vec![("fs", Data::String(format!("/d/{n}"))), ("dir", Data::Bool(false))]); + Ok(obj(vec![("entries", Data::Array(vec![e("a"), e("b")]))])) + }; + let mut r = Realm::new(); + let items: Vec<(String, HostItem)> = vec![ + ("text".into(), HostItem::Object(messages.to_vec())), + ("system".into(), HostItem::Value(Data::String("Unix".into()))), + ("lineFeed".into(), HostItem::Value(Data::String("Unix".into()))), + ("current".into(), f(|_| Data::String("/".into()))), + ("parse".into(), HostItem::Function(Rc::new(move |a: Vec| Ok(parse(&a))))), + ("stat".into(), HostItem::Function(Rc::new(stat))), + ("list".into(), HostItem::Function(Rc::new(list))), + ("tempName".into(), f(|_| Data::String("/tmp1".into()))), + ("locations".into(), f(|_| Data::Object(vec![]))), + ]; + r.define_file_classes(items).unwrap(); + r + } + + #[test] + fn objects_keep_their_classes_through_collections() { + let mut r = realm(); + r.run("var kept = new File('x'); var listed = new Folder('d').getFiles(); File = null; Folder = null;").unwrap(); + r.collect_garbage(); + r.run("var many = []; for (var i = 0; i < 2000; i++) many.push(kept.parent);").unwrap(); + r.collect_garbage(); + let v = r.run( + "[kept.name, kept.fsName, listed.length, listed[1].name, kept.parent.getFiles(function (f) { return f.name === 'd/a'; }).length].join()", + ); + assert_eq!(v.unwrap(), JsValue::String("x,/x,2,d/b,1".into())); + } + + #[test] + fn the_constructor_picks_the_class_only_without_new() { + let mut r = realm(); + let v = r.run( + "var F = File, D = Folder; [F('d') instanceof D, new F('d') instanceof F, D('x') instanceof D, \ + typeof Object.keys(new F('x')).length, Object.keys(new F('x')).length].join()", + ); + assert_eq!(v.unwrap(), JsValue::String("true,true,true,number,0".into())); + let e = r.run("File.prototype.read.call({})").unwrap_err(); + assert!(e.message.contains("is not a File or a Folder"), "{e:?}"); + } +} diff --git a/crates/js/src/host.rs b/crates/js/src/host.rs index 2a12baf3c3..c029954775 100644 --- a/crates/js/src/host.rs +++ b/crates/js/src/host.rs @@ -462,7 +462,7 @@ impl Interp { self.define(global, key(name), Prop::Data { value: Value::Object(o), flags: 0 }) } - fn host_object(&mut self, path: &str, items: Vec<(String, HostItem)>) -> JsResult { + pub(crate) fn host_object(&mut self, path: &str, items: Vec<(String, HostItem)>) -> JsResult { let o = self.new_object(); let mark = self.temp_roots.len(); self.temp_roots.push(Value::Object(o)); diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index cacd5a95b9..3e68007638 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -175,6 +175,8 @@ pub(crate) struct Interp { pub(crate) held_bytes: usize, /// Why installing the built-ins failed; a realm with this set runs no script. pub(crate) install_error: Option, + /// `File` and `Folder`, once a host has added them ([`crate::Realm::define_file_classes`]). + pub(crate) files: Option, /// The clock and time zone `Date` reads. pub(crate) date_host: crate::date_builtins::DateHost, /// The bytes native code holds outside the heap's cells through [`HeldBytes`]. @@ -395,6 +397,7 @@ impl Interp { join_stack: Vec::new(), held_bytes: 0, install_error: None, + files: None, date_host: crate::date_builtins::DateHost::default(), held: Rc::default(), pause: Rc::default(), @@ -587,6 +590,7 @@ impl Interp { } roots.objects.push(self.global); roots.objects.extend(self.intrinsics.all()); + roots.objects.extend(self.files.iter().flat_map(crate::file_classes::Classes::objects)); roots.envs.push(self.global_env); self.heap.collect(&roots); // The collection counts the live total again, so bytes already released are not in it. diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 3936154eb7..9c17d09d1c 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -46,6 +46,7 @@ mod chars; mod compiler; mod date_builtins; mod error; +mod file_classes; mod heap; mod host; mod interp; diff --git a/crates/js/src/realm.rs b/crates/js/src/realm.rs index 13ed7d2624..4b653c0aa1 100644 --- a/crates/js/src/realm.rs +++ b/crates/js/src/realm.rs @@ -398,6 +398,20 @@ impl Realm { } } + /// Adds the global constructors `File` and `Folder`, whose file system work the host functions + /// in `items` do: `parse`, `relative`, `tempName`, `stat`, `open`, `close`, `read`, `readln`, + /// `readch`, `seek`, `tell`, `eof`, `encoding`, `setEncoding`, `list`, `resolve`, `execute`, + /// `encode`, `decode`, `encodingAvailable`, `current` and `locations`, with the values `system` + /// and `lineFeed` and the messages `text.notOpen`, `text.noWrites`, `text.noPicker` and + /// `text.badMode`. Each object keeps its path, its `error` text, its open file's id, its + /// encoding and its line feed in properties that are not enumerable. + pub fn define_file_classes(&mut self, items: Vec<(String, crate::HostItem)>) -> Result<(), RunError> { + match crate::file_classes::install(&mut self.interp, items) { + Ok(()) => Ok(()), + Err(e) => self.outcome(Err(e)).map(drop), + } + } + /// Defines the global function `name`, whose arguments arrive converted as `args` says /// ([`crate::HostItem::Converted`]). Unlike a host object, a script may replace or delete it, as /// it may a built-in function. diff --git a/docs/mcp.md b/docs/mcp.md index 9592012540..47f53d8a4a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1661,6 +1661,8 @@ VectorCraft sets these limits itself: | A dialog's message, title and prompt preset | 16 KiB, 256 bytes and 16 KiB | | `answers` | 10,000 answers and 1 MiB as JSON | | `dialogs` in the report | 10,000 dialogs and 1 MiB as JSON | +| Files `File.open` reads | 8 MiB a file, 32 MiB in a run, 256 open at once | +| `Folder.getFiles` | 2,048 entries, 65,536 names looked at | | Growth before a person is asked | 100,000 objects and 512 MiB of new document data | In the app, a script past the growth limit stops at a question to continue or to stop and undo. Stop, Escape and @@ -1673,7 +1675,9 @@ another file than the script) and `fuelLeft`. - `runTimeMs` is the time the script ran, its commands included. Waits between frames and for a person's answer are left out. `timingAvailable` is false on the web, where `runTimeMs` is 0. -- `hostCallCounts` is `{vcRun, documentReads, commandListReads, vcLog, consoleWrites}`. +- `hostCallCounts` is `{vcRun, documentReads, commandListReads, vcLog, consoleWrites, fileCalls, fileExecutes}`. + `fileCalls` counts the calls `File` and `Folder` make to the app, and `fileExecutes` the `execute` calls, which open + nothing. - `slowestCommands` lists at most 5 commands as `{id, calls, totalMs, maxMs}`, the longest in all first, then by id. - `console` is `{lines, droppedLines, truncatedLines}`: the run's own output, `vc.log` lines and uncaught error, as text. @@ -1732,6 +1736,54 @@ empty object are the same. vectorcraft-cli run --cmd script.run --params '{"source": "if (confirm(\"Add a frame?\")) vc.log(prompt(\"Name\", \"Frame\"));", "answers": [{"kind": "confirm", "value": true}, {"kind": "prompt", "value": "Border"}]}' ``` +The help pages https://extendscript.docsforadobe.dev/file-system-access/file-object/ and +https://extendscript.docsforadobe.dev/file-system-access/folder-object/ describe the `File` and `Folder` objects. A +script reads files with them: + +- `new File(path)` and `new Folder(path)` name a path without looking it up. A path is a platform path or a URI + path: a relative path is taken from `Folder.current`, which starts at the script's folder, `~` is the home folder, + `%xx` escapes are read as UTF-8, and on Windows `/` and `\` both separate names and `/c/` names drive C. `File(path)` + without `new` gives a `Folder` for an existing folder, and `Folder(path)` gives a `File` for an existing file. Two + objects for the same path are different objects. +- `name`, `displayName`, `fsName`, `absoluteURI`, `fullName`, `relativeURI`, `path`, `parent` and `toString()` come + from the path. `exists`, `length`, `created`, `modified`, `hidden`, `readonly` and `alias` read the file system. + `File.encode`, `File.decode` and `File.isEncodingAvailable` (and the same on `Folder`), `File.fs`, and the folders + `Folder.temp`, `Folder.desktop`, `Folder.myDocuments`, `Folder.userData` and the others are there too. +- `open('r')` opens a file to read, and `read([chars])`, `readln()`, `readch()`, `seek(pos[, mode])`, `tell()`, `eof` + and `close()` read it. `read`, `readln` and `readch` count characters, and `seek` and `tell` count bytes. `readln` + and `readch` take CR, LF, CRLF and LF CR as one line end. `folder.getFiles([mask])` lists a folder, sorted by name; + the mask matches `*` and `?` without regard to case, and a function as the mask keeps the entries it returns true + for. +- A method that fails returns `false`, `null` or an empty string and sets `error`, such as `Script file access + denied`, `Script file does not exist` or `Script file is not open`. A count or a position that is not a finite + number, or a negative count, throws a `RangeError`. + +VectorCraft reads files by these rules of its own: + +- A script run from a file reads only in that file's folder and the folders below it, as its includes are read, and + a script given as `source` reads no files. When automation read roots are set, as for a run over MCP or the command + line, the files must also be inside them. A path outside, a Windows network path (`\\host\share`) and a device path + (`\\?\`, `\\.\`) are refused before anything is looked up: `exists` is `false`, the dates and `getFiles` give `null`, + and `error` is `Script file access denied`. A path that leads out through a link or a junction is refused too, and + `getFiles` leaves such an entry out. +- `open` reads the whole file. A file larger than 8 MiB is refused from its size before any of it is read, with + `Script file exceeds 8 MiB`, and a run that opens more than 32 MiB in all ends with an error. A run has at most 256 + files open at once; another `open` returns `false` with `Script has 256 files open` until one is closed. +- The encoding is the one the file's byte order mark gives (UTF-8, UTF-16LE or UTF-16BE), otherwise the `encoding` + the script set before `open`, otherwise UTF-8 when the bytes are valid UTF-8, otherwise CP1252. `BINARY` reads each + byte as one character. A byte the encoding cannot read, such as one above 127 in `ASCII`, fails the read with + `Script file cannot be decoded` and leaves the position where it was. +- `getFiles` gives at most 2,048 entries and looks at most 65,536 names; past that it gives `null` with `Script folder + has more than 2,048 entries`. A mask that takes more than 16,777,216 steps to match against the names of one + folder gives `null` with `Script folder mask takes too many steps to match`. +- A path longer than 32,767 characters throws a `RangeError` (`Script file path exceeds 32,767 characters`) when a + `File` or `Folder` is made from it. +- Writing is not available yet: `open('w')`, `open('e')`, `open('a')`, `write`, `writeln`, `copy`, `remove`, + `rename`, `create` and `createAlias` return `false` with `Script file writing is not available`, and setting + `length`, `hidden` or `readonly` sets `error` to that message and changes nothing. The file + and folder dialogs return `null`, and `execute` opens nothing and returns `false`. In the web build every lookup + fails with `Script file access is unavailable on the web`. + Window › Script Console shows the session's script lines, oldest first: each run's start, its `$.write` and `$.writeln` output, its `vc.log` entries, its uncaught error as `name:line:column: message` (or `name: file:line:column: message` when the error is in another file), its end, and a report of diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8e78caf8e6..5f1d867769 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -734,6 +734,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Scripts: Window › Script Console shows each run's `$.write` and `$.writeln` output, `vc.log` entries, uncaught error with the script's name, line and column, and a report of its time, host calls and slowest commands, keeping at most 2,048 lines and counting the lines it dropped. `script.run`'s report carries the same data, and `script.console`, `script.console.clear` and `ui.copyScriptConsole` read, clear and copy it. `crates/engine/src/tests/script_console.rs`. - Scripts: an error `$.evalFile` throws for a file it cannot run is reported at that file's line and column only while no `catch` has taken it. Caught and thrown again later, in the script or in another file, it is reported where it was thrown again, and a new error whose message holds its text is reported where that error was thrown. VectorCraft's own rule. `crates/engine/src/tests/script_include.rs`, `crates/js/src/host_tests.rs`. - Scripts: a run reads its preprocessor lines before any of it runs: `#include` and `//@include` put another file in place of the line, looked for beside the including file, in the `#includepath` folders and in `JSINCLUDE`; `#script` names the run; `#target` and `#strict off` are taken, `#strict on` turns on strict write checks (below), and `#targetengine` and `#engine` are refused. `$.evalFile` runs a file with the same globals and returns its last value, and `$.fileName` and `$.includePath` give the file being run and the include folders. A script run from a file reads these files only in its folder and below, without `..` names or links that lead out, and an error in one is reported at its own file, line and column ([preprocessor directives](https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/), [the `$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/); `crates/engine/src/tests/script_include.rs`). + - Scripts: `File` and `Folder` ([File object](https://extendscript.docsforadobe.dev/file-system-access/file-object/), [Folder object](https://extendscript.docsforadobe.dev/file-system-access/folder-object/)) name files and folders by platform or URI path, with `name`, `fsName`, `absoluteURI`, `relativeURI`, `parent`, `Folder.current` and `encode`/`decode`, and read them: `exists`, `length`, the dates, `getFiles` with a mask or a function, and `open('r')` with `read`, `readln`, `readch`, `seek`, `tell` and `close`, decoding by byte order mark, `encoding`, UTF-8 or CP1252. VectorCraft's own rules: a script reads only in its own folder and below (and inside the automation read roots when they are set); any other path and a network or device path are refused before anything is looked up, and a link that leads out is refused as well (`false` or `null`, with `error` set); a file is read whole up to 8 MiB and a run reads up to 32 MiB; writing, the file dialogs and `execute` are not available yet. `crates/engine/src/tests/script_io.rs`. - Scripts: `#strict on` (and `//@strict on`) runs the script with the checks the [`$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/) help page gives for `$.strict`: an assignment to a read-only property throws a `TypeError`, and so does a new property on an object that takes none. VectorCraft's own rules: a getter-only property counts as read-only, the objects that take no new properties are the non-extensible ones (`Object.preventExtensions`, `seal`, `freeze`, and the frozen `vc` and `$` objects), the last `#strict` line of the script and its includes sets the whole run, a file `$.evalFile` runs keeps its own line's checks for its code, and the rest of ES5 strict mode stays with `'use strict'`. Without the line, runs are unchanged. `crates/engine/src/tests/script_include.rs` (`strict_on_*`, `strict_mode_in_eval_file_is_scoped`), `crates/js/src/interp_tests.rs` (`strict_writes_are_separate_from_es5_strict`). - Scripts folder: File › Scripts lists the `.js` and `.jsx` files of the `Scripts` folder next to the preferences, each named after its file without the extension, with each subfolder as a submenu, above Check Script… and Other Script… (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html). VectorCraft's own rules: the folder is read again when the window becomes active and when the File menu opens, links are not followed, and one read stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms. `script.listInstalled` returns the listing. `crates/engine/src/tests/script_folder.rs`, `crates/ui-egui/src/tests/scripts_menu.rs`. - Open Recent Files under `--automation-read-root`: the control channel's `ui.menu.list` and `file.recentFiles` leave out the recent files outside the read roots, the other rows keep their ids, and `file.openRecentN` for one outside them answers `no such recent file`, as an empty slot does. `crates/ui-egui/src/tests/automation.rs`.