mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
Generated
+1
@@ -4428,6 +4428,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"wasmtime",
|
||||
"wasmtime-wasi",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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,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> {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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[],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "覆盖率",
|
||||
|
||||
Reference in New Issue
Block a user