diff --git a/crates/engine/src/commands/scripts.rs b/crates/engine/src/commands/scripts.rs index e6951c37..198c9bad 100644 --- a/crates/engine/src/commands/scripts.rs +++ b/crates/engine/src/commands/scripts.rs @@ -124,12 +124,38 @@ fn is_script(name: &str) -> bool { l.ends_with(".jsx") || l.ends_with(".js") } +/// Whether `name` can name an installed script: a bare file name, never a path (names are joined +/// onto the Scripts folders of the settings store, which takes them as relative paths), and on +/// Windows none of the names [`windows_unsafe_name`] lists. +fn is_script_name(name: &str) -> bool { + is_bare_name(name) && !(cfg!(windows) && windows_unsafe_name(name)) +} + +/// One path component on every platform: not empty, `.` or `..`, and without `/`, `\` or NUL. +fn is_bare_name(name: &str) -> bool { + !name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\', '\0']) +} + +/// Names that are not ordinary file names on Windows (elsewhere they are): with `:` (a drive or a +/// stream), ending in `.` or a space (Windows drops them), or a device name, which names a device +/// in any folder and with any extension (`con.jsx`): `CON`, `PRN`, `AUX`, `NUL`, `CONIN$`, +/// `CONOUT$`, `COM0`–`COM9` and `LPT0`–`LPT9` (also with `¹²³`). +fn windows_unsafe_name(name: &str) -> bool { + let stem = name.split('.').next().unwrap_or_default().trim_end_matches(' ').to_ascii_uppercase(); + let port = |p: &str| { + let mut n = stem.strip_prefix(p).unwrap_or_default().chars(); + matches!((n.next(), n.next()), (Some('0'..='9' | '¹' | '²' | '³'), None)) + }; + let device = matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$") || port("COM") || port("LPT"); + name.contains(':') || name.ends_with(['.', ' ']) || device +} + /// Installed scripts and panels, then the bundled ones not shadowed by an installed one, by name. pub fn scripts(s: &Session) -> Vec { let mut v: Vec = vec![]; if let Some(cfg) = &s.config { for (dir, panel) in [(SCRIPTS_DIR, false), (PANELS_DIR, true)] { - for n in cfg.list(dir).into_iter().filter(|n| is_script(n)) { + for n in cfg.list(dir).into_iter().filter(|n| is_script(n) && is_script_name(n)) { v.push(ScriptEntry { name: n, panel, source: "installed" }); } } @@ -145,6 +171,9 @@ pub fn scripts(s: &Session) -> Vec { /// The code of an installed or bundled script / panel. pub fn script_code(s: &Session, name: &str, panel: Option) -> Option<(bool, String)> { + if !is_script(name) || !is_script_name(name) { + return None; + } if let Some(cfg) = &s.config { for (dir, is_panel) in [(SCRIPTS_DIR, false), (PANELS_DIR, true)] { if panel.is_some_and(|p| p != is_panel) { @@ -165,6 +194,10 @@ fn install(s: &mut Session, p: &Value, panel: bool) -> Result { if !is_script(&name) { return Err(bad(c, "scripts are .jsx or .js files (.jsxbin is not supported)")); } + // A name it could not be run or uninstalled by (`\` is a file-name character on Unix). + if !is_script_name(&name) { + return Err(bad(c, format!("`{name}` is not a script file name"))); + } let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?; let code = String::from_utf8(bytes).map_err(|_| bad(c, "the script is not UTF-8 text"))?; let cfg = s.config.clone().ok_or_else(|| EngineError::Other("this session has no settings folder to install scripts into".into()))?; @@ -186,6 +219,9 @@ fn list(s: &mut Session, _: &Value) -> Result { fn uninstall(s: &mut Session, p: &Value) -> Result { let c = "file.uninstallScript"; let name = str_p(p, "name").ok_or_else(|| bad(c, "missing `name`"))?; + if !is_script(name) || !is_script_name(name) { + return Err(bad(c, format!("`{name}` is not a script file name"))); + } let cfg = s.config.clone().ok_or_else(|| bad(c, "no settings folder"))?; let mut removed = false; for dir in [SCRIPTS_DIR, PANELS_DIR] { @@ -384,3 +420,168 @@ pub fn specs() -> Vec { ), ] } + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + use std::sync::Arc; + + use serde_json::{Value, json}; + + use super::{BUNDLED, PANELS_DIR, SCRIPTS_DIR, is_bare_name, is_script, is_script_name, script_code, windows_unsafe_name}; + use crate::config::DirConfig; + use crate::{EngineError, Result, ScriptRequest, Session}; + + /// Stands in for the JavaScript runtime: reports the code it was asked to run. + fn echo(_: &mut Session, r: &ScriptRequest) -> Value { + json!({"ran": r.code}) + } + + /// A session whose settings store is a fresh `/settings/` (with both Scripts folders, as + /// after installing a script and a panel), next to a script `/outside.jsx`. + fn session_in_tmp(name: &str) -> (Session, PathBuf) { + use std::sync::atomic::{AtomicU32, Ordering}; + static N: AtomicU32 = AtomicU32::new(0); + let root = std::env::temp_dir().join(format!("ec-scripts-{}-{}-{name}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("settings").join(PANELS_DIR)).unwrap(); + std::fs::write(root.join("outside.jsx"), "outside").unwrap(); + let s = Session { config: Some(Arc::new(DirConfig::new(root.join("settings")))), script: Some(echo), ..Default::default() }; + (s, root) + } + + fn bad_params(r: &Result) -> bool { + matches!(r, Err(EngineError::BadParams { .. })) + } + + /// Paths, refused on every platform. + const PATHS: [&str; 10] = ["", ".", "..", "../x.jsx", "a/b.jsx", "/abs/x.jsx", "a\\b.jsx", "..\\x.jsx", "C:\\x.jsx", "a\0.jsx"]; + /// Ordinary file names except on Windows. + const WINDOWS_ONLY: [&str; 8] = ["Step 1: Setup.jsx", "In:Out.jsx", "C:x.jsx", "x.jsx.", "x.jsx ", "Aux.Tools.jsx", "con.jsx", "COM1.jsx"]; + + #[test] + fn bare_names_are_one_path_component() { + for name in ["Rename Layers.jsx", "..jsx", "a..b.js", "Äpfel ✓.jsx"].into_iter().chain(WINDOWS_ONLY) { + assert!(is_bare_name(name), "{name:?}"); + } + for name in PATHS { + assert!(!is_bare_name(name), "{name:?}"); + } + } + + #[test] + fn windows_unsafe_names_are_drives_streams_trailing_dots_and_devices() { + let devices = ["CON", "Con.tar.jsx", "CON .jsx", "PRN.js", "aux.jsx", "NUL.jsx", "CONIN$.jsx", "conout$.js"]; + let ports = ["COM0.jsx", "com9.js", "LPT0.jsx", "lpt9.jsx", "COM¹.jsx", "LPT³.js"]; + for name in WINDOWS_ONLY.into_iter().chain(devices).chain(ports) { + assert!(windows_unsafe_name(name), "{name:?}"); + } + let fine = ["console.jsx", "Concert.jsx", "NULL.jsx", "CONIN.jsx", "COM10.jsx", "LPT.jsx", "x .jsx", "..jsx"]; + for name in BUNDLED.iter().map(|b| b.0).chain(fine) { + assert!(!windows_unsafe_name(name), "{name:?}"); + } + } + + #[test] + fn script_names_are_bare_file_names() { + let more = ["..jsx", "a..b.js", "Äpfel ✓.jsx", "console.jsx", "Concert.jsx", "NULL.jsx", "COM10.jsx", "LPT.jsx"]; + for name in BUNDLED.iter().map(|b| b.0).chain(more) { + assert!(is_script(name) && is_script_name(name), "{name}"); + } + for name in PATHS { + assert!(!is_script_name(name), "{name:?}"); + } + for name in WINDOWS_ONLY { + assert_eq!(is_script_name(name), !cfg!(windows), "{name:?}"); + } + // Install refuses a file name the script could not then be run or uninstalled by. + #[cfg(unix)] + { + let (mut s, root) = session_in_tmp("odd-name"); + let odd = root.join("a\\b.jsx"); + std::fs::write(&odd, "x").unwrap(); + assert!(bad_params(&s.execute("file.installScript", json!({"path": odd.to_string_lossy()})))); + assert!(!root.join("settings").join(SCRIPTS_DIR).join("a\\b.jsx").exists()); + let _ = std::fs::remove_dir_all(&root); + } + } + + #[test] + fn uninstall_takes_a_script_name_not_a_path() { + let (mut s, root) = session_in_tmp("uninstall"); + let (outside, prefs) = (root.join("outside.jsx"), root.join("settings").join("prefs.json")); + std::fs::write(&prefs, "{}").unwrap(); + // From `Scripts/`, `../../outside.jsx` is the script next to the settings folder and + // `../prefs.json` a settings file; an absolute path is not a script name either. + for name in ["../../outside.jsx".to_string(), "../prefs.json".into(), outside.to_string_lossy().into_owned()] { + let r = s.execute("file.uninstallScript", json!({"name": name})); + assert!(outside.is_file() && prefs.is_file(), "`{name}` deleted a file outside the Scripts folders: {r:?}"); + assert!(bad_params(&r), "`{name}`: {r:?}"); + } + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn scripts_run_by_name_come_from_the_scripts_folders() { + let (mut s, root) = session_in_tmp("run"); + // `Scripts/../../outside.jsx` and `Scripts/ScriptUI Panels/../../../outside.jsx` are the + // script next to the settings folder. + let r = s.execute("file.runScript", json!({"name": "../../outside.jsx"})); + assert!(bad_params(&r), "{r:?}"); + let r = s.execute("window.scriptPanel", json!({"name": "../../../outside.jsx"})); + assert!(bad_params(&r), "{r:?}"); + assert_eq!(script_code(&s, "../../outside.jsx", None), None); + assert_eq!(script_code(&s, "../../../outside.jsx", Some(true)), None); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn only_scripts_are_read_or_removed() { + // A settings file in the Scripts folder that is not a script (.jsx / .js). + let (mut s, root) = session_in_tmp("not-a-script"); + let notes = root.join("settings").join(SCRIPTS_DIR).join("notes.txt"); + std::fs::write(¬es, "notes").unwrap(); + assert_eq!(script_code(&s, "notes.txt", None), None); + let r = s.execute("file.uninstallScript", json!({"name": "notes.txt"})); + assert!(notes.is_file() && bad_params(&r), "{r:?}"); + let _ = std::fs::remove_dir_all(&root); + } + + /// On Unix `\` is a file-name character: a script dropped into the folder under such a name is + /// not offered, as it could not be run or uninstalled. Names only Windows refuses stay. + #[cfg(unix)] + #[test] + fn listing_skips_names_run_and_uninstall_refuse() { + let (mut s, root) = session_in_tmp("odd-names"); + let scripts = root.join("settings").join(SCRIPTS_DIR); + std::fs::write(scripts.join("a\\b.jsx"), "x").unwrap(); + std::fs::write(scripts.join("In:Out.jsx"), "in-out").unwrap(); + let list = s.execute("file.scripts.list", json!({})).unwrap(); + let listed = |n: &str| list.as_array().unwrap().iter().any(|e| e["name"] == n); + assert!(!listed("a\\b.jsx") && listed("In:Out.jsx"), "{list}"); + assert_eq!(script_code(&s, "a\\b.jsx", None), None); + assert_eq!(script_code(&s, "In:Out.jsx", None), Some((false, "in-out".to_string()))); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn install_run_and_uninstall_by_name() { + let (mut s, root) = session_in_tmp("install"); + let file = root.join("Hello.jsx"); + std::fs::write(&file, "hello").unwrap(); + s.execute("file.installScript", json!({"path": file.to_string_lossy()})).unwrap(); + s.execute("file.installScriptUIPanel", json!({"path": file.to_string_lossy()})).unwrap(); + let installed = [root.join("settings").join(SCRIPTS_DIR).join("Hello.jsx"), root.join("settings").join(PANELS_DIR).join("Hello.jsx")]; + assert!(installed.iter().all(|p| p.is_file())); + assert_eq!(s.execute("file.runScript", json!({"name": "Hello.jsx"})).unwrap()["ran"], "hello"); + let r = s.execute("window.scriptPanel", json!({"name": "Hello.jsx"})).unwrap(); + assert!(r["result"]["ran"].as_str().is_some_and(|c| c.contains("hello")), "{r}"); + // The bundled samples still run by name. + let sample = BUNDLED.iter().find(|b| b.0 == "Rename Layers.jsx").unwrap().3; + assert_eq!(s.execute("file.runScript", json!({"name": "Rename Layers.jsx"})).unwrap()["ran"], sample); + s.execute("file.uninstallScript", json!({"name": "Hello.jsx"})).unwrap(); + assert!(installed.iter().all(|p| !p.exists())); + assert!(bad_params(&s.execute("file.uninstallScript", json!({"name": "Hello.jsx"}))), "no longer installed"); + let _ = std::fs::remove_dir_all(&root); + } +}