Files
IdeA/crates/app-tauri/src/plugins.rs
Blomios e2da2d911e feat(plugins): load activation scope from plugin manifest
Plugins can now declare activationScope ("app" | "project") in their
manifest; loader/runtime honor it to defer activation of project-scoped
plugins until a project is focused instead of activating everything at
app bootstrap. Bumps sdk/IdeaSDK to the commit that adds the field.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 14:44:08 +02:00

1081 lines
35 KiB
Rust

//! Plugin Tauri commands and asset protocol.
use std::future::Future;
use std::path::{Path, PathBuf};
use application::{ReviewPluginPackageInput, SetPluginEnabledInput, UninstallPluginInput};
use backend::dto::{
ErrorDto, PluginAdminDto, PluginConfigDocumentDto, PluginConfigDocumentReadDto,
PluginConfigDocumentUpdateDto, PluginConfigDocumentWriteResultDto, PluginEventBatchDto,
PluginEventPollDto, PluginEventSubscribeDto, PluginEventSubscriptionDto,
PluginEventUnsubscribeDto, PluginInstallResultDto, PluginProjectStructureDto,
PluginProjectStructureQueryDto, PluginReviewDto, PluginRunCommandDto,
PluginRuntimeContributionCatalogDto, PluginStorageGetDto, PluginStorageSetDto, PluginTaskDto,
PluginTaskStatusDto, PluginToolchainDiagnosticDto, PluginToolchainDiagnosticRequestDto,
PluginUninstallResultDto, PluginWorkspaceBinaryFileDto, PluginWorkspaceDirectoryListingDto,
PluginWorkspacePathDto, PluginWorkspaceStatDto, PluginWorkspaceTextFileDto,
PluginWorkspaceWriteBinaryDto, PluginWorkspaceWriteTextDto, ReviewPluginPackageDto,
};
use domain::ports::{PluginManifestValidator, PluginPackageStore, PluginRegistryStore};
use domain::{PluginId, RelativePath};
use http::{header, Response, StatusCode};
use tauri::{AppHandle, Manager, State};
use crate::state::AppState;
const PLUGIN_ASSET_CORS_ORIGIN: &str = "*";
const PLUGIN_ASSET_CORS_METHODS: &str = "GET";
/// Lists installed plugins for the admin surface.
#[tauri::command]
pub async fn plugin_list_plugins(
state: State<'_, AppState>,
) -> Result<Vec<PluginAdminDto>, ErrorDto> {
state
.list_plugins
.execute()
.await
.map(|plugins| plugins.into_iter().map(PluginAdminDto::from).collect())
.map_err(ErrorDto::from)
}
/// Reviews a local plugin package without committing it.
#[tauri::command]
pub async fn plugin_review_package(
input: ReviewPluginPackageDto,
state: State<'_, AppState>,
) -> Result<PluginReviewDto, ErrorDto> {
let input = match input.source_kind.as_str() {
"archive" => ReviewPluginPackageInput::Archive { path: input.path },
"directory" => ReviewPluginPackageInput::Directory { path: input.path },
other => {
return Err(ErrorDto::invalid(format!(
"invalid plugin source kind: {other}"
)))
}
};
state
.review_plugin_package
.execute(input)
.await
.map(PluginReviewDto::from)
.map_err(ErrorDto::from)
}
/// Installs a plugin from a local archive.
#[tauri::command]
pub async fn plugin_install_from_archive(
path: String,
state: State<'_, AppState>,
) -> Result<PluginInstallResultDto, ErrorDto> {
application::diag!("[plugins] install archive start path={path}");
let result = state
.install_plugin_from_archive
.execute(path)
.await
.map(PluginInstallResultDto::from)
.map_err(ErrorDto::from);
match &result {
Ok(result) => application::diag!(
"[plugins] install archive ok plugin={} version={} hash={} lifecycle={:?}",
result.plugin.id,
result.plugin.version,
result.review.content_hash,
result.plugin.lifecycle_state
),
Err(err) => application::diag!(
"[plugins] install archive failed code={} message={}",
err.code,
err.message
),
}
result
}
/// Installs a plugin from a local directory snapshot.
#[tauri::command]
pub async fn plugin_install_from_directory(
path: String,
state: State<'_, AppState>,
) -> Result<PluginInstallResultDto, ErrorDto> {
application::diag!("[plugins] install directory start path={path}");
let result = state
.install_plugin_from_directory
.execute(path)
.await
.map(PluginInstallResultDto::from)
.map_err(ErrorDto::from);
match &result {
Ok(result) => application::diag!(
"[plugins] install directory ok plugin={} version={} hash={} lifecycle={:?}",
result.plugin.id,
result.plugin.version,
result.review.content_hash,
result.plugin.lifecycle_state
),
Err(err) => application::diag!(
"[plugins] install directory failed code={} message={}",
err.code,
err.message
),
}
result
}
/// Enables or disables a plugin.
#[tauri::command]
pub async fn plugin_set_enabled(
plugin_id: String,
enabled: bool,
state: State<'_, AppState>,
) -> Result<PluginAdminDto, ErrorDto> {
state
.set_plugin_enabled
.execute(SetPluginEnabledInput { plugin_id, enabled })
.await
.map(PluginAdminDto::from)
.map_err(ErrorDto::from)
}
/// Uninstalls a plugin.
#[tauri::command]
pub async fn plugin_uninstall(
plugin_id: String,
state: State<'_, AppState>,
) -> Result<PluginUninstallResultDto, ErrorDto> {
state
.uninstall_plugin
.execute(UninstallPluginInput { plugin_id })
.await
.map(PluginUninstallResultDto::from)
.map_err(ErrorDto::from)
}
/// Lists active runtime contributions for frontend bootstrap.
#[tauri::command]
pub async fn plugin_list_runtime_contributions(
state: State<'_, AppState>,
) -> Result<PluginRuntimeContributionCatalogDto, ErrorDto> {
state
.list_plugin_runtime_contributions
.execute()
.await
.map(PluginRuntimeContributionCatalogDto::from)
.map_err(ErrorDto::from)
}
/// Reads a UTF-8 workspace file for the public plugin API.
#[tauri::command]
pub async fn plugin_workspace_read_text(
input: PluginWorkspacePathDto,
state: State<'_, AppState>,
) -> Result<PluginWorkspaceTextFileDto, ErrorDto> {
state
.plugin_workspace_access
.read_text(input.into())
.await
.map_err(ErrorDto::from)
}
/// Reads a binary workspace file for the public plugin API.
#[tauri::command]
pub async fn plugin_workspace_read_binary(
input: PluginWorkspacePathDto,
state: State<'_, AppState>,
) -> Result<PluginWorkspaceBinaryFileDto, ErrorDto> {
state
.plugin_workspace_access
.read_binary(input.into())
.await
.map_err(ErrorDto::from)
}
/// Writes a UTF-8 workspace file for the public plugin API.
#[tauri::command]
pub async fn plugin_workspace_write_text(
input: PluginWorkspaceWriteTextDto,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
state
.plugin_workspace_access
.write_text(input.into())
.await
.map_err(ErrorDto::from)
}
/// Writes a binary workspace file for the public plugin API.
#[tauri::command]
pub async fn plugin_workspace_write_binary(
input: PluginWorkspaceWriteBinaryDto,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
state
.plugin_workspace_access
.write_binary(input.into())
.await
.map_err(ErrorDto::from)
}
/// Reads a plugin-owned JSON storage value.
#[tauri::command]
pub async fn plugin_storage_get(
input: PluginStorageGetDto,
state: State<'_, AppState>,
) -> Result<Option<serde_json::Value>, ErrorDto> {
state
.plugin_storage_access
.get(input.into())
.await
.map_err(ErrorDto::from)
}
/// Writes a plugin-owned JSON storage value.
#[tauri::command]
pub async fn plugin_storage_set(
input: PluginStorageSetDto,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
state
.plugin_storage_access
.set(input.into())
.await
.map_err(ErrorDto::from)
}
/// Deletes a plugin-owned JSON storage value.
#[tauri::command]
pub async fn plugin_storage_delete(
input: PluginStorageGetDto,
state: State<'_, AppState>,
) -> Result<bool, ErrorDto> {
state
.plugin_storage_access
.delete(input.into())
.await
.map_err(ErrorDto::from)
}
/// Lists a workspace directory for the public plugin API.
#[tauri::command]
pub async fn plugin_workspace_list_dir(
input: PluginWorkspacePathDto,
state: State<'_, AppState>,
) -> Result<PluginWorkspaceDirectoryListingDto, ErrorDto> {
state
.plugin_workspace_access
.list_dir(input.into())
.await
.map_err(ErrorDto::from)
}
/// Stats a workspace path for the public plugin API.
#[tauri::command]
pub async fn plugin_workspace_stat(
input: PluginWorkspacePathDto,
state: State<'_, AppState>,
) -> Result<PluginWorkspaceStatDto, ErrorDto> {
state
.plugin_workspace_access
.stat(input.into())
.await
.map_err(ErrorDto::from)
}
/// Queries a bounded generic project structure for the public plugin API.
#[tauri::command]
pub async fn plugin_query_project_structure(
input: PluginProjectStructureQueryDto,
state: State<'_, AppState>,
) -> Result<PluginProjectStructureDto, ErrorDto> {
state
.query_project_structure
.execute(input.into())
.await
.map_err(ErrorDto::from)
}
/// Reads a structured configuration document for the public plugin API.
#[tauri::command]
pub async fn plugin_config_read_document(
input: PluginConfigDocumentReadDto,
state: State<'_, AppState>,
) -> Result<PluginConfigDocumentDto, ErrorDto> {
state
.plugin_config_documents
.read(input.into())
.await
.map_err(ErrorDto::from)
}
/// Updates a structured configuration document for the public plugin API.
#[tauri::command]
pub async fn plugin_config_update_document(
input: PluginConfigDocumentUpdateDto,
state: State<'_, AppState>,
) -> Result<PluginConfigDocumentWriteResultDto, ErrorDto> {
state
.plugin_config_documents
.update(input.into())
.await
.map_err(ErrorDto::from)
}
/// Launches a command-backed background task for the public plugin API.
#[tauri::command]
pub async fn plugin_task_run_command(
input: PluginRunCommandDto,
state: State<'_, AppState>,
) -> Result<PluginTaskDto, ErrorDto> {
state
.plugin_command_tasks
.run_command(input.into())
.await
.map(PluginTaskDto::from)
.map_err(ErrorDto::from)
}
/// Reads one command task status for the public plugin API.
#[tauri::command]
pub async fn plugin_task_get_status(
input: PluginTaskStatusDto,
state: State<'_, AppState>,
) -> Result<Option<PluginTaskDto>, ErrorDto> {
state
.plugin_command_tasks
.get_status(input.into())
.await
.map(|task| task.map(PluginTaskDto::from))
.map_err(ErrorDto::from)
}
/// Diagnoses generic external toolchain prerequisites for the public plugin API.
#[tauri::command]
pub async fn plugin_toolchain_diagnose(
input: PluginToolchainDiagnosticRequestDto,
state: State<'_, AppState>,
) -> Result<PluginToolchainDiagnosticDto, ErrorDto> {
state
.plugin_toolchain_diagnostics
.diagnose(input.into())
.await
.map_err(ErrorDto::from)
}
/// Subscribes to stable public plugin events.
#[tauri::command]
pub async fn plugin_events_subscribe(
input: PluginEventSubscribeDto,
state: State<'_, AppState>,
) -> Result<PluginEventSubscriptionDto, ErrorDto> {
state
.plugin_event_subscriptions
.subscribe(input.into())
.await
.map_err(ErrorDto::from)
}
/// Drains retained public plugin events for one subscription.
#[tauri::command]
pub fn plugin_events_poll(
input: PluginEventPollDto,
state: State<'_, AppState>,
) -> Result<PluginEventBatchDto, ErrorDto> {
state
.plugin_event_subscriptions
.poll(input.into())
.map_err(ErrorDto::from)
}
/// Disposes a public plugin event subscription.
#[tauri::command]
pub fn plugin_events_unsubscribe(
input: PluginEventUnsubscribeDto,
state: State<'_, AppState>,
) -> PluginEventSubscriptionDto {
state.plugin_event_subscriptions.unsubscribe(input.into())
}
/// Opens the plugin store folder, or one plugin folder when an id is provided.
#[tauri::command]
pub fn plugin_open_plugins_folder(
plugin_id: Option<String>,
app: AppHandle,
) -> Result<(), ErrorDto> {
let mut path = app
.path()
.app_data_dir()
.map_err(|e| ErrorDto::invalid(e.to_string()))?
.join("plugins");
if let Some(id) = plugin_id {
let id = PluginId::new(id).map_err(|e| ErrorDto::invalid(e.to_string()))?;
path = path.join("installed").join(id.as_str());
}
open_folder(&path)
}
/// Serves `idea-plugin://<pluginId>/<version>/<contentHash>/<relativePath>` after
/// checking registry state, hash and root confinement.
pub fn plugin_asset_protocol(
app: &AppHandle,
request: http::Request<Vec<u8>>,
) -> Response<Vec<u8>> {
let uri = request.uri().to_string();
match plugin_asset_response(app, request) {
Ok(response) => {
application::diag!(
"[plugins] asset ok uri={} status={} bytes={}",
uri,
response.status(),
response.body().len()
);
response
}
Err((status, message)) => {
application::diag!(
"[plugins] asset failed uri={uri} status={status} message={message}"
);
plugin_asset_response_builder(status)
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(message.into_bytes())
.unwrap_or_else(|e| {
application::diag!("[plugins] asset error response build failed: {e}");
Response::new(Vec::new())
})
}
}
}
fn plugin_asset_response(
app: &AppHandle,
request: http::Request<Vec<u8>>,
) -> Result<Response<Vec<u8>>, (StatusCode, String)> {
let state = app.state::<AppState>();
let app_data = app
.path()
.app_data_dir()
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
plugin_asset_response_with_stores(
&app_data,
request,
state.plugin_registry_store.as_ref(),
state.plugin_package_store.as_ref(),
state.plugin_manifest_validator.as_ref(),
)
}
fn plugin_asset_response_with_stores(
app_data: &Path,
request: http::Request<Vec<u8>>,
registry_store: &dyn PluginRegistryStore,
package_store: &dyn PluginPackageStore,
validator: &dyn PluginManifestValidator,
) -> Result<Response<Vec<u8>>, (StatusCode, String)> {
let uri = request.uri();
let plugin_id = uri
.host()
.ok_or_else(|| (StatusCode::BAD_REQUEST, "missing plugin id".to_owned()))
.and_then(|raw| {
PluginId::new(raw.to_owned()).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))
})?;
let mut parts = uri.path().trim_start_matches('/').splitn(3, '/');
let version_segment = parts.next().unwrap_or_default();
let hash = parts.next().unwrap_or_default();
let rel = parts.next().unwrap_or_default();
if version_segment.is_empty() || hash.is_empty() {
return Err((
StatusCode::BAD_REQUEST,
"invalid plugin asset URL".to_owned(),
));
}
let rel =
RelativePath::new(rel.to_owned()).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
let allowed = block_on_protocol_future(asset_allowed(
&plugin_id,
hash,
&rel,
registry_store,
package_store,
validator,
))?;
if !allowed {
return Err((
StatusCode::FORBIDDEN,
"plugin asset is not active".to_owned(),
));
}
let root = app_data
.join("plugins")
.join("installed")
.join(plugin_id.as_str());
let target = root.join(rel.as_str());
let root = root
.canonicalize()
.map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
let target = target
.canonicalize()
.map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
if !target.starts_with(&root) {
return Err((
StatusCode::FORBIDDEN,
"asset escapes plugin root".to_owned(),
));
}
let bytes = std::fs::read(&target).map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
plugin_asset_response_builder(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type(&target))
.body(bytes)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
}
fn plugin_asset_response_builder(status: StatusCode) -> http::response::Builder {
Response::builder()
.status(status)
.header(
header::ACCESS_CONTROL_ALLOW_ORIGIN,
PLUGIN_ASSET_CORS_ORIGIN,
)
.header(
header::ACCESS_CONTROL_ALLOW_METHODS,
PLUGIN_ASSET_CORS_METHODS,
)
}
async fn asset_allowed(
plugin_id: &PluginId,
hash: &str,
_rel: &RelativePath,
registry_store: &dyn PluginRegistryStore,
package_store: &dyn PluginPackageStore,
validator: &dyn PluginManifestValidator,
) -> Result<bool, (StatusCode, String)> {
let registry = registry_store
.load_registry()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let Some(entry) = registry.find(plugin_id) else {
return Ok(false);
};
if !entry.lifecycle_state.is_runtime_active() || entry.content_hash.as_str() != hash {
return Ok(false);
}
let package = domain::PluginPackageRef {
plugin_id: Some(plugin_id.clone()),
root: plugin_id.as_str().to_owned(),
};
let manifest_bytes = package_store
.read_manifest(&package)
.await
.map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
validator
.validate(&manifest_bytes.bytes, &package)
.map_err(|e| (StatusCode::FORBIDDEN, e.to_string()))?;
Ok(true)
}
fn block_on_protocol_future<F: Future>(future: F) -> F::Output {
match tokio::runtime::Handle::try_current() {
Ok(handle) => tokio::task::block_in_place(|| handle.block_on(future)),
Err(_) => tauri::async_runtime::block_on(future),
}
}
fn content_type(path: &Path) -> &'static str {
match path
.extension()
.and_then(|e| e.to_str())
.unwrap_or_default()
{
"js" | "mjs" => "text/javascript; charset=utf-8",
"json" => "application/json; charset=utf-8",
"svg" => "image/svg+xml",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"webp" => "image/webp",
"css" => "text/css; charset=utf-8",
_ => "application/octet-stream",
}
}
fn open_folder(path: &PathBuf) -> Result<(), ErrorDto> {
let mut command = if cfg!(target_os = "macos") {
let mut c = std::process::Command::new("open");
c.arg(path);
c
} else if cfg!(target_os = "windows") {
let mut c = std::process::Command::new("cmd");
c.args(["/C", "start", ""]).arg(path);
c
} else {
let mut c = std::process::Command::new("xdg-open");
c.arg(path);
c
};
command
.spawn()
.map(|_| ())
.map_err(|e| ErrorDto::invalid(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::{
asset_allowed, block_on_protocol_future, plugin_asset_response_with_stores,
PLUGIN_ASSET_CORS_METHODS, PLUGIN_ASSET_CORS_ORIGIN,
};
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use async_trait::async_trait;
use domain::ports::{
PluginManifestBytes, PluginManifestError, PluginPackageStore, PluginRegistryError,
PluginRegistryStore, PluginStoreError,
};
use domain::{
ContentHash, LocalPath, PluginContributionSet, PluginId, PluginInstallSource,
PluginLifecycleState, PluginManifest, PluginRegistry, PluginRegistryEntry,
PluginTrustLevel, PluginVersion, RelativePath, RemovalOutcome, StagedPluginPackage,
};
use http::{header, StatusCode};
struct FakeRegistry {
registry: Mutex<PluginRegistry>,
}
#[async_trait]
impl PluginRegistryStore for FakeRegistry {
async fn load_registry(&self) -> Result<PluginRegistry, PluginRegistryError> {
Ok(self.registry.lock().unwrap().clone())
}
async fn save_registry(
&self,
registry: &PluginRegistry,
) -> Result<(), PluginRegistryError> {
*self.registry.lock().unwrap() = registry.clone();
Ok(())
}
}
struct FakePackages {
manifest: Vec<u8>,
}
#[async_trait]
impl PluginPackageStore for FakePackages {
async fn list_installed(&self) -> Result<Vec<domain::PluginPackageRef>, PluginStoreError> {
Ok(Vec::new())
}
async fn read_manifest(
&self,
_package: &domain::PluginPackageRef,
) -> Result<PluginManifestBytes, PluginStoreError> {
Ok(PluginManifestBytes {
bytes: self.manifest.clone(),
})
}
async fn install_from_archive(
&self,
_archive: &LocalPath,
) -> Result<StagedPluginPackage, PluginStoreError> {
Err(PluginStoreError::Invalid("not used".to_owned()))
}
async fn install_from_directory(
&self,
_dir: &LocalPath,
) -> Result<StagedPluginPackage, PluginStoreError> {
Err(PluginStoreError::Invalid("not used".to_owned()))
}
async fn commit_install(
&self,
_staged: StagedPluginPackage,
_plugin_id: &PluginId,
) -> Result<domain::PluginPackageRef, PluginStoreError> {
Err(PluginStoreError::Invalid("not used".to_owned()))
}
async fn remove_package(
&self,
_plugin_id: &PluginId,
) -> Result<RemovalOutcome, PluginStoreError> {
Ok(RemovalOutcome::NotFound)
}
fn bundle_url(
&self,
plugin_id: &PluginId,
entry: &RelativePath,
hash: &ContentHash,
) -> Result<domain::PluginBundleUrl, PluginStoreError> {
Ok(domain::PluginBundleUrl::new(format!(
"idea-plugin://{}/current/{}/{}",
plugin_id.as_str(),
hash.as_str(),
entry.as_str()
)))
}
}
struct RejectingValidator;
impl domain::ports::PluginManifestValidator for RejectingValidator {
fn validate(
&self,
_bytes: &[u8],
_package: &domain::PluginPackageRef,
) -> Result<domain::PluginManifest, PluginManifestError> {
Err(PluginManifestError::Invalid("broken manifest".to_owned()))
}
}
struct AcceptingValidator {
plugin_id: PluginId,
main: RelativePath,
}
impl domain::ports::PluginManifestValidator for AcceptingValidator {
fn validate(
&self,
_bytes: &[u8],
_package: &domain::PluginPackageRef,
) -> Result<PluginManifest, PluginManifestError> {
Ok(PluginManifest {
idea_plugin_manifest_version: 1,
id: self.plugin_id.clone(),
display_name: "Git Graph".to_owned(),
publisher: None,
version: PluginVersion::new("1.0.0").unwrap(),
description: None,
engine_idea: None,
main: self.main.clone(),
icon: None,
trust_level: PluginTrustLevel::Full,
capabilities: Vec::new(),
activation_scope: domain::PluginActivationScope::default(),
contributes: PluginContributionSet::default(),
})
}
}
#[tokio::test(flavor = "multi_thread")]
async fn protocol_future_can_be_waited_inside_tauri_runtime() {
let value = block_on_protocol_future(async { 42 });
assert_eq!(value, 42);
}
#[tokio::test]
async fn asset_allowed_confines_invalid_active_manifest_to_forbidden() {
let plugin_id = PluginId::new("dev.acme.gitgraph").unwrap();
let hash = ContentHash::new("abc123").unwrap();
let registry = FakeRegistry {
registry: Mutex::new(PluginRegistry {
version: 1,
plugins: vec![PluginRegistryEntry {
id: plugin_id.clone(),
lifecycle_state: PluginLifecycleState::Enabled,
source: PluginInstallSource::Directory {
path_label: "/source/plugin".to_owned(),
},
content_hash: hash.clone(),
restart_required: false,
error: None,
}],
}),
};
let packages = FakePackages {
manifest: br#"{"broken":true}"#.to_vec(),
};
let rel = RelativePath::new("dist/index.js").unwrap();
let err = asset_allowed(
&plugin_id,
hash.as_str(),
&rel,
&registry,
&packages,
&RejectingValidator,
)
.await
.unwrap_err();
assert_eq!(err.0, StatusCode::FORBIDDEN);
assert!(err.1.contains("broken manifest"));
}
#[test]
fn plugin_asset_response_serves_confined_file_not_declared_in_manifest() {
let plugin_id = PluginId::new("dev.acme.gitgraph").unwrap();
let hash = ContentHash::new("abc123").unwrap();
let registry = FakeRegistry {
registry: Mutex::new(PluginRegistry {
version: 1,
plugins: vec![PluginRegistryEntry {
id: plugin_id.clone(),
lifecycle_state: PluginLifecycleState::Enabled,
source: PluginInstallSource::Directory {
path_label: "/source/plugin".to_owned(),
},
content_hash: hash.clone(),
restart_required: false,
error: None,
}],
}),
};
let packages = FakePackages {
manifest: br#"{"ideaPluginManifestVersion":1}"#.to_vec(),
};
let validator = AcceptingValidator {
plugin_id: plugin_id.clone(),
main: RelativePath::new("dist/index.js").unwrap(),
};
let app_data = test_app_data_dir("plugin-asset-undeclared");
let rel = RelativePath::new("dist/constants.js").unwrap();
let target = app_data
.join("plugins")
.join("installed")
.join(plugin_id.as_str())
.join(rel.as_str());
std::fs::create_dir_all(target.parent().unwrap()).unwrap();
std::fs::write(&target, "export const answer = 42;").unwrap();
let request = http::Request::builder()
.uri(format!(
"idea-plugin://{}/current/{}/{}",
plugin_id.as_str(),
hash.as_str(),
rel.as_str()
))
.body(Vec::new())
.unwrap();
let response =
plugin_asset_response_with_stores(&app_data, request, &registry, &packages, &validator)
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.body(), b"export const answer = 42;");
std::fs::remove_dir_all(app_data).ok();
}
#[test]
fn plugin_asset_response_rejects_invalid_hash_or_inactive_plugin() {
let plugin_id = PluginId::new("dev.acme.gitgraph").unwrap();
let hash = ContentHash::new("abc123").unwrap();
let rel = RelativePath::new("dist/constants.js").unwrap();
let registry = FakeRegistry {
registry: Mutex::new(PluginRegistry {
version: 1,
plugins: vec![PluginRegistryEntry {
id: plugin_id.clone(),
lifecycle_state: PluginLifecycleState::Enabled,
source: PluginInstallSource::Directory {
path_label: "/source/plugin".to_owned(),
},
content_hash: hash.clone(),
restart_required: false,
error: None,
}],
}),
};
let packages = FakePackages {
manifest: br#"{"ideaPluginManifestVersion":1}"#.to_vec(),
};
let validator = AcceptingValidator {
plugin_id: plugin_id.clone(),
main: RelativePath::new("dist/index.js").unwrap(),
};
let app_data = test_app_data_dir("plugin-asset-rejected");
let target = app_data
.join("plugins")
.join("installed")
.join(plugin_id.as_str())
.join(rel.as_str());
std::fs::create_dir_all(target.parent().unwrap()).unwrap();
std::fs::write(&target, "export const answer = 42;").unwrap();
let invalid_hash_request = http::Request::builder()
.uri(format!(
"idea-plugin://{}/current/deadbeef/{}",
plugin_id.as_str(),
rel.as_str()
))
.body(Vec::new())
.unwrap();
let invalid_hash_err = plugin_asset_response_with_stores(
&app_data,
invalid_hash_request,
&registry,
&packages,
&validator,
)
.unwrap_err();
assert_eq!(invalid_hash_err.0, StatusCode::FORBIDDEN);
registry.registry.lock().unwrap().plugins[0].lifecycle_state =
PluginLifecycleState::Disabled;
let inactive_request = http::Request::builder()
.uri(format!(
"idea-plugin://{}/current/{}/{}",
plugin_id.as_str(),
hash.as_str(),
rel.as_str()
))
.body(Vec::new())
.unwrap();
let inactive_err = plugin_asset_response_with_stores(
&app_data,
inactive_request,
&registry,
&packages,
&validator,
)
.unwrap_err();
assert_eq!(inactive_err.0, StatusCode::FORBIDDEN);
std::fs::remove_dir_all(app_data).ok();
}
#[test]
fn plugin_asset_response_rejects_symlink_path_traversal() {
let plugin_id = PluginId::new("dev.acme.gitgraph").unwrap();
let hash = ContentHash::new("abc123").unwrap();
let rel = RelativePath::new("assets/leak.txt").unwrap();
let registry = FakeRegistry {
registry: Mutex::new(PluginRegistry {
version: 1,
plugins: vec![PluginRegistryEntry {
id: plugin_id.clone(),
lifecycle_state: PluginLifecycleState::Enabled,
source: PluginInstallSource::Directory {
path_label: "/source/plugin".to_owned(),
},
content_hash: hash.clone(),
restart_required: false,
error: None,
}],
}),
};
let packages = FakePackages {
manifest: br#"{"ideaPluginManifestVersion":1}"#.to_vec(),
};
let validator = AcceptingValidator {
plugin_id: plugin_id.clone(),
main: RelativePath::new("dist/index.js").unwrap(),
};
let app_data = test_app_data_dir("plugin-asset-traversal");
let plugin_root = app_data
.join("plugins")
.join("installed")
.join(plugin_id.as_str());
let assets = plugin_root.join("assets");
std::fs::create_dir_all(&assets).unwrap();
let outside = app_data.join("outside.txt");
std::fs::write(&outside, "secret").unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(&outside, assets.join("leak.txt")).unwrap();
#[cfg(windows)]
std::os::windows::fs::symlink_file(&outside, assets.join("leak.txt")).unwrap();
let request = http::Request::builder()
.uri(format!(
"idea-plugin://{}/current/{}/{}",
plugin_id.as_str(),
hash.as_str(),
rel.as_str()
))
.body(Vec::new())
.unwrap();
let err =
plugin_asset_response_with_stores(&app_data, request, &registry, &packages, &validator)
.unwrap_err();
assert_eq!(err.0, StatusCode::FORBIDDEN);
assert!(err.1.contains("escapes plugin root"));
std::fs::remove_dir_all(app_data).ok();
}
#[test]
fn plugin_asset_response_includes_cors_headers_for_dynamic_import() {
let plugin_id = PluginId::new("dev.acme.gitgraph").unwrap();
let hash = ContentHash::new("abc123").unwrap();
let rel = RelativePath::new("dist/index.js").unwrap();
let registry = FakeRegistry {
registry: Mutex::new(PluginRegistry {
version: 1,
plugins: vec![PluginRegistryEntry {
id: plugin_id.clone(),
lifecycle_state: PluginLifecycleState::Enabled,
source: PluginInstallSource::Directory {
path_label: "/source/plugin".to_owned(),
},
content_hash: hash.clone(),
restart_required: false,
error: None,
}],
}),
};
let packages = FakePackages {
manifest: br#"{"ideaPluginManifestVersion":1}"#.to_vec(),
};
let validator = AcceptingValidator {
plugin_id: plugin_id.clone(),
main: rel.clone(),
};
let app_data = test_app_data_dir("plugin-asset-cors");
let target = app_data
.join("plugins")
.join("installed")
.join(plugin_id.as_str())
.join(rel.as_str());
std::fs::create_dir_all(target.parent().unwrap()).unwrap();
std::fs::write(&target, "export default 42;").unwrap();
let request = http::Request::builder()
.uri(format!(
"idea-plugin://{}/current/{}/{}",
plugin_id.as_str(),
hash.as_str(),
rel.as_str()
))
.body(Vec::new())
.unwrap();
let response =
plugin_asset_response_with_stores(&app_data, request, &registry, &packages, &validator)
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN),
Some(&http::HeaderValue::from_static(PLUGIN_ASSET_CORS_ORIGIN))
);
assert_eq!(
response.headers().get(header::ACCESS_CONTROL_ALLOW_METHODS),
Some(&http::HeaderValue::from_static(PLUGIN_ASSET_CORS_METHODS))
);
assert_eq!(
response.headers().get(header::CONTENT_TYPE),
Some(&http::HeaderValue::from_static(
"text/javascript; charset=utf-8"
))
);
std::fs::remove_dir_all(app_data).ok();
}
fn test_app_data_dir(label: &str) -> std::path::PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("idea-{label}-{}-{nanos}", std::process::id()))
}
}