diff --git a/Cargo.lock b/Cargo.lock index 56a55e9b..a55ceac5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4428,6 +4428,7 @@ dependencies = [ "tracing", "wasmtime", "wasmtime-wasi", + "zip", ] [[package]] diff --git a/crates/ide-plugin/Cargo.toml b/crates/ide-plugin/Cargo.toml index 06b54ab5..d6bd3055 100644 --- a/crates/ide-plugin/Cargo.toml +++ b/crates/ide-plugin/Cargo.toml @@ -15,8 +15,7 @@ toml.workspace = true thiserror.workspace = true tracing.workspace = true parking_lot.workspace = true +tempfile.workspace = true +zip.workspace = true wasmtime = { version = "49.0.4", default-features = false, features = ["runtime", "cranelift", "component-model", "std", "parallel-compilation"] } wasmtime-wasi = { version = "49.0.4", default-features = false, features = ["p2"] } - -[dev-dependencies] -tempfile.workspace = true diff --git a/crates/ide-plugin/src/lib.rs b/crates/ide-plugin/src/lib.rs index 72446576..dc6a3ed4 100644 --- a/crates/ide-plugin/src/lib.rs +++ b/crates/ide-plugin/src/lib.rs @@ -15,6 +15,7 @@ //! (dev plugins, loaded by [`PluginHost::sync_project`]). mod manifest; +mod package; mod runtime; mod sandbox; mod theme; @@ -263,6 +264,20 @@ impl PluginHost { self.info_of(&new_id) } + /// Installs the package at `path`: a folder, its `plugin.toml`, or a `.zip` of the folder. + pub fn install_from_path(&self, path: &Path) -> IdeResult { + if package::is_zip(path) && path.is_file() { + // Outside the plugins directory: a crash must not leave a package that loads. + let unpacked = tempfile::Builder::new().prefix("openidea-plugin-").tempdir()?; + let root = package::unpack_zip(path, unpacked.path())?; + return self.install_from_dir(&root); + } + if path.file_name().is_some_and(|name| name == MANIFEST_FILE) && path.is_file() { + return self.install_from_dir(path.parent().unwrap_or(path)); + } + self.install_from_dir(path) + } + /// Copies a plugin package (manifest + referenced files) into the plugins /// directory, replacing an installed version with the same id. pub fn install_from_dir(&self, src: &Path) -> IdeResult { diff --git a/crates/ide-plugin/src/package.rs b/crates/ide-plugin/src/package.rs new file mode 100644 index 00000000..cefb3fa9 --- /dev/null +++ b/crates/ide-plugin/src/package.rs @@ -0,0 +1,74 @@ +//! Plugin packages on disk: a folder, its `plugin.toml`, or a `.zip` of the folder. + +use crate::MANIFEST_FILE; +use ide_core::{IdeError, IdeResult}; +use std::fs::{self, File}; +use std::io::{self, BufReader, Read}; +use std::path::{Path, PathBuf}; + +/// Far above any real package; stops a zip bomb before it fills the disk. +const MAX_ENTRIES: usize = 10_000; +const MAX_UNPACKED_BYTES: u64 = 1024 * 1024 * 1024; + +pub(crate) fn is_zip(path: &Path) -> bool { + path.extension().is_some_and(|e| e.eq_ignore_ascii_case("zip")) +} + +fn invalid_zip(zip: &Path, reason: impl std::fmt::Display) -> IdeError { + IdeError::invalid(format!("{} is not a valid plugin archive: {reason}", zip.display())) +} + +/// Unpacks `zip` into `dest` and returns the package folder inside it. Refuses the +/// whole archive when an entry would leave `dest` or is a symbolic link. +pub(crate) fn unpack_zip(zip: &Path, dest: &Path) -> IdeResult { + let mut archive = zip::ZipArchive::new(BufReader::new(File::open(zip)?)).map_err(|e| invalid_zip(zip, e))?; + if archive.len() > MAX_ENTRIES { + return Err(invalid_zip(zip, format!("more than {MAX_ENTRIES} entries"))); + } + let mut budget = MAX_UNPACKED_BYTES; + for i in 0..archive.len() { + let mut entry = archive.by_index(i).map_err(|e| invalid_zip(zip, e))?; + let name = entry.name().to_owned(); + let Some(rel) = entry.enclosed_name() else { + return Err(invalid_zip(zip, format!("unsafe path {name:?}"))); + }; + if entry.is_symlink() { + return Err(invalid_zip(zip, format!("symbolic link {name:?}"))); + } + let out = dest.join(rel); + if entry.is_dir() { + fs::create_dir_all(&out)?; + continue; + } + if let Some(parent) = out.parent() { + fs::create_dir_all(parent)?; + } + // One byte over the budget tells a full budget from a file that exactly fits. + let written = + io::copy(&mut (&mut entry).take(budget + 1), &mut File::create(&out)?).map_err(|e| invalid_zip(zip, e))?; + if written > budget { + return Err(invalid_zip(zip, "it unpacks to more than 1 GB")); + } + budget -= written; + } + package_root(dest).ok_or_else(|| { + IdeError::invalid(format!( + "{} is not an OpenIDEA plugin: no {MANIFEST_FILE} at its root or in its only folder", + zip.display() + )) + }) +} + +/// `dir` itself, or its only folder as when a zip holds the package folder. +/// Archiver metadata (`__MACOSX`, dot files) does not count as a second folder. +fn package_root(dir: &Path) -> Option { + if dir.join(MANIFEST_FILE).is_file() { + return Some(dir.to_path_buf()); + } + let mut folders = fs::read_dir(dir).ok()?.flatten().map(|e| e.path()).filter(|p| { + let name = p.file_name().unwrap_or_default().to_string_lossy(); + p.is_dir() && !name.starts_with('.') && name != "__MACOSX" + }); + let only = folders.next().filter(|_| folders.next().is_none())?; + only.join(MANIFEST_FILE).is_file().then_some(only) +} diff --git a/crates/ide-plugin/src/types.rs b/crates/ide-plugin/src/types.rs index be25b241..47890f3d 100644 --- a/crates/ide-plugin/src/types.rs +++ b/crates/ide-plugin/src/types.rs @@ -89,7 +89,7 @@ pub struct CatalogPlugin { pub authors: Vec, pub homepage: Option, pub change_notes: Option, - /// Package directory; install with `install_from_dir(path)`. + /// Package directory; install with `install_from_path(path)`. pub path: ide_core::IpcPath, pub has_code: bool, pub permissions: Vec, diff --git a/crates/ide-plugin/tests/host.rs b/crates/ide-plugin/tests/host.rs index a644cca9..2c2572c0 100644 --- a/crates/ide-plugin/tests/host.rs +++ b/crates/ide-plugin/tests/host.rs @@ -8,6 +8,7 @@ use ide_plugin::*; use parking_lot::Mutex; +use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; @@ -510,3 +511,93 @@ fn restricted_dev_plugin_has_no_activation_registration() { f.host.sync_project(&f.project); assert!(f.host.list(Some(&f.project)).plugins.is_empty()); } + +/// Zips the files of `dir` under `prefix` (recursively). +fn add_dir(zip: &mut zip::ZipWriter, dir: &Path, prefix: &str) { + for entry in std::fs::read_dir(dir).unwrap().flatten() { + let name = format!("{prefix}{}", entry.file_name().to_string_lossy()); + if entry.path().is_dir() { + add_dir(zip, &entry.path(), &format!("{name}/")); + } else { + zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + zip.write_all(&std::fs::read(entry.path()).unwrap()).unwrap(); + } + } +} + +fn zip_of(dir: &Path, prefix: &str, out: &Path) -> PathBuf { + let mut zip = zip::ZipWriter::new(std::fs::File::create(out).unwrap()); + add_dir(&mut zip, dir, prefix); + zip.finish().unwrap(); + out.to_path_buf() +} + +#[test] +fn installs_zips_with_the_package_at_the_root_or_in_one_folder() { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + for (name, prefix) in [("root.ZIP", ""), ("folder.zip", "hello-1.0/")] { + let zip = zip_of(&sample("hello"), prefix, &tmp.path().join(name)); + let info = f.host.install_from_path(&zip).unwrap(); + assert_eq!((info.id.as_str(), info.status), ("hello", PluginStatus::Active), "{:?}", info.error); + assert!(f.plugins_dir.join("hello/dist/plugin.wasm").is_file()); + } + // Still runs from the installed copy, not the unpacked archive. + assert!(f.run("hello", "insert-date", None).unwrap().is_some()); +} + +#[test] +fn installs_from_a_manifest_or_a_folder() { + let f = fixture(Limits::default()); + assert_eq!(f.host.install_from_path(&sample("hello").join("plugin.toml")).unwrap().id, "hello"); + assert_eq!(f.host.install_from_path(&sample("hello")).unwrap().id, "hello"); +} + +#[test] +fn ignores_archiver_metadata_next_to_the_package_folder() { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + let out = tmp.path().join("mac.zip"); + let mut zip = zip::ZipWriter::new(std::fs::File::create(&out).unwrap()); + add_dir(&mut zip, &sample("hello"), "hello/"); + zip.start_file("__MACOSX/hello/._plugin.toml", zip::write::SimpleFileOptions::default()).unwrap(); + zip.write_all(b"metadata").unwrap(); + zip.finish().unwrap(); + assert_eq!(f.host.install_from_path(&out).unwrap().id, "hello"); +} + +#[test] +fn refuses_zips_that_are_not_safe_plugin_packages() { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + let options = zip::write::SimpleFileOptions::default; + + let no_manifest = tmp.path().join("idea-plugin.zip"); + let mut zip = zip::ZipWriter::new(std::fs::File::create(&no_manifest).unwrap()); + zip.start_file("idea-plugin/lib/plugin.jar", options()).unwrap(); + zip.finish().unwrap(); + let error = f.host.install_from_path(&no_manifest).unwrap_err(); + assert!(error.message.contains("no plugin.toml"), "{}", error.message); + + let escape = tmp.path().join("escape.zip"); + let mut zip = zip::ZipWriter::new(std::fs::File::create(&escape).unwrap()); + zip.start_file("../../evil.txt", options()).unwrap(); + zip.write_all(b"evil").unwrap(); + zip.finish().unwrap(); + let error = f.host.install_from_path(&escape).unwrap_err(); + assert!(error.message.contains("unsafe path"), "{}", error.message); + + let link = tmp.path().join("link.zip"); + let mut zip = zip::ZipWriter::new(std::fs::File::create(&link).unwrap()); + zip.add_symlink("plugin.toml", "/etc/passwd", options()).unwrap(); + zip.finish().unwrap(); + let error = f.host.install_from_path(&link).unwrap_err(); + assert!(error.message.contains("symbolic link"), "{}", error.message); + + let garbage = tmp.path().join("garbage.zip"); + std::fs::write(&garbage, "not a zip").unwrap(); + let error = f.host.install_from_path(&garbage).unwrap_err(); + assert!(error.message.contains("not a valid plugin archive"), "{}", error.message); + + assert!(!f.plugins_dir.join("hello").exists()); +} diff --git a/docs/plugins/themes.md b/docs/plugins/themes.md index 6b038fc5..013041bf 100644 --- a/docs/plugins/themes.md +++ b/docs/plugins/themes.md @@ -109,8 +109,8 @@ There is no build step. The package *is* the folder. 1. Copy `templates/theme-plugin` and rename `id`, `name` and the theme files. 2. Edit the colours. Keep `kind` truthful (`light` for light backgrounds). 3. **Install:** *Settings › Plugins › ⚙ › Install Plugin from Disk…* (or the - *Install Plugin from Disk…* action) and pick the package folder. The - manifest and the files it references are copied to + *Install Plugin from Disk…* action) and pick the package's `plugin.toml`, or + a `.zip` of the folder. The manifest and the files it references are copied to `/plugins//`. 4. **Apply:** in *Settings › Plugins*, select the plugin and press *Apply* next to the theme. *Use Default Theme* switches back. diff --git a/src-tauri/src/commands/plugin.rs b/src-tauri/src/commands/plugin.rs index 0f94dc2a..6c91ad07 100644 --- a/src-tauri/src/commands/plugin.rs +++ b/src-tauri/src/commands/plugin.rs @@ -91,12 +91,12 @@ pub async fn plugin_set_enabled(state: State<'_, AppState>, id: String, enabled: blocking(move || host.set_enabled(&id, enabled)).await } -/// Installs the plugin package in `path` (a folder with `plugin.toml`). +/// Installs the plugin package at `path`: a folder, its `plugin.toml`, or a `.zip` of the folder. #[tauri::command] #[specta::specta] pub async fn plugin_install_dir(state: State<'_, AppState>, path: PathBuf) -> IdeResult { let host = state.plugins.clone(); - blocking(move || host.install_from_dir(&path)).await + blocking(move || host.install_from_path(&path)).await } /// Plugin packages available locally for installation: `/plugins` diff --git a/src/bindings.ts b/src/bindings.ts index 4f191324..6d71d1a3 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -853,7 +853,7 @@ export const commands = { */ pluginList: () => typedError(__TAURI_INVOKE("plugin_list")), pluginSetEnabled: (id: string, enabled: boolean) => typedError(__TAURI_INVOKE("plugin_set_enabled", { id, enabled })), - /** Installs the plugin package in `path` (a folder with `plugin.toml`). */ + /** Installs the plugin package at `path`: a folder, its `plugin.toml`, or a `.zip` of the folder. */ pluginInstallDir: (path: string) => typedError(__TAURI_INVOKE("plugin_install_dir", { path })), /** * Plugin packages available locally for installation: `/plugins` @@ -1688,7 +1688,7 @@ export type CatalogPlugin = { authors: string[], homepage: string | null, changeNotes: string | null, - /** Package directory; install with `install_from_dir(path)`. */ + /** Package directory; install with `install_from_path(path)`. */ path: IpcPath, hasCode: boolean, permissions: Permission[], diff --git a/src/features/plugins/backend.ts b/src/features/plugins/backend.ts index d3a4b131..4030550d 100644 --- a/src/features/plugins/backend.ts +++ b/src/features/plugins/backend.ts @@ -5,6 +5,7 @@ */ import { Channel } from "@tauri-apps/api/core"; import type { ActiveTheme, CatalogPlugin, PluginEvent, PluginInfo, PluginsSnapshot } from "@/bindings"; +import { tx } from "@/platform/i18n"; import { call, commands, IpcError, isTauri } from "@/platform/ipc"; export interface PluginsBackend { @@ -21,7 +22,8 @@ export interface PluginsBackend { /** The selected theme's resolved frame colour (`#rrggbb[aa]`), which new windows open in. */ setThemeFrame(color: string | null): Promise; /** Folder picker; `null` when cancelled. */ - pickFolder(): Promise; + /** A package to install: a `.zip`, or the `plugin.toml` of a folder. */ + pickPackage(): Promise; /** Subscribes to backend plugin events (this window only). */ onEvent(handler: (e: PluginEvent) => void): () => void; } @@ -45,9 +47,13 @@ export const tauriPluginsBackend: PluginsBackend = { setThemeFrame: async (color) => { await call(commands.pluginSetThemeFrame(color)); }, - pickFolder: async () => { + pickPackage: async () => { const { open } = await import("@tauri-apps/plugin-dialog"); - const picked = await open({ directory: true, multiple: false, title: "Install Plugin from Disk" }); + const picked = await open({ + multiple: false, + title: tx("Install Plugin from Disk"), + filters: [{ name: tx("Plugin package"), extensions: ["zip", "toml"] }], + }); return (Array.isArray(picked) ? picked[0] : picked) ?? null; }, onEvent: (handler) => { @@ -247,7 +253,7 @@ export function createMemoryPluginsBackend( return t; }, setThemeFrame: async () => {}, - pickFolder: async () => null, + pickPackage: async () => null, onEvent: (handler) => { listeners.add(handler); return () => void listeners.delete(handler); diff --git a/src/features/plugins/store.ts b/src/features/plugins/store.ts index 01913365..08ec18a6 100644 --- a/src/features/plugins/store.ts +++ b/src/features/plugins/store.ts @@ -124,25 +124,25 @@ export function compareVersions(a: string, b: string): number { return 0; } -/** Asks for a plugin folder (containing plugin.toml) and installs it. Returns the new plugin id. */ +/** Asks for a plugin package (a `.zip` or a `plugin.toml`) and installs it. Returns the new plugin id. */ export async function installFromDisk(path?: string): Promise { const backend = pluginsBackend(); - let dir: string | null = path ?? null; + let source: string | null = path ?? null; try { - dir ??= await backend.pickFolder(); + source ??= await backend.pickPackage(); } catch (e) { notifications().notify({ group: "plugins", level: "error", - title: "Could not open the folder picker", + title: "Could not open the file picker", body: errorText(e), }); return null; } - if (!dir) return null; + if (!source) return null; usePlugins.setState({ installing: true }); try { - const info = await backend.installDir(dir); + const info = await backend.installDir(source); notifications().notify({ group: "plugins", level: info.status === "failed" ? "warning" : "info", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index ed9b9131..048353eb 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -523,7 +523,8 @@ "Could not install plugin": "无法安装插件", "Could not invalidate caches": "无法清除缓存", "Could not open project": "无法打开项目", - "Could not open the folder picker": "无法打开文件夹选择器", + "Could not open the file picker": "无法打开文件选择器", + "Plugin package": "插件包", "Could not reach GitHub": "无法连接到 GitHub", "Could not save": "无法保存", "Coverage": "覆盖率",