fix(plugins): ajoute les headers CORS sur le protocole idea-plugin:// (#120)

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 17:58:35 +02:00
parent 95066d4110
commit fbe69de0c2

View File

@ -15,6 +15,9 @@ use tauri::{AppHandle, Manager, State};
use crate::state::AppState; use crate::state::AppState;
const PLUGIN_ASSET_CORS_ORIGIN: &str = "*";
const PLUGIN_ASSET_CORS_METHODS: &str = "GET";
/// Lists installed plugins for the admin surface. /// Lists installed plugins for the admin surface.
#[tauri::command] #[tauri::command]
pub async fn plugin_list_plugins( pub async fn plugin_list_plugins(
@ -192,8 +195,7 @@ pub fn plugin_asset_protocol(
application::diag!( application::diag!(
"[plugins] asset failed uri={uri} status={status} message={message}" "[plugins] asset failed uri={uri} status={status} message={message}"
); );
Response::builder() plugin_asset_response_builder(status)
.status(status)
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8") .header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(message.into_bytes()) .body(message.into_bytes())
.unwrap_or_else(|e| { .unwrap_or_else(|e| {
@ -207,6 +209,27 @@ pub fn plugin_asset_protocol(
fn plugin_asset_response( fn plugin_asset_response(
app: &AppHandle, app: &AppHandle,
request: http::Request<Vec<u8>>, 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)> { ) -> Result<Response<Vec<u8>>, (StatusCode, String)> {
let uri = request.uri(); let uri = request.uri();
let plugin_id = uri let plugin_id = uri
@ -227,14 +250,13 @@ fn plugin_asset_response(
} }
let rel = let rel =
RelativePath::new(rel.to_owned()).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; RelativePath::new(rel.to_owned()).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
let state = app.state::<AppState>();
let allowed = block_on_protocol_future(asset_allowed( let allowed = block_on_protocol_future(asset_allowed(
&plugin_id, &plugin_id,
hash, hash,
&rel, &rel,
state.plugin_registry_store.as_ref(), registry_store,
state.plugin_package_store.as_ref(), package_store,
state.plugin_manifest_validator.as_ref(), validator,
))?; ))?;
if !allowed { if !allowed {
return Err(( return Err((
@ -242,10 +264,6 @@ fn plugin_asset_response(
"plugin asset is not active".to_owned(), "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 let root = app_data
.join("plugins") .join("plugins")
.join("installed") .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()))?; let bytes = std::fs::read(&target).map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
Response::builder() plugin_asset_response_builder(StatusCode::OK)
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type(&target)) .header(header::CONTENT_TYPE, content_type(&target))
.body(bytes) .body(bytes)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())) .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( async fn asset_allowed(
plugin_id: &PluginId, plugin_id: &PluginId,
hash: &str, hash: &str,
@ -351,8 +381,12 @@ fn open_folder(path: &PathBuf) -> Result<(), ErrorDto> {
#[cfg(test)] #[cfg(test)]
mod tests { 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::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use async_trait::async_trait; use async_trait::async_trait;
use domain::ports::{ use domain::ports::{
@ -360,10 +394,11 @@ mod tests {
PluginRegistryStore, PluginStoreError, PluginRegistryStore, PluginStoreError,
}; };
use domain::{ use domain::{
ContentHash, LocalPath, PluginId, PluginInstallSource, PluginLifecycleState, ContentHash, LocalPath, PluginContributionSet, PluginId, PluginInstallSource,
PluginRegistry, PluginRegistryEntry, RelativePath, RemovalOutcome, StagedPluginPackage, PluginLifecycleState, PluginManifest, PluginRegistry, PluginRegistryEntry,
PluginTrustLevel, PluginVersion, RelativePath, RemovalOutcome, StagedPluginPackage,
}; };
use http::StatusCode; use http::{header, StatusCode};
struct FakeRegistry { struct FakeRegistry {
registry: Mutex<PluginRegistry>, registry: Mutex<PluginRegistry>,
@ -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<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(),
contributes: PluginContributionSet::default(),
})
}
}
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]
async fn protocol_future_can_be_waited_inside_tauri_runtime() { async fn protocol_future_can_be_waited_inside_tauri_runtime() {
let value = block_on_protocol_future(async { 42 }); let value = block_on_protocol_future(async { 42 });
@ -504,4 +567,80 @@ mod tests {
assert_eq!(err.0, StatusCode::FORBIDDEN); assert_eq!(err.0, StatusCode::FORBIDDEN);
assert!(err.1.contains("broken manifest")); 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, &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()))
}
} }