fix(ticket116): isoler crash plugin hello-plugin + erreur explicite UI

This commit is contained in:
2026-07-31 14:17:56 +02:00
parent e042ced724
commit d4e61a86f0
9 changed files with 417 additions and 64 deletions

View File

@ -302,7 +302,113 @@ fn open_folder(path: &PathBuf) -> Result<(), ErrorDto> {
#[cfg(test)]
mod tests {
use super::block_on_protocol_future;
use super::{asset_allowed, block_on_protocol_future};
use std::sync::Mutex;
use async_trait::async_trait;
use domain::ports::{
PluginManifestBytes, PluginManifestError, PluginPackageStore, PluginRegistryError,
PluginRegistryStore, PluginStoreError,
};
use domain::{
ContentHash, LocalPath, PluginId, PluginInstallSource, PluginLifecycleState,
PluginRegistry, PluginRegistryEntry, RelativePath, RemovalOutcome, StagedPluginPackage,
};
use http::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()))
}
}
#[tokio::test(flavor = "multi_thread")]
async fn protocol_future_can_be_waited_inside_tauri_runtime() {
@ -310,4 +416,43 @@ mod tests {
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"));
}
}

View File

@ -713,44 +713,99 @@ impl ListPluginRuntimeContributions {
/// Executes the use case.
pub async fn execute(&self) -> Result<PluginRuntimeCatalog, AppError> {
let registry = self.registry.load_registry().await.map_err(map_registry)?;
let mut registry = self.registry.load_registry().await.map_err(map_registry)?;
let mut plugins = Vec::new();
for entry in registry.plugins {
let mut invalid_plugins = Vec::new();
for entry in registry.plugins.clone() {
if !entry.lifecycle_state.is_runtime_active() {
continue;
}
let descriptor =
descriptor_for(self.packages.as_ref(), self.validator.as_ref(), entry).await?;
let bundle = plugin_asset_url(
&descriptor.manifest.id,
descriptor.manifest.version.as_str(),
&descriptor.registry.content_hash,
&descriptor.manifest.main,
);
let icon_url = match &descriptor.manifest.icon {
Some(icon) => Some(plugin_asset_url(
&descriptor.manifest.id,
descriptor.manifest.version.as_str(),
&descriptor.registry.content_hash,
icon,
)),
None => None,
};
plugins.push(PluginRuntimePlugin {
id: descriptor.manifest.id.as_str().to_owned(),
display_name: descriptor.manifest.display_name,
publisher: descriptor.manifest.publisher,
version: descriptor.manifest.version.as_str().to_owned(),
bundle_url: bundle,
icon_url,
content_hash: descriptor.registry.content_hash.as_str().to_owned(),
contributes: descriptor.manifest.contributes,
});
match runtime_plugin_from_entry(
self.packages.as_ref(),
self.validator.as_ref(),
entry.clone(),
)
.await
{
Ok(plugin) => plugins.push(plugin),
Err(err) => {
let message = format!(
"runtime contributions disabled: plugin `{}` is not servable: {err}",
entry.id.as_str()
);
crate::diag!("[plugins] {message}");
invalid_plugins.push((entry.id, message));
}
}
}
if !invalid_plugins.is_empty() {
for (plugin_id, message) in invalid_plugins {
if let Some(entry) = registry.plugins.iter_mut().find(|p| p.id == plugin_id) {
entry.lifecycle_state = PluginLifecycleState::Invalid;
entry.error = Some(message);
}
}
if let Err(err) = self
.registry
.save_registry(&registry)
.await
.map_err(map_registry)
{
crate::diag!("[plugins] failed to persist invalid runtime plugin state: {err}");
}
}
Ok(PluginRuntimeCatalog { plugins })
}
}
async fn runtime_plugin_from_entry(
packages: &dyn PluginPackageStore,
validator: &dyn PluginManifestValidator,
entry: PluginRegistryEntry,
) -> Result<PluginRuntimePlugin, AppError> {
let descriptor = descriptor_for(packages, validator, entry).await?;
let bundle = checked_plugin_asset_url(
packages,
&descriptor.manifest.id,
descriptor.manifest.version.as_str(),
&descriptor.registry.content_hash,
&descriptor.manifest.main,
)?;
let icon_url = match &descriptor.manifest.icon {
Some(icon) => Some(checked_plugin_asset_url(
packages,
&descriptor.manifest.id,
descriptor.manifest.version.as_str(),
&descriptor.registry.content_hash,
icon,
)?),
None => None,
};
Ok(PluginRuntimePlugin {
id: descriptor.manifest.id.as_str().to_owned(),
display_name: descriptor.manifest.display_name,
publisher: descriptor.manifest.publisher,
version: descriptor.manifest.version.as_str().to_owned(),
bundle_url: bundle,
icon_url,
content_hash: descriptor.registry.content_hash.as_str().to_owned(),
contributes: descriptor.manifest.contributes,
})
}
fn checked_plugin_asset_url(
packages: &dyn PluginPackageStore,
plugin_id: &PluginId,
version: &str,
hash: &ContentHash,
path: &domain::RelativePath,
) -> Result<String, AppError> {
packages
.bundle_url(plugin_id, path, hash)
.map_err(map_store)?;
Ok(plugin_asset_url(plugin_id, version, hash, path))
}
/// Reconciles plugin MCP servers.
pub struct ReconcilePluginMcpServers {
packages: Arc<dyn PluginPackageStore>,
@ -1541,6 +1596,34 @@ mod tests {
}
}
#[tokio::test]
async fn runtime_catalog_marks_invalid_active_plugin_and_keeps_bootstrap_alive() {
let packages = Arc::new(FakePackages::with_manifest(br#"{"broken":true}"#.to_vec()));
let registry = Arc::new(FakeRegistry {
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
});
let usecase =
ListPluginRuntimeContributions::new(packages, registry.clone(), Arc::new(validator()));
let catalog = usecase.execute().await.unwrap();
assert!(
catalog.plugins.is_empty(),
"invalid active plugin must be excluded from runtime catalog"
);
let saved = registry.load_registry().await.unwrap();
let entry = saved.find(&plugin_id()).unwrap();
assert_eq!(entry.lifecycle_state, PluginLifecycleState::Invalid);
assert!(
entry
.error
.as_deref()
.unwrap_or_default()
.contains("not servable"),
"registry must carry a confined runtime error: {entry:?}"
);
}
#[tokio::test]
async fn reconcile_mcp_uses_only_enabled_auto_start_servers_with_plugin_identity() {
let packages = Arc::new(FakePackages::with_manifest(valid_manifest()));