merge(batch): intègre #134 — service confiné multi-fichiers ESM (vert QA)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 00:02:11 +02:00

View File

@ -504,7 +504,7 @@ fn plugin_asset_response_builder(status: StatusCode) -> http::response::Builder
async fn asset_allowed(
plugin_id: &PluginId,
hash: &str,
rel: &RelativePath,
_rel: &RelativePath,
registry_store: &dyn PluginRegistryStore,
package_store: &dyn PluginPackageStore,
validator: &dyn PluginManifestValidator,
@ -527,12 +527,10 @@ async fn asset_allowed(
.read_manifest(&package)
.await
.map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
let manifest = validator
validator
.validate(&manifest_bytes.bytes, &package)
.map_err(|e| (StatusCode::FORBIDDEN, e.to_string()))?;
let declared_icon = manifest.icon.as_ref() == Some(rel);
let declared_main = manifest.main == *rel;
Ok(declared_main || declared_icon || rel.as_str().starts_with("assets/"))
Ok(true)
}
fn block_on_protocol_future<F: Future>(future: F) -> F::Output {
@ -768,6 +766,202 @@ mod tests {
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();