Merge pull request #558 from haranishi/fix/uninstall-script-name

This commit is contained in:
Claude committed 2026-10-10 12:51:15 +00:00
commit 072c33ecf0
1 file changed
+202 -1
+202 -1
View File
@@ -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<ScriptEntry> {
let mut v: Vec<ScriptEntry> = 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<ScriptEntry> {
/// The code of an installed or bundled script / panel.
pub fn script_code(s: &Session, name: &str, panel: Option<bool>) -> 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<Value> {
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<Value> {
fn uninstall(s: &mut Session, p: &Value) -> Result<Value> {
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<CommandSpec> {
),
]
}
#[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 `<root>/settings/` (with both Scripts folders, as
/// after installing a script and a panel), next to a script `<root>/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<Value>) -> 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(&notes, "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);
}
}