fix(plugins): install plugin packages from a .zip (#896)

Root cause: Install Plugin from Disk only opened a folder picker and the host could only copy folders, so a zipped plugin could not be installed. The picker now accepts a .zip or a package's plugin.toml, and the host's install_from_path unpacks a zip to a temporary folder and runs it through the existing install_from_dir validation. The package may sit at the archive root or in its only folder (archiver metadata such as __MACOSX is ignored). Archives with unsafe paths or symbolic links, more than 10,000 entries or over 1 GB unpacked are refused, and a zip without plugin.toml (e.g. an IntelliJ plugin) gets a clear error.

Fixes #892

Co-authored-by: florentvdry <16673235+florentvdry@users.noreply.github.com>
This commit is contained in:
Florent V
2026-11-03 01:44:28 +08:00
committed by GitHub
co-authored by florentvdry
parent eb633f6ca6
commit db5251efe6
12 changed files with 208 additions and 21 deletions
Generated
+1
View File
@@ -4428,6 +4428,7 @@ dependencies = [
"tracing",
"wasmtime",
"wasmtime-wasi",
"zip",
]
[[package]]
+2 -3
View File
@@ -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
+15
View File
@@ -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<PluginInfo> {
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<PluginInfo> {
+74
View File
@@ -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<PathBuf> {
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<PathBuf> {
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)
}
+1 -1
View File
@@ -89,7 +89,7 @@ pub struct CatalogPlugin {
pub authors: Vec<String>,
pub homepage: Option<String>,
pub change_notes: Option<String>,
/// 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<Permission>,
+91
View File
@@ -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<std::fs::File>, 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());
}
+2 -2
View File
@@ -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
`<app-data>/plugins/<id>/`.
4. **Apply:** in *Settings › Plugins*, select the plugin and press *Apply* next
to the theme. *Use Default Theme* switches back.
+2 -2
View File
@@ -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<PluginInfo> {
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: `<resources>/plugins`
+2 -2
View File
@@ -853,7 +853,7 @@ export const commands = {
*/
pluginList: () => typedError<PluginsSnapshot, IdeError>(__TAURI_INVOKE("plugin_list")),
pluginSetEnabled: (id: string, enabled: boolean) => typedError<null, IdeError>(__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<PluginInfo, IdeError>(__TAURI_INVOKE("plugin_install_dir", { path })),
/**
* Plugin packages available locally for installation: `<resources>/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[],
+10 -4
View File
@@ -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<void>;
/** Folder picker; `null` when cancelled. */
pickFolder(): Promise<string | null>;
/** A package to install: a `.zip`, or the `plugin.toml` of a folder. */
pickPackage(): Promise<string | null>;
/** 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);
+6 -6
View File
@@ -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<string | null> {
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",
+2 -1
View File
@@ -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": "覆盖率",