From 0bee9b7aab9df309262a618466187d59759d3b86 Mon Sep 17 00:00:00 2001
From: Arnaud Ritti <77437157+arnaud-ritti@users.noreply.github.com>
Date: Sat, 7 Nov 2026 20:12:01 +0100
Subject: [PATCH] feat(plugins): allow custom gutter marker icons (#2044)
Closes #1951
Gutter markers only recognised a short alias list and the runtime truncated icon references at 32 bytes, so ordinary icon names and package paths were dropped. Markers now accept any built-in icon name, and API 1.3 `contributes.markerIcons` declares package SVGs (with an optional dark variant) that are size-limited, sanitised and rendered through the icon registry. The shared SVG sanitiser now also rejects URL strings in any CSS function (e.g. image-set), not only url(). Verified on macOS in light and dark themes.
Co-authored-by: arnaud-ritti <77437157+arnaud-ritti@users.noreply.github.com>
---
crates/ide-host/src/plugins/icons.rs | 6 +
crates/ide-host/src/plugins/mod.rs | 3 +-
crates/ide-host/src/plugins/points.rs | 1 +
crates/ide-host/src/plugins/ui_points.rs | 30 +++++
crates/ide-host/tests/plugins.rs | 109 ++++++++++++++++++
crates/ide-plugin/src/runtime.rs | 2 +-
.../tests/fixtures/misbehave/dist/plugin.wasm | Bin 136764 -> 137885 bytes
.../misbehave/dist/plugin.wasm.sources | 2 +-
.../tests/fixtures/misbehave/src/lib.rs | 16 ++-
e2e/pluginMarkerIcons.spec.ts | 102 ++++++++++++++++
plugins/README.md | 21 ++++
.../plugins/__tests__/plugins.test.tsx | 1 +
src/features/plugins/lineMarkerGutter.test.ts | 59 ++++++++++
src/features/plugins/lineMarkerGutter.ts | 27 ++++-
src/features/plugins/markerIcons.test.ts | 58 ++++++++++
src/features/plugins/markerIcons.ts | 51 ++++++++
src/features/plugins/uiPoints.tsx | 5 +
src/platform/locales/text/en.json | 1 +
src/platform/locales/text/zh.json | 1 +
src/ui/icons.test.tsx | 9 ++
src/ui/icons.tsx | 7 +-
21 files changed, 499 insertions(+), 12 deletions(-)
create mode 100644 e2e/pluginMarkerIcons.spec.ts
create mode 100644 src/features/plugins/markerIcons.test.ts
create mode 100644 src/features/plugins/markerIcons.ts
diff --git a/crates/ide-host/src/plugins/icons.rs b/crates/ide-host/src/plugins/icons.rs
index 99dfe3d11..031af23f5 100644
--- a/crates/ide-host/src/plugins/icons.rs
+++ b/crates/ide-host/src/plugins/icons.rs
@@ -510,6 +510,8 @@ fn check_value(key: &str, value: &str) -> Result<(), String> {
if lower.contains("javascript:")
|| lower.contains("expression(")
|| lower.contains("@import")
+ // These CSS image/URL functions accept bare strings, bypassing url().
+ || ["image(", "image-set(", "src("].iter().any(|function| lower.contains(function))
|| value.contains('\\')
{
return Err("unsafe value".into());
@@ -607,6 +609,10 @@ mod tests {
r##""##,
r##""##,
r##""##,
+ r##""##,
+ r##""##,
+ r##""##,
+ r##""##,
r##"]>"##,
r##""##,
r##""##,
diff --git a/crates/ide-host/src/plugins/mod.rs b/crates/ide-host/src/plugins/mod.rs
index 5d9b2dd60..06e83b24d 100644
--- a/crates/ide-host/src/plugins/mod.rs
+++ b/crates/ide-host/src/plugins/mod.rs
@@ -382,7 +382,8 @@ impl PluginHost {
let ui = UiStore::new();
let index = Arc::new(ContributionIndex::new(
plugins_dir.join(".cache").join("contributions"),
- env!("CARGO_PKG_VERSION"),
+ // Revalidate SVGs accepted before URL-valued CSS functions were rejected.
+ concat!(env!("CARGO_PKG_VERSION"), ":svg-2"),
));
ui.set_index(Some(index.clone()));
let languages = PluginLanguages::new();
diff --git a/crates/ide-host/src/plugins/points.rs b/crates/ide-host/src/plugins/points.rs
index 3fcb09b84..13ed6d29f 100644
--- a/crates/ide-host/src/plugins/points.rs
+++ b/crates/ide-host/src/plugins/points.rs
@@ -67,6 +67,7 @@ pub fn register_builtin_points(
registry.register(Arc::new(UiPoint::new(p::SETTINGS, ui.clone(), p::settings)))?;
registry.register(Arc::new(UiPoint::new(p::VIEWS, ui.clone(), p::view)))?;
registry.register(Arc::new(UiPoint::new(p::CUSTOM_EDITORS, ui.clone(), p::custom_editor)))?;
+ registry.register(Arc::new(UiPoint::new(p::MARKER_ICONS, ui.clone(), p::marker_icon)))?;
registry.register(Arc::new(UiPoint::new(p::FILE_ICONS, ui.clone(), p::file_icon)))?;
registry.register(Arc::new(UiPoint::new(p::FILE_TEMPLATES, ui.clone(), p::file_template)))?;
registry.register(Arc::new(UiPoint::new(p::COMMIT_CHECKS, ui.clone(), p::commit_check)))?;
diff --git a/crates/ide-host/src/plugins/ui_points.rs b/crates/ide-host/src/plugins/ui_points.rs
index a38dc44d8..151e9ec58 100644
--- a/crates/ide-host/src/plugins/ui_points.rs
+++ b/crates/ide-host/src/plugins/ui_points.rs
@@ -69,6 +69,7 @@ pub const SETTINGS: &str = "settings";
pub const VIEWS: &str = "views";
pub const CUSTOM_EDITORS: &str = "customEditors";
pub const FILE_ICONS: &str = "fileIcons";
+pub const MARKER_ICONS: &str = "markerIcons";
pub const FILE_TEMPLATES: &str = "fileTemplates";
pub const COMMIT_CHECKS: &str = "commitChecks";
@@ -553,6 +554,35 @@ pub(super) fn file_icon(pkg: &Package, decl: toml::Value) -> Result,
+}
+
+pub(super) fn marker_icon(pkg: &Package, decl: toml::Value) -> Result {
+ pkg.manifest.needs_api("contributes.markerIcons", 3)?;
+ let d: MarkerIconDecl = decode(MARKER_ICONS, decl)?;
+ validate_item_id(&d.id, "marker icon id")?;
+ if d.icon.to_string_lossy().len() > 256 {
+ return Err("marker icon paths are up to 256 bytes".into());
+ }
+ let mut out = UiDecl::new(d.id.clone(), d.id.clone(), serde_json::json!({ "id": d.id, "icon": d.icon }));
+ for (path, variant) in [(Some(&d.icon), "light"), (d.icon_dark.as_ref(), "dark")] {
+ let Some(path) = path else { continue };
+ let svg = super::icons::read_icon(pkg, path, super::icons::MAX_SVG)?;
+ out.assets.push((format!("markerIcon/{}/{variant}", d.id), svg));
+ out.files.push(path.clone());
+ }
+ // A changed SVG must reproject even when its declared path stays the same.
+ let bytes = serde_json::to_vec(&out.assets).map_err(|e| e.to_string())?;
+ out.json["revision"] = Value::String(blake3::hash(&bytes).to_hex().to_string());
+ Ok(out)
+}
+
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct FileTemplateDecl {
diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs
index 295effc30..73c32a825 100644
--- a/crates/ide-host/tests/plugins.rs
+++ b/crates/ide-host/tests/plugins.rs
@@ -4336,3 +4336,112 @@ fn view_messages_accept_multi_megabyte_snapshots_and_cap_at_8_mib() {
f.host.view_event("jobs", "jobs", ide_plugin::ViewEvent::Message { text: too_large }, f.inv()).unwrap_err();
assert!(err.message.contains("limited to"), "{}", err.message);
}
+
+#[test]
+fn marker_icons_ship_sanitized_light_and_dark_assets() {
+ let f = fixture(Limits::default());
+ let tmp = tempfile::tempdir().unwrap();
+ let pkg = tmp.path().join("plugin");
+ std::fs::create_dir_all(pkg.join("icons")).unwrap();
+ let manifest = |engine: &str, icon: &str, dark: &str| {
+ format!(
+ r#"
+id = "marker-icons"
+name = "Marker Icons"
+version = "1.0.0"
+engines.openidea = "{engine}"
+[[contributes.markerIcons]]
+id = "resource"
+icon = "{icon}"
+iconDark = "{dark}"
+"#
+ )
+ };
+ let clean = r#""#;
+ std::fs::write(pkg.join("icons/resource.svg"), clean).unwrap();
+ std::fs::write(pkg.join("icons/resource_dark.svg"), clean.replace("rect", "circle")).unwrap();
+ for (engine, icon, dark) in [
+ ("^1.0", "icons/resource.svg", "icons/resource_dark.svg"),
+ ("^1.3", "../outside.svg", "icons/resource_dark.svg"),
+ ("^1.3", "icons/missing.svg", "icons/resource_dark.svg"),
+ ("^1.3", "icons/resource.svg", "icons/missing.svg"),
+ ] {
+ std::fs::write(pkg.join("plugin.toml"), manifest(engine, icon, dark)).unwrap();
+ assert!(f.host.install_from_dir(&pkg, &Consent::all()).is_err());
+ }
+ std::fs::write(pkg.join("plugin.toml"), manifest("^1.3", "icons/resource.svg", "icons/resource_dark.svg")).unwrap();
+ #[cfg(unix)]
+ {
+ let outside = tmp.path().join("outside.svg");
+ std::fs::write(&outside, clean).unwrap();
+ std::fs::remove_file(pkg.join("icons/resource_dark.svg")).unwrap();
+ std::os::unix::fs::symlink(outside, pkg.join("icons/resource_dark.svg")).unwrap();
+ assert!(f.host.install_from_dir(&pkg, &Consent::all()).is_err());
+ std::fs::remove_file(pkg.join("icons/resource_dark.svg")).unwrap();
+ }
+ let mut unsafe_svgs = vec![
+ "".to_owned(),
+ "