From a7b7616da988a3d58205bbec38a06f3894b8be2a Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:42:42 +0800 Subject: [PATCH] feat(plugins): install packages from a .zip or a folder's plugin.toml Install Plugin from Package File now takes an `.oiplugin`, a `.zip` or a package folder's `plugin.toml`, and goes through the same install page (capabilities, signature, consent) as every other install. - A zip may hold the package at its root or in its only folder, as zipping the folder makes; archiver metadata (`__MACOSX`, `.DS_Store`, `._*`) is left out. A zip without a `plugin.toml` there, such as an IntelliJ plugin, is refused with a message saying so. Unsafe paths, links and size limits are checked as for `.oiplugin` files. - Picking a `plugin.toml` installs its folder. Co-authored-by: florentvdry <16673235+florentvdry@users.noreply.github.com> --- Cargo.lock | 1 + crates/ide-extension/src/package.rs | 97 ++++++++++++++++++- crates/ide-host/Cargo.toml | 1 + crates/ide-host/src/plugins/market.rs | 2 +- crates/ide-host/tests/plugins.rs | 47 +++++++++ docs/plugins/themes.md | 5 +- plugins/README.md | 4 +- src-tauri/src/commands/plugin.rs | 2 +- .../plugins/__tests__/plugins.test.tsx | 31 +++++- src/features/plugins/backend.ts | 9 +- src/features/plugins/store.ts | 22 +++-- 11 files changed, 200 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 974e0f901..5f086b53d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4280,6 +4280,7 @@ dependencies = [ "tracing-subscriber", "uuid", "windows-sys 0.61.2", + "zip", ] [[package]] diff --git a/crates/ide-extension/src/package.rs b/crates/ide-extension/src/package.rs index 5822a405e..b8d3988d2 100644 --- a/crates/ide-extension/src/package.rs +++ b/crates/ide-extension/src/package.rs @@ -141,7 +141,31 @@ fn safe_path(name: &str) -> Option { (!name.is_empty() && p.components().all(|c| matches!(c, Component::Normal(_)))).then(|| p.to_path_buf()) } -/// Extracts the package `archive` into the empty directory `dest`, refusing +/// Archiver metadata at the top of an archive, not part of a package +/// (`__MACOSX/…`, `.DS_Store`, AppleDouble `._…` files). +fn archiver_metadata(name: &str) -> bool { + let top = name.split('/').next().unwrap_or_default(); + top == "__MACOSX" || top == ".DS_Store" || (top.starts_with("._") && top.len() > 2) +} + +/// Where the package is inside an archive: at its root (`.oiplugin`, or a zip +/// of the package's contents) or in its only folder (a zip of the package +/// folder). `None` when neither holds a manifest. +fn package_prefix(names: &[String]) -> Option { + if names.iter().any(|n| n == MANIFEST_FILE) { + return Some(String::new()); + } + let mut tops = names.iter().filter(|n| !archiver_metadata(n)).map(|n| n.split('/').next().unwrap_or_default()); + let top = tops.next()?; + if tops.any(|t| t != top) { + return None; + } + let prefix = format!("{top}/"); + names.iter().any(|n| *n == format!("{prefix}{MANIFEST_FILE}")).then_some(prefix) +} + +/// Extracts the package `archive` (an `.oiplugin`, or a `.zip` of a package +/// folder or of its contents) into the empty directory `dest`, refusing /// paths leaving it, links, and oversized contents. pub fn unpack(archive: &Path, dest: &Path) -> Result { let file = std::fs::File::open(archive).map_err(|e| format!("{}: {e}", archive.display()))?; @@ -150,6 +174,17 @@ pub fn unpack(archive: &Path, dest: &Path) -> Result { if zip.len() > MAX_ENTRIES { return Err(format!("more than {MAX_ENTRIES} files")); } + let mut names = Vec::with_capacity(zip.len()); + for i in 0..zip.len() { + let entry = zip.by_index(i).map_err(|e| e.to_string())?; + if !entry.is_dir() { + names.push(entry.name().map_err(|e| e.to_string())?.into_owned()); + } + } + // An IntelliJ plugin or any other zip: say what is missing rather than fail later. + let prefix = package_prefix(&names).ok_or_else(|| { + format!("{} is not an OpenIDEA plugin: no {MANIFEST_FILE} at its root or in its only folder", archive.display()) + })?; std::fs::create_dir_all(dest).map_err(|e| e.to_string())?; let (mut files, mut signature, mut total, mut manifest) = (Vec::new(), None, 0u64, false); for i in 0..zip.len() { @@ -157,11 +192,16 @@ pub fn unpack(archive: &Path, dest: &Path) -> Result { if entry.is_dir() { continue; } - let name = entry.name().map_err(|e| e.to_string())?.into_owned(); - if entry.is_symlink() { - return Err(format!("{name}: links are not allowed in packages")); + let full = entry.name().map_err(|e| e.to_string())?.into_owned(); + // Outside the package folder there is only archiver metadata (`package_prefix` checked). + let Some(name) = full.strip_prefix(&prefix).map(str::to_owned) else { continue }; + if archiver_metadata(&name) { + continue; } - let rel = safe_path(&name).ok_or_else(|| format!("invalid path {name:?} in package"))?; + if entry.is_symlink() { + return Err(format!("{full}: links are not allowed in packages")); + } + let rel = safe_path(&name).ok_or_else(|| format!("invalid path {full:?} in package"))?; let mut bytes = Vec::new(); entry.by_ref().take(MAX_TOTAL - total + 1).read_to_end(&mut bytes).map_err(|e| e.to_string())?; total += bytes.len() as u64; @@ -375,4 +415,51 @@ mod tests { assert!(!tmp.path().join("outside.txt").exists()); assert!(Index::parse(r#"{"format": 2}"#).is_err()); } + + fn zip_of(path: &Path, entries: &[(&str, &str)]) { + let mut zip = zip::ZipWriter::new(std::fs::File::create(path).unwrap()); + for (name, body) in entries { + zip.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap(); + zip.write_all(body.as_bytes()).unwrap(); + } + zip.finish().unwrap(); + } + + #[test] + fn a_zip_holds_the_package_at_its_root_or_in_its_only_folder() { + let tmp = tempfile::tempdir().unwrap(); + let manifest = "id = \"acme.tools\""; + let at_root = tmp.path().join("root.zip"); + zip_of(&at_root, &[(MANIFEST_FILE, manifest), ("dist/plugin.wasm", "w"), (".DS_Store", "x")]); + let got = unpack(&at_root, &tmp.path().join("a")).unwrap(); + assert_eq!(got.files, [PathBuf::from("dist/plugin.wasm")]); + + // What zipping the folder in Finder makes: the folder plus `__MACOSX`. + let folder = tmp.path().join("folder.zip"); + zip_of( + &folder, + &[ + ("acme-tools/plugin.toml", manifest), + ("acme-tools/dist/plugin.wasm", "w"), + ("__MACOSX/acme-tools/._plugin.toml", "x"), + ], + ); + let dest = tmp.path().join("b"); + let got = unpack(&folder, &dest).unwrap(); + assert_eq!(got.files, [PathBuf::from("dist/plugin.wasm")]); + assert!(dest.join(MANIFEST_FILE).is_file()); + assert!(!dest.join("__MACOSX").exists() && !dest.join("acme-tools").exists()); + + // Two folders, a manifest one level too deep, or none (an IntelliJ plugin): not a package. + for (name, entries) in [ + ("two.zip", &[("a/plugin.toml", manifest), ("b/x.txt", "x")][..]), + ("deep.zip", &[("a/b/plugin.toml", manifest)][..]), + ("intellij.zip", &[("my-plugin/lib/my-plugin.jar", "jar")][..]), + ] { + let path = tmp.path().join(name); + zip_of(&path, entries); + let err = unpack(&path, &tmp.path().join(name).with_extension("out")).unwrap_err(); + assert!(err.contains("is not an OpenIDEA plugin"), "{name}: {err}"); + } + } } diff --git a/crates/ide-host/Cargo.toml b/crates/ide-host/Cargo.toml index 47095ccb2..e95aee036 100644 --- a/crates/ide-host/Cargo.toml +++ b/crates/ide-host/Cargo.toml @@ -62,6 +62,7 @@ tempfile.workspace = true ide-ssh = { workspace = true, features = ["testing"] } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "test-util"] } criterion = { version = "0.5", default-features = false } +zip.workspace = true [target.'cfg(unix)'.dependencies] rustix = { version = "1.1", features = ["fs", "process"] } diff --git a/crates/ide-host/src/plugins/market.rs b/crates/ide-host/src/plugins/market.rs index 771ac3597..b7b9c3dfd 100644 --- a/crates/ide-host/src/plugins/market.rs +++ b/crates/ide-host/src/plugins/market.rs @@ -152,7 +152,7 @@ impl PluginHost { Ok(dir) } - /// Unpacks a package file (`.oiplugin`) next to the downloads; returns the folder. + /// Unpacks a package file (`.oiplugin`, or a `.zip` of a package) next to the downloads; returns the folder. pub fn unpack_package(&self, archive: &Path) -> IdeResult { let stem = archive.file_stem().and_then(|s| s.to_str()).unwrap_or("package"); let dir = self.plugins_dir.join(DOWNLOADS).join(format!("{stem}-{}", std::process::id())); diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index 6ae5e832c..b39eb3b06 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -2116,3 +2116,50 @@ fn official_excalidraw_installs_and_serves_its_page() { assert!(f.host.languages().iter().any(|l| l.id.as_str() == "excalidraw")); assert_eq!(f.host.assets("openidea.excalidraw", "fileIcon/excalidraw/").unwrap().len(), 2); } + +/// Zips `dir` as an archiver does: the folder itself at the top, plus macOS metadata. +fn zip_folder(dir: &Path, out: &Path) { + use std::io::Write; + let name = dir.file_name().unwrap().to_string_lossy().into_owned(); + let mut zip = zip::ZipWriter::new(std::fs::File::create(out).unwrap()); + let options = zip::write::SimpleFileOptions::default(); + let mut stack = vec![(dir.to_path_buf(), name.clone())]; + while let Some((real, rel)) = stack.pop() { + for entry in std::fs::read_dir(&real).unwrap().flatten() { + let (path, rel) = (entry.path(), format!("{rel}/{}", entry.file_name().to_string_lossy())); + if path.is_dir() { + stack.push((path, rel)); + } else { + zip.start_file(rel, options).unwrap(); + zip.write_all(&std::fs::read(&path).unwrap()).unwrap(); + } + } + } + zip.start_file(format!("__MACOSX/{name}/._plugin.toml"), options).unwrap(); + zip.write_all(b"metadata").unwrap(); + zip.finish().unwrap(); +} + +#[test] +fn a_zipped_plugin_folder_installs_like_the_folder() { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + let archive = tmp.path().join("language-toy.zip"); + zip_folder(&sample("language-toy"), &archive); + + let dir = f.host.unpack_package(&archive).unwrap(); + let preview = f.host.preview_install(&dir).unwrap(); + assert_eq!(preview.id, "language-toy"); + assert!(preview.unsigned); + let info = f.host.install_from_dir(&dir, &Consent::all()).unwrap(); + assert_eq!(info.id, "language-toy"); + assert_eq!(f.host.languages().len(), 1); + + // A zip that isn't an OpenIDEA plugin says so. + let other = tmp.path().join("intellij-plugin.zip"); + let mut zip = zip::ZipWriter::new(std::fs::File::create(&other).unwrap()); + zip.start_file("my-plugin/lib/my-plugin.jar", zip::write::SimpleFileOptions::default()).unwrap(); + zip.finish().unwrap(); + let err = f.host.unpack_package(&other).unwrap_err().message; + assert!(err.contains("is not an OpenIDEA plugin"), "{err}"); +} diff --git a/docs/plugins/themes.md b/docs/plugins/themes.md index 013041bf5..090ab4af6 100644 --- a/docs/plugins/themes.md +++ b/docs/plugins/themes.md @@ -109,8 +109,9 @@ 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's `plugin.toml`, or - a `.zip` of the folder. The manifest and the files it references are copied to + *Install Plugin from Disk…* action) and pick the package folder, or + *Install Plugin from Package File…* and pick its `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/plugins/README.md b/plugins/README.md index 5138c268b..47cde2540 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -636,7 +636,9 @@ granted, and starts the approved program. ## 7. Installing and developing - **Install:** Settings › Plugins › _Install Plugin from Disk…_ (a package - folder) or _Install Plugin from Package File…_ (a `.oiplugin`). The install + folder) or _Install Plugin from Package File…_ (a `.oiplugin`, a `.zip` with + the package at its root or in its only folder, or a folder's `plugin.toml`; + a zip without a `plugin.toml` there, such as an IntelliJ plugin, is refused). The install page lists every capability and whether the package is signed by a publisher the marketplace trusts; nothing is copied until you accept. - **Versions:** installed plugins live in `/plugins///` diff --git a/src-tauri/src/commands/plugin.rs b/src-tauri/src/commands/plugin.rs index 1ad89dd10..4111567f5 100644 --- a/src-tauri/src/commands/plugin.rs +++ b/src-tauri/src/commands/plugin.rs @@ -498,7 +498,7 @@ pub async fn plugin_market_download(state: State<'_, AppState>, id: String) -> I blocking(move || host.download_market_plugin(&id)).await } -/// Unpacks a package file (`.oiplugin`); returns the folder for the install page. +/// Unpacks a package file (`.oiplugin` or `.zip`); returns the folder for the install page. #[tauri::command] #[specta::specta] pub async fn plugin_unpack_package(state: State<'_, AppState>, path: PathBuf) -> IdeResult { diff --git a/src/features/plugins/__tests__/plugins.test.tsx b/src/features/plugins/__tests__/plugins.test.tsx index 7dae1fc7e..fa1686657 100644 --- a/src/features/plugins/__tests__/plugins.test.tsx +++ b/src/features/plugins/__tests__/plugins.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { act, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { EditorSelection, EditorState } from "@codemirror/state"; @@ -272,6 +272,35 @@ describe("store", () => { expect(usePlugins.getState().activeTheme).toBeNull(); }); + it("installs from a folder, its plugin.toml, or a package file unpacked first", async () => { + setConsentPrompt(async () => ({ optional: [] })); + for (const [picked, unpacked] of [ + ["plugins/hello", null], + ["plugins/hello/plugin.toml", null], + ["C:\\plugins\\hello\\Plugin.TOML", null], + ["/downloads/hello.zip", "/downloads/hello.zip"], + ["/downloads/hello-1.0.0.oiplugin", "/downloads/hello-1.0.0.oiplugin"], + ] as const) { + const unpack = vi.fn(async () => "plugins/hello"); + const preview = vi.fn(); + const backend = createMemoryPluginsBackend([], sampleCatalog()); + setPluginsBackend({ + ...backend, + unpackPackage: unpack, + previewInstall: (path) => { + preview(path); + return backend.previewInstall("plugins/hello"); + }, + installDir: (_path, consent) => backend.installDir("plugins/hello", consent), + }); + expect(await installFromDisk(picked)).toBe("hello"); + expect(unpack.mock.calls).toEqual(unpacked ? [[unpacked]] : []); + const folder = unpacked ? "plugins/hello" : picked.replace(/[\\/]plugin\.toml$/i, ""); + expect(preview).toHaveBeenCalledWith(folder); + usePlugins.setState({ plugins: [] }); + } + }); + it("asks for consent before installing and passes the answer on", async () => { const asked: ConsentRequest[] = []; setConsentPrompt(async (req) => { diff --git a/src/features/plugins/backend.ts b/src/features/plugins/backend.ts index 7119c7146..45d8f2cae 100644 --- a/src/features/plugins/backend.ts +++ b/src/features/plugins/backend.ts @@ -24,6 +24,7 @@ import type { Viewport, WorkbenchEvent, } from "@/bindings"; +import { tx } from "@/platform/i18n"; import { call, commands, IpcError, isTauri } from "@/platform/ipc"; export interface PluginsBackend { @@ -63,11 +64,11 @@ export interface PluginsBackend { setMarketUrl(url: string | null): Promise; /** Downloads and checks a marketplace plugin; the folder to install from. */ marketDownload(id: string): Promise; - /** Unpacks a `.oiplugin` package file; the folder to install from. */ + /** Unpacks a package file (`.oiplugin` or `.zip`); the folder to install from. */ unpackPackage(path: string): Promise; /** Goes back to the version the last update replaced. */ rollBack(id: string): Promise; - /** File picker for a package file; `null` when cancelled. */ + /** File picker for a package: an `.oiplugin`, a `.zip` or a folder's `plugin.toml`; `null` when cancelled. */ pickPackage(): Promise; /** Content of a plugin view (`doc`: the document of a custom editor). */ renderView(pluginId: string, view: string, viewport: Viewport | null, doc: number | null): Promise; @@ -159,8 +160,8 @@ export const tauriPluginsBackend: PluginsBackend = { const { open } = await import("@tauri-apps/plugin-dialog"); const picked = await open({ multiple: false, - title: "Install Plugin from Package File", - filters: [{ name: "OpenIDEA plugin", extensions: ["oiplugin"] }], + title: tx("Install Plugin from Package File"), + filters: [{ name: tx("Plugin package"), extensions: ["oiplugin", "zip", "toml"] }], }); return (Array.isArray(picked) ? picked[0] : picked) ?? null; }, diff --git a/src/features/plugins/store.ts b/src/features/plugins/store.ts index 9d3dd0c63..7e0ec9961 100644 --- a/src/features/plugins/store.ts +++ b/src/features/plugins/store.ts @@ -13,7 +13,7 @@ import type { } from "@/bindings"; import { activeEditor } from "@/editor/api"; import { notifications } from "@/workbench/contributions"; -import { pluginsBackend } from "./backend"; +import { pluginsBackend, type PluginsBackend } from "./backend"; export const SETTINGS_PAGE_ID = "plugins"; @@ -256,22 +256,32 @@ export function compareVersions(a: string, b: string): number { return 0; } +/** The folder to install from for what the user picked; `.oiplugin` / `.zip` files are unpacked. */ +async function packageFolder(backend: PluginsBackend, picked: string): Promise { + const lower = picked.toLowerCase(); + if (lower.endsWith(".oiplugin") || lower.endsWith(".zip")) return backend.unpackPackage(picked); + // A folder's manifest stands for the folder. + const manifest = /[\\/]plugin\.toml$/i.exec(picked); + return manifest ? picked.slice(0, manifest.index) : picked; +} + /** - * Asks for a plugin folder (containing plugin.toml), shows what it may do, - * and installs it with the user's consent. Returns the new plugin id. + * Asks for a plugin (a folder, its `plugin.toml`, or an `.oiplugin` / `.zip` + * package file), shows what it may do, and installs it with the user's + * consent. Returns the new plugin id. */ export async function installFromDisk(path?: string, packageFile = false): Promise { const backend = pluginsBackend(); let dir: string | null = path ?? null; try { dir ??= packageFile ? await backend.pickPackage() : await backend.pickFolder(); - // A package file (`.oiplugin`) is unpacked first; signed ones are checked on the install page. - if (dir?.endsWith(".oiplugin")) dir = await backend.unpackPackage(dir); + // Package files are unpacked first; signed ones are checked on the install page. + if (dir) dir = await packageFolder(backend, dir); } catch (e) { notifications().notify({ group: "plugins", level: "error", - title: packageFile ? "Could not open the package" : "Could not open the folder picker", + title: packageFile ? "Could not open the package" : "Could not open the file picker", body: errorText(e), }); return null;