From fbe69de0c2bb14b32a1b2bf0a2255789289df1b4 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 1 Aug 2026 17:58:35 +0200 Subject: [PATCH] fix(plugins): ajoute les headers CORS sur le protocole idea-plugin:// (#120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit plugin_asset_response ne posait aucun header Access-Control-Allow-Origin, ce qui bloquait l'import() dynamique du bundle plugin (origine distincte du document) sous WebKitGTK — cause racine confirmee de la 4e rechute #120 (bandeau « Cross-origin script load denied »). Ajoute Access-Control-Allow- Origin/-Methods sur toutes les reponses du protocole (succes et erreurs) et un test backend dedie pour eviter une rechute silencieuse. QA 2026-08-01 : PASS avec reserve — cargo test -p app-tauri (test CORS cible + plugin_install_load) et tests frontend runtime/plugins verts, mais pas de preuve visuelle e2e AppImage dans cet environnement (fuse absent, AppImage non montable ici). Validation AppImage reelle a faire avant cloture definitive du ticket #120. Co-Authored-By: Claude Sonnet 5 --- crates/app-tauri/src/plugins.rs | 171 +++++++++++++++++++++++++++++--- 1 file changed, 155 insertions(+), 16 deletions(-) diff --git a/crates/app-tauri/src/plugins.rs b/crates/app-tauri/src/plugins.rs index 0759d6e..70bbc21 100644 --- a/crates/app-tauri/src/plugins.rs +++ b/crates/app-tauri/src/plugins.rs @@ -15,6 +15,9 @@ 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( @@ -192,8 +195,7 @@ pub fn plugin_asset_protocol( application::diag!( "[plugins] asset failed uri={uri} status={status} message={message}" ); - Response::builder() - .status(status) + plugin_asset_response_builder(status) .header(header::CONTENT_TYPE, "text/plain; charset=utf-8") .body(message.into_bytes()) .unwrap_or_else(|e| { @@ -207,6 +209,27 @@ pub fn plugin_asset_protocol( fn plugin_asset_response( app: &AppHandle, request: http::Request>, +) -> Result>, (StatusCode, String)> { + let state = app.state::(); + 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>, + registry_store: &dyn PluginRegistryStore, + package_store: &dyn PluginPackageStore, + validator: &dyn PluginManifestValidator, ) -> Result>, (StatusCode, String)> { let uri = request.uri(); let plugin_id = uri @@ -227,14 +250,13 @@ fn plugin_asset_response( } let rel = RelativePath::new(rel.to_owned()).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; - let state = app.state::(); let allowed = block_on_protocol_future(asset_allowed( &plugin_id, hash, &rel, - state.plugin_registry_store.as_ref(), - state.plugin_package_store.as_ref(), - state.plugin_manifest_validator.as_ref(), + registry_store, + package_store, + validator, ))?; if !allowed { return Err(( @@ -242,10 +264,6 @@ fn plugin_asset_response( "plugin asset is not active".to_owned(), )); } - let app_data = app - .path() - .app_data_dir() - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let root = app_data .join("plugins") .join("installed") @@ -264,13 +282,25 @@ fn plugin_asset_response( )); } let bytes = std::fs::read(&target).map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?; - Response::builder() - .status(StatusCode::OK) + 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, @@ -351,8 +381,12 @@ fn open_folder(path: &PathBuf) -> Result<(), ErrorDto> { #[cfg(test)] mod tests { - use super::{asset_allowed, block_on_protocol_future}; + 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::{ @@ -360,10 +394,11 @@ mod tests { PluginRegistryStore, PluginStoreError, }; use domain::{ - ContentHash, LocalPath, PluginId, PluginInstallSource, PluginLifecycleState, - PluginRegistry, PluginRegistryEntry, RelativePath, RemovalOutcome, StagedPluginPackage, + ContentHash, LocalPath, PluginContributionSet, PluginId, PluginInstallSource, + PluginLifecycleState, PluginManifest, PluginRegistry, PluginRegistryEntry, + PluginTrustLevel, PluginVersion, RelativePath, RemovalOutcome, StagedPluginPackage, }; - use http::StatusCode; + use http::{header, StatusCode}; struct FakeRegistry { registry: Mutex, @@ -459,6 +494,34 @@ mod tests { } } + struct AcceptingValidator { + plugin_id: PluginId, + main: RelativePath, + } + + impl domain::ports::PluginManifestValidator for AcceptingValidator { + fn validate( + &self, + _bytes: &[u8], + _package: &domain::PluginPackageRef, + ) -> Result { + 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(), + 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 }); @@ -504,4 +567,80 @@ mod tests { assert_eq!(err.0, StatusCode::FORBIDDEN); assert!(err.1.contains("broken manifest")); } + + #[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, ®istry, &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())) + } }