From 0024a1c7059bcd7173104c3dad756c5a22541847 Mon Sep 17 00:00:00 2001
From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Date: Wed, 4 Nov 2026 17:46:50 +0800
Subject: [PATCH] feat(plugins): keep a plugin's signer across updates and
announce updates
- Index format: fields are only added; an IDE reading a newer `format`
still applies its revocations and `moved`, lists no plugins and asks to
be updated, instead of failing to read the index.
- Key continuity: an installed plugin updates silently only to a package
signed with the same key, or a key it endorsed (`KeyEntry.endorsement`,
`index --previous-key`, `openidea-plugin endorse`). Another signer, or an
unsigned package replacing a signed one, is shown on the install page and
needs the user's confirmation (`Consent.signerChange`).
- Official key: release builds carry the official publisher's public key
(`OPENIDEA_OFFICIAL_PLUGIN_KEYS`, derived from PLUGIN_SIGNING_KEY);
`openidea.*` plugins must be signed with it or a key it endorsed,
whatever the index lists.
- Updates: the daily marketplace check announces new versions once, with
Update All (also in the gear menu and as an action). An update that asks
for nothing new installs with the grants the installed version has.
- Index entries need `engine`; `categories` beyond the first three are
ignored instead of rejecting the manifest.
---
.github/workflows/release.yml | 17 +
Cargo.lock | 1 +
crates/ide-extension/src/lib.rs | 4 +-
crates/ide-extension/src/manifest.rs | 25 +-
crates/ide-extension/src/package.rs | 298 ++++++++++++++++--
crates/ide-host/Cargo.toml | 1 +
.../ide-host/src/bin/openidea-plugin/main.rs | 136 ++++++--
crates/ide-host/src/plugins/market.rs | 85 ++++-
crates/ide-host/src/plugins/mod.rs | 51 ++-
crates/ide-host/src/plugins/remote_host.rs | 3 +-
crates/ide-host/src/plugins/types.rs | 12 +
crates/ide-host/tests/distribution.rs | 192 ++++++++++-
plugins/README.md | 33 +-
src/bindings.ts | 33 +-
src/features/plugins/PluginsPage.tsx | 16 +-
.../plugins/__tests__/intellijImport.test.tsx | 2 +
.../plugins/__tests__/plugins.test.tsx | 131 +++++++-
src/features/plugins/backend.ts | 2 +
src/features/plugins/bridge.ts | 22 +-
src/features/plugins/consent.tsx | 10 +-
src/features/plugins/index.ts | 12 +-
src/features/plugins/store.ts | 76 +++--
src/platform/locales/text/zh.json | 10 +-
23 files changed, 1058 insertions(+), 114 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 7a9c72e39..9a4af689a 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -26,6 +26,7 @@ jobs:
runs-on: ubuntu-latest
outputs:
release_id: ${{ steps.release.outputs.id }}
+ official_plugin_keys: ${{ steps.plugin-keys.outputs.keys }}
steps:
- uses: actions/checkout@v4
with: { ref: "${{ env.TAG }}" }
@@ -36,6 +37,20 @@ jobs:
echo "::error::tag $TAG does not match tauri.conf.json version $app"
exit 1
fi
+ # The official plugins' public key (from PLUGIN_SIGNING_KEY, the seed
+ # `openidea-plugin keygen` writes), built into the app: their packages
+ # must carry its signature whatever the marketplace index lists. Without
+ # the secret the app takes the index's word, as development builds do.
+ - id: plugin-keys
+ env:
+ SIGNING_KEY: ${{ secrets.PLUGIN_SIGNING_KEY }}
+ run: |
+ if [ -n "$SIGNING_KEY" ]; then
+ seed="$(printf %s "$SIGNING_KEY" | base64 -d | od -An -tx1 | tr -d ' \n')"
+ key="$(printf '302e020100300506032b657004220420%s' "$seed" | xxd -r -p |
+ openssl pkey -inform DER -pubout -outform DER | tail -c 32 | base64)"
+ echo "keys=$key" >> "$GITHUB_OUTPUT"
+ fi
- id: release
uses: actions/github-script@v7
with:
@@ -58,6 +73,8 @@ jobs:
build:
needs: create-release
name: Build (${{ matrix.name }})
+ env:
+ OPENIDEA_OFFICIAL_PLUGIN_KEYS: ${{ needs.create-release.outputs.official_plugin_keys }}
strategy:
fail-fast: false
matrix:
diff --git a/Cargo.lock b/Cargo.lock
index a9bce3786..76ff7212d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4276,6 +4276,7 @@ dependencies = [
"regex-syntax",
"reqwest",
"rustix",
+ "semver",
"serde",
"serde_json",
"specta",
diff --git a/crates/ide-extension/src/lib.rs b/crates/ide-extension/src/lib.rs
index 880f6e02a..fdf63d950 100644
--- a/crates/ide-extension/src/lib.rs
+++ b/crates/ide-extension/src/lib.rs
@@ -27,8 +27,8 @@ mod when;
pub use capability::{Capability, CapabilityInfo, Risk, host_matches};
pub use index::ContributionIndex;
pub use manifest::{
- ActivationEvent, CATEGORIES, Capabilities, ENGINE_VERSION, EVENT_KINDS, LEGACY_API, MANIFEST_FILE, Manifest,
- check_relative, engine_compatible, validate_item_id, validate_plugin_id, validate_point_id,
+ ActivationEvent, CATEGORIES, Capabilities, ENGINE_VERSION, EVENT_KINDS, LEGACY_API, MANIFEST_FILE, MAX_CATEGORIES,
+ Manifest, check_relative, engine_compatible, validate_item_id, validate_plugin_id, validate_point_id,
};
pub use point::{Commit, Disposable, ExtensionPoint, MAX_PACKAGE_FILE, Package, Placement, Prepared, decode};
pub use registry::{ContributionInfo, ExtensionRegistry, PreparedSet};
diff --git a/crates/ide-extension/src/manifest.rs b/crates/ide-extension/src/manifest.rs
index 25d19c45d..d079cff2d 100644
--- a/crates/ide-extension/src/manifest.rs
+++ b/crates/ide-extension/src/manifest.rs
@@ -38,8 +38,8 @@ pub const LEGACY_API: &str = "0.1";
/// Marketplace categories, in the order they are shown.
pub const CATEGORIES: &[&str] =
&["languages", "themes", "icon-themes", "linters", "vcs", "ai", "frameworks", "tools", "other"];
-/// What a manifest may declare in `categories`.
-const MAX_CATEGORIES: usize = 3;
+/// Categories a plugin is listed under; later ones are ignored.
+pub const MAX_CATEGORIES: usize = 3;
#[derive(Clone, Debug, PartialEq)]
pub struct Manifest {
@@ -300,9 +300,6 @@ impl Manifest {
return Err(format!("duplicate activation event {event}"));
}
}
- if self.declared_categories.len() > MAX_CATEGORIES {
- return Err(format!("`categories` lists at most {MAX_CATEGORIES}"));
- }
for c in &self.declared_categories {
validate_item_id(c, "category")?;
}
@@ -342,12 +339,18 @@ impl Manifest {
}
impl Manifest {
- /// Its marketplace categories, in [`CATEGORIES`] order: those declared that
- /// this IDE knows; without any, those its contributions imply (themes,
- /// icon themes, languages), else `other`.
+ /// Its marketplace categories, in [`CATEGORIES`] order: the first
+ /// [`MAX_CATEGORIES`] declared that this IDE knows; without any, those its
+ /// contributions imply (themes, icon themes, languages), else `other`.
pub fn categories(&self) -> Vec {
let pick = |f: &dyn Fn(&str) -> bool| CATEGORIES.iter().filter(|c| f(c)).map(|c| c.to_string()).collect();
- let mut out: Vec = pick(&|c| self.declared_categories.iter().any(|d| d == c));
+ let mut known: Vec<&str> = Vec::new();
+ for d in &self.declared_categories {
+ if CATEGORIES.contains(&d.as_str()) && !known.contains(&d.as_str()) && known.len() < MAX_CATEGORIES {
+ known.push(d);
+ }
+ }
+ let mut out: Vec = pick(&|c| known.contains(&c));
if out.is_empty() {
out = pick(&|c| match c {
"themes" => self.contributes.contains_key("themes"),
@@ -474,7 +477,9 @@ mod tests {
let declared_wins = m("categories = [\"tools\"]\n[[contributes.languages]]\nid = \"l\"\n").unwrap();
assert_eq!(declared_wins.categories(), ["tools"]);
assert_eq!(m("").unwrap().categories(), ["other"]);
- assert!(m("categories = [\"a\", \"b\", \"c\", \"d\"]\n").unwrap_err().contains("at most 3"));
+ // At most three: the first ones declared.
+ let many = m("categories = [\"vcs\", \"ai\", \"vcs\", \"tools\", \"themes\"]\n").unwrap();
+ assert_eq!(many.categories(), ["vcs", "ai", "tools"]);
assert!(m("categories = [\"Bad Name\"]\n").is_err());
}
diff --git a/crates/ide-extension/src/package.rs b/crates/ide-extension/src/package.rs
index e41c11fb2..f1bc9c040 100644
--- a/crates/ide-extension/src/package.rs
+++ b/crates/ide-extension/src/package.rs
@@ -14,6 +14,14 @@
//! revoked plugin version is disabled where it is installed. A plugin may be
//! listed in several versions, each with the plugin API it needs: the IDE
//! offers the newest one it can load.
+//! - **Key continuity**: an installed plugin updates silently only to a
+//! package signed with the same key, or with a key the installed one
+//! endorsed ([`Endorsement`], a publisher rotating keys); any other signer
+//! is the user's call. An IDE can pin a publisher to keys it was built with
+//! ([`Index::pin_publisher`]): the index then can't replace them.
+//! - **Format**: fields are only ever added (IDEs ignore those they don't
+//! know); `format` grows only for changes older IDEs can't read, and they
+//! still apply such an index's revocations ([`Index::parse`]).
use crate::MANIFEST_FILE;
use base64::Engine as _;
@@ -42,6 +50,24 @@ fn signed_message(id: &str, version: &str, digest: &str) -> Vec {
format!("openidea-plugin/1\n{id}\n{version}\n{digest}").into_bytes()
}
+fn endorsement_message(publisher: &str, key: &str) -> Vec {
+ format!("openidea-key/1\n{publisher}\n{key}").into_bytes()
+}
+
+/// Whether `signature` (base64) is `key`'s (base64) Ed25519 signature of
+/// `message`; malformed keys and signatures are errors.
+fn verify_ed25519(key: &str, message: &[u8], signature: &str) -> Result {
+ let key: [u8; 32] = B64
+ .decode(key)
+ .ok()
+ .and_then(|b| b.try_into().ok())
+ .ok_or_else(|| "signature: malformed public key".to_owned())?;
+ let key = VerifyingKey::from_bytes(&key).map_err(|_| "signature: invalid public key".to_owned())?;
+ let sig: [u8; 64] =
+ B64.decode(signature).ok().and_then(|b| b.try_into().ok()).ok_or_else(|| "signature: malformed".to_owned())?;
+ Ok(key.verify_strict(message, &Signature::from_bytes(&sig)).is_ok())
+}
+
/// `SIGNATURE.json` of a signed package.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -76,6 +102,12 @@ impl PublisherKey {
B64.encode(self.0.verifying_key().to_bytes())
}
+ /// Vouches for `publisher`'s new key `key` (base64), the one that follows this one.
+ pub fn endorse(&self, publisher: &str, key: &str) -> Endorsement {
+ let sig = self.0.sign(&endorsement_message(publisher, key));
+ Endorsement { key: self.public_base64(), signature: B64.encode(sig.to_bytes()) }
+ }
+
pub fn sign(&self, publisher: &str, id: &str, version: &str, digest: &str) -> PackageSignature {
let sig = self.0.sign(&signed_message(id, version, digest));
PackageSignature {
@@ -90,19 +122,11 @@ impl PackageSignature {
/// Whether the signature is valid for `id`, `version` and `digest` (the
/// key's validity is checked against an [`Index`]).
pub fn check(&self, id: &str, version: &str, digest: &str) -> Result<(), String> {
- let key: [u8; 32] = B64
- .decode(&self.key)
- .ok()
- .and_then(|b| b.try_into().ok())
- .ok_or_else(|| "signature: malformed public key".to_owned())?;
- let key = VerifyingKey::from_bytes(&key).map_err(|_| "signature: invalid public key".to_owned())?;
- let sig: [u8; 64] = B64
- .decode(&self.signature)
- .ok()
- .and_then(|b| b.try_into().ok())
- .ok_or_else(|| "signature: malformed".to_owned())?;
- key.verify_strict(&signed_message(id, version, digest), &Signature::from_bytes(&sig))
- .map_err(|_| "the signature does not match the package (it was changed after signing)".to_owned())
+ if verify_ed25519(&self.key, &signed_message(id, version, digest), &self.signature)? {
+ Ok(())
+ } else {
+ Err("the signature does not match the package (it was changed after signing)".to_owned())
+ }
}
}
@@ -259,7 +283,7 @@ pub struct Publisher {
pub keys: Vec,
}
-#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct KeyEntry {
/// Ed25519 public key, base64.
@@ -267,6 +291,27 @@ pub struct KeyEntry {
/// Packages signed with it are no longer trusted.
#[serde(default)]
pub revoked: bool,
+ /// The publisher's earlier key vouching for this one.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub endorsement: Option,
+}
+
+impl KeyEntry {
+ pub fn new(key: String) -> Self {
+ Self { key, ..Self::default() }
+ }
+}
+
+/// A publisher's earlier key vouching for a newer one: plugins installed from
+/// packages signed with the earlier key update to ones signed with the newer
+/// key without asking the user.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
+#[serde(rename_all = "camelCase")]
+pub struct Endorsement {
+ /// The endorsing key, base64.
+ pub key: String,
+ /// Its Ed25519 signature of `openidea-key/1\n\n`, base64.
+ pub signature: String,
}
/// A published plugin version.
@@ -289,7 +334,7 @@ pub struct IndexEntry {
pub capabilities: Vec,
/// The plugin API versions it loads in (a semver requirement): its
/// manifest's `engines.openidea` and the API its component was built for.
- /// Empty: any.
+ /// Required: an entry without one is offered to no IDE.
#[serde(default)]
pub engine: String,
/// Marketplace categories (`CATEGORIES`); an IDE ignores those it doesn't know.
@@ -300,7 +345,7 @@ pub struct IndexEntry {
impl IndexEntry {
/// Whether this IDE can load the version.
pub fn compatible(&self) -> bool {
- self.engine.is_empty() || crate::engine_compatible(&self.engine, false)
+ crate::engine_compatible(&self.engine, false)
}
fn semver(&self) -> Option {
@@ -328,12 +373,34 @@ pub struct Revocation {
}
impl Index {
+ /// Parses an index. One in a newer format keeps what must reach every IDE —
+ /// revocations, publisher keys (revoked ones) and `moved` — and lists no
+ /// plugins ([`Index::is_newer`]): the IDE needs updating to install from it.
pub fn parse(text: &str) -> Result {
- let index: Index = serde_json::from_str(text).map_err(|e| format!("index: {e}"))?;
- if index.format != INDEX_FORMAT {
- return Err(format!("index format {} is not supported (this IDE reads {INDEX_FORMAT})", index.format));
+ let value: serde_json::Value = serde_json::from_str(text).map_err(|e| format!("index: {e}"))?;
+ let format = value.get("format").and_then(serde_json::Value::as_u64).ok_or("index: `format` is missing")?;
+ if format == u64::from(INDEX_FORMAT) {
+ return serde_json::from_value(value).map_err(|e| format!("index: {e}"));
}
- Ok(index)
+ if format < u64::from(INDEX_FORMAT) {
+ return Err(format!("index format {format} is not supported (this IDE reads {INDEX_FORMAT})"));
+ }
+ fn each(value: &serde_json::Value, field: &str) -> Vec {
+ let items = value.get(field).and_then(serde_json::Value::as_array);
+ items.into_iter().flatten().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect()
+ }
+ Ok(Index {
+ format: u32::try_from(format).unwrap_or(u32::MAX),
+ publishers: each(&value, "publishers"),
+ plugins: Vec::new(),
+ revoked: each(&value, "revoked"),
+ moved: value.get("moved").and_then(serde_json::Value::as_str).map(str::to_owned),
+ })
+ }
+
+ /// Written in a format newer than this IDE reads: it lists no plugins.
+ pub fn is_newer(&self) -> bool {
+ self.format > INDEX_FORMAT
}
/// The version of each plugin to offer, and whether this IDE can load it:
@@ -407,6 +474,58 @@ impl Index {
Ok(publisher.id.clone())
}
+ /// Whether `to` is `from`, or a key of `publisher` that `from` endorsed,
+ /// directly or through keys it endorsed. Endorsements by revoked keys don't
+ /// count: whoever stole a key could vouch for their own.
+ pub fn succeeds(&self, publisher: &str, from: &str, to: &str) -> bool {
+ let keys = self.publishers.iter().find(|p| p.id == publisher).map_or(&[][..], |p| &p.keys[..]);
+ let mut key = to;
+ // Each step moves to an earlier key; more steps than keys is a loop.
+ for _ in 0..=keys.len() {
+ if key == from {
+ return true;
+ }
+ let Some(e) = keys.iter().find(|k| k.key == key).and_then(|k| k.endorsement.as_ref()) else {
+ return false;
+ };
+ let endorser_valid = keys.iter().any(|k| k.key == e.key && !k.revoked);
+ if !endorser_valid || verify_ed25519(&e.key, &endorsement_message(publisher, key), &e.signature) != Ok(true)
+ {
+ return false;
+ }
+ key = &e.key;
+ }
+ false
+ }
+
+ /// Makes `roots` (base64) the keys `publisher` signs with, whatever this
+ /// index says: another key it lists stays only when a root endorsed it (a
+ /// rotation the roots vouched for). Revocations of the keys are kept.
+ pub fn pin_publisher(&mut self, publisher: &str, name: &str, roots: &[String]) {
+ if roots.is_empty() {
+ return;
+ }
+ let i = match self.publishers.iter().position(|p| p.id == publisher) {
+ Some(i) => i,
+ None => {
+ self.publishers.push(Publisher { id: publisher.into(), name: name.into(), keys: Vec::new() });
+ self.publishers.len() - 1
+ }
+ };
+ for root in roots {
+ if !self.publishers[i].keys.iter().any(|k| &k.key == root) {
+ self.publishers[i].keys.push(KeyEntry::new(root.clone()));
+ }
+ }
+ let keys = self.publishers[i].keys.clone();
+ let trusted = keys
+ .into_iter()
+ .filter(|k| roots.iter().any(|r| self.succeeds(publisher, r, &k.key)))
+ .map(|k| if roots.contains(&k.key) { KeyEntry { endorsement: None, ..k } } else { k })
+ .collect();
+ self.publishers[i].keys = trusted;
+ }
+
/// Replaces what this index lists for every publisher but `keep` with what
/// `other` lists: its publishers, their plugins and their revocations
/// (`other`'s downloads should already be absolute). `other` may only
@@ -430,6 +549,9 @@ impl Index {
if !theirs.contains(&e.publisher.as_str()) || !owns(&e.publisher, &e.id) {
return Err(format!("{}: not a plugin of publisher {:?}", e.id, e.publisher));
}
+ if semver::VersionReq::parse(&e.engine).is_err() {
+ return Err(format!("{} {}: `engine` must be the plugin API it needs", e.id, e.version));
+ }
}
if let Some(r) = other.revoked.iter().find(|r| !owned_by(&r.id)) {
return Err(format!("{}: revoked by an index that doesn't publish it", r.id));
@@ -445,6 +567,25 @@ impl Index {
}
}
+/// Whether a package signed `new` (`None`: unsigned) may replace an installed
+/// one signed `installed` without asking the user: signed with the same key,
+/// or with one the installed key endorsed (per `index`). An unsigned installed
+/// copy pins nothing.
+pub fn signer_continues(
+ index: Option<&Index>,
+ installed: Option<&PackageSignature>,
+ new: Option<&PackageSignature>,
+) -> bool {
+ match (installed, new) {
+ (None, _) => true,
+ (Some(_), None) => false,
+ (Some(old), Some(new)) => {
+ old.publisher == new.publisher
+ && (old.key == new.key || index.is_some_and(|i| i.succeeds(&new.publisher, &old.key, &new.key)))
+ }
+ }
+}
+
/// Whether the plugin `id` is in `publisher`'s namespace.
fn owns(publisher: &str, id: &str) -> bool {
id.strip_prefix(publisher).is_some_and(|rest| rest.starts_with('.'))
@@ -472,7 +613,7 @@ mod tests {
publishers: vec![Publisher {
id: "acme".into(),
name: "ACME".into(),
- keys: vec![KeyEntry { key: key.public_base64(), revoked: false }],
+ keys: vec![KeyEntry::new(key.public_base64())],
}],
plugins: Vec::new(),
revoked: vec![Revocation {
@@ -529,7 +670,7 @@ mod tests {
download: format!("https://example.com/{id}.oiplugin"),
digest: "d".into(),
capabilities: Vec::new(),
- engine: String::new(),
+ engine: "^1.0".into(),
categories: Vec::new(),
}
}
@@ -562,7 +703,8 @@ mod tests {
let expected = [
("acme.a".to_owned(), "1.2.0".to_owned(), true),
("acme.b".to_owned(), "0.2.0".to_owned(), false),
- ("acme.c".to_owned(), "1.0.0".to_owned(), true),
+ // No `engine`: listed, but no IDE installs it.
+ ("acme.c".to_owned(), "1.0.0".to_owned(), false),
];
assert_eq!(offered(&index), expected);
@@ -579,7 +721,7 @@ mod tests {
}
fn publisher(id: &str) -> Publisher {
- Publisher { id: id.into(), name: id.into(), keys: vec![KeyEntry { key: format!("{id}-key"), revoked: false }] }
+ Publisher { id: id.into(), name: id.into(), keys: vec![KeyEntry::new(format!("{id}-key"))] }
}
fn revocation(id: &str) -> Revocation {
@@ -632,6 +774,14 @@ mod tests {
(with(vec![publisher("alice")], vec![entry("alice.a", "bob")], vec![]), "not a plugin of"),
(with(vec![publisher("alice")], vec![entry("alicex.a", "alice")], vec![]), "not a plugin of"),
(with(vec![publisher("alice")], vec![], vec![revocation("openidea.a")]), "doesn't publish"),
+ (
+ with(
+ vec![publisher("alice")],
+ vec![IndexEntry { engine: String::new(), ..entry("alice.a", "alice") }],
+ vec![],
+ ),
+ "`engine`",
+ ),
];
for (other, error) in cases {
let mut index = base.clone();
@@ -655,7 +805,7 @@ mod tests {
let err = unpack(&out, &tmp.path().join("dest")).unwrap_err();
assert!(err.contains("invalid path"), "{err}");
assert!(!tmp.path().join("outside.txt").exists());
- assert!(Index::parse(r#"{"format": 2}"#).is_err());
+ assert!(Index::parse(r#"{"format": 2}"#).unwrap().is_newer());
}
fn zip_of(path: &Path, entries: &[(&str, &str)]) {
@@ -704,4 +854,102 @@ mod tests {
assert!(err.contains("is not an OpenIDEA plugin"), "{name}: {err}");
}
}
+
+ #[test]
+ fn a_newer_index_still_revokes_and_moves() {
+ let newer = format!(
+ r#"{{"format": {}, "plugins": [{{"anything": "new"}}], "shape": "changed",
+ "publishers": [{{"id": "acme", "name": "ACME", "keys": [{{"key": "k1", "revoked": true}}]}}, {{"id": 5}}],
+ "revoked": [{{"id": "acme.bad", "reason": "malware"}}, {{"oops": 1}}],
+ "moved": "https://example.com/next.json"}}"#,
+ INDEX_FORMAT + 1
+ );
+ let index = Index::parse(&newer).unwrap();
+ assert!(index.is_newer());
+ assert!(index.plugins.is_empty() && index.offered().is_empty());
+ assert_eq!(index.revocation("acme.bad", "2.0.0").unwrap().reason, "malware");
+ assert!(index.key_revoked("k1"));
+ assert_eq!(index.moved.as_deref(), Some("https://example.com/next.json"));
+ // Fields this IDE doesn't know are ignored in its own format.
+ let current = format!(r#"{{"format": {INDEX_FORMAT}, "later": true, "plugins": []}}"#);
+ assert!(!Index::parse(¤t).unwrap().is_newer());
+ assert!(Index::parse(r#"{"format": 0}"#).unwrap_err().contains("not supported"));
+ assert!(Index::parse(r#"{"plugins": []}"#).is_err());
+ }
+
+ fn keys<'a>(index: &'a mut Index, publisher: &str) -> &'a mut Vec {
+ &mut index.publishers.iter_mut().find(|p| p.id == publisher).unwrap().keys
+ }
+
+ #[test]
+ fn rotated_keys_continue_through_endorsements() {
+ let (k1, k2, k3) =
+ (PublisherKey::from_seed([1; 32]), PublisherKey::from_seed([2; 32]), PublisherKey::from_seed([3; 32]));
+ let mut index = index(&k1);
+ let endorsed = |by: &PublisherKey, key: &PublisherKey| KeyEntry {
+ endorsement: Some(by.endorse("acme", &key.public_base64())),
+ ..KeyEntry::new(key.public_base64())
+ };
+ keys(&mut index, "acme").push(endorsed(&k1, &k2));
+ keys(&mut index, "acme").push(endorsed(&k2, &k3));
+ let (p1, p2, p3) = (k1.public_base64(), k2.public_base64(), k3.public_base64());
+ assert!(index.succeeds("acme", &p1, &p1));
+ assert!(index.succeeds("acme", &p1, &p3), "through k2");
+ assert!(!index.succeeds("acme", &p3, &p1), "not backwards");
+ assert!(!index.succeeds("other", &p1, &p2));
+ // An endorsement for another publisher, or by a revoked key, doesn't count.
+ let mut foreign = index.clone();
+ keys(&mut foreign, "acme")[1].endorsement = Some(k1.endorse("evil", &p2));
+ assert!(!foreign.succeeds("acme", &p1, &p2));
+ let mut stolen = index.clone();
+ keys(&mut stolen, "acme")[0].revoked = true;
+ assert!(!stolen.succeeds("acme", &p1, &p2));
+
+ let sig = |key: &PublisherKey| key.sign("acme", "acme.tools", "1.0.0", "d");
+ assert!(signer_continues(Some(&index), Some(&sig(&k1)), Some(&sig(&k3))));
+ assert!(signer_continues(None, Some(&sig(&k1)), Some(&sig(&k1))));
+ assert!(!signer_continues(None, Some(&sig(&k1)), Some(&sig(&k2))), "no index to vouch");
+ let stranger = PublisherKey::from_seed([4; 32]);
+ assert!(!signer_continues(Some(&index), Some(&sig(&k1)), Some(&sig(&stranger))));
+ assert!(!signer_continues(Some(&index), Some(&sig(&k1)), None), "signed, then unsigned");
+ assert!(signer_continues(Some(&index), None, Some(&sig(&stranger))), "unsigned pins nothing");
+ }
+
+ #[test]
+ fn pinned_publishers_keep_their_built_in_keys() {
+ let (root, next, intruder) =
+ (PublisherKey::from_seed([1; 32]), PublisherKey::from_seed([2; 32]), PublisherKey::from_seed([5; 32]));
+ let mut index = index(&intruder);
+ index.publishers[0].id = "openidea".into();
+ keys(&mut index, "openidea").push(KeyEntry {
+ endorsement: Some(root.endorse("openidea", &next.public_base64())),
+ ..KeyEntry::new(next.public_base64())
+ });
+ let roots = [root.public_base64()];
+ let listed = |index: &Index| -> Vec<(String, bool)> {
+ let mut index = index.clone();
+ index.pin_publisher("openidea", "OpenIDEA", &roots);
+ keys(&mut index, "openidea").iter().map(|k| (k.key.clone(), k.revoked)).collect()
+ };
+ // The intruder's key goes, the successor the root endorsed stays, the root is added.
+ assert_eq!(listed(&index), [(next.public_base64(), false), (root.public_base64(), false)]);
+ let mut pinned = index.clone();
+ pinned.pin_publisher("openidea", "OpenIDEA", &roots);
+ let digest = "d";
+ let signed = |k: &PublisherKey| k.sign("openidea", "openidea.tools", "1.0.0", digest);
+ assert!(pinned.verify(&signed(&intruder), "openidea.tools", "1.0.0", digest).is_err());
+ assert!(pinned.verify(&signed(&next), "openidea.tools", "1.0.0", digest).is_ok());
+ // A revoked root stays revoked, and what it endorsed is no longer trusted.
+ keys(&mut index, "openidea").push(KeyEntry { revoked: true, ..KeyEntry::new(root.public_base64()) });
+ assert_eq!(listed(&index), [(root.public_base64(), true)]);
+
+ // A publisher missing from the index is added with its roots.
+ let mut empty = Index { format: INDEX_FORMAT, ..Index::default() };
+ empty.pin_publisher("openidea", "OpenIDEA", &[root.public_base64()]);
+ assert_eq!(empty.verify(&signed(&root), "openidea.tools", "1.0.0", digest).unwrap(), "openidea");
+ // No roots: the index decides.
+ let mut open = index.clone();
+ open.pin_publisher("openidea", "OpenIDEA", &[]);
+ assert_eq!(open, index);
+ }
}
diff --git a/crates/ide-host/Cargo.toml b/crates/ide-host/Cargo.toml
index 70c843e77..a8473b6b7 100644
--- a/crates/ide-host/Cargo.toml
+++ b/crates/ide-host/Cargo.toml
@@ -46,6 +46,7 @@ ide-extension.workspace = true
ide-plugin.workspace = true
ide-tools = { path = "../ide-tools" }
ide-docker = { path = "../ide-docker" }
+semver = "1"
serde.workspace = true
serde_json.workspace = true
toml.workspace = true
diff --git a/crates/ide-host/src/bin/openidea-plugin/main.rs b/crates/ide-host/src/bin/openidea-plugin/main.rs
index 107d6149d..c0bee922b 100644
--- a/crates/ide-host/src/bin/openidea-plugin/main.rs
+++ b/crates/ide-host/src/bin/openidea-plugin/main.rs
@@ -8,8 +8,9 @@
//! openidea-plugin unlink [dir]
//! openidea-plugin links # list linked folders
//! openidea-plugin keygen # a publisher's Ed25519 key
+//! openidea-plugin endorse --key OLD --publisher ID # vouch for a new key
//! openidea-plugin pack [dir] [--key F --publisher ID] [--out FILE] # -.oiplugin
-//! openidea-plugin index [--publisher ID --name NAME --key F] # marketplace index.json
+//! openidea-plugin index [--publisher ID --name NAME --key F [--previous-key F]] # marketplace index.json
//! openidea-plugin merge --keep ID # list another index's publishers
//! ```
//!
@@ -39,13 +40,19 @@ commands:
unlink [dir] stop loading a linked folder
links list linked folders
keygen create a publisher signing key (keep the file secret)
+ endorse --key F --publisher ID
+ print an earlier key's endorsement of a new one (JSON), for an
+ index the publisher doesn't write with `index --previous-key`
pack [dir] [--key F --publisher ID] [--out FILE]
write -.oiplugin, signed with the key
- index [--publisher ID --name NAME --key F] [--out FILE] [--download-base URL]
+ index [--publisher ID --name NAME --key F [--previous-key F]] [--out FILE]
+ [--download-base URL]
write the marketplace index.json for the packages in dir: with a
key, register it and list that publisher's plugins (earlier
versions stay for older IDEs); without, list only these packages,
- each checked against the index's publishers
+ each checked against the index's publishers. A new key replacing
+ an earlier one is endorsed with --previous-key, so installed
+ plugins update to it without asking
merge --keep ID [--out FILE]
replace every publisher but ID in index with those of other-index
(a path or URL), their plugins and revocations
@@ -63,6 +70,7 @@ fn main() -> ExitCode {
Some("unlink") => dir_arg(&args[1..]).and_then(|d| link(&d, false)),
Some("links") => list_links(),
Some("keygen") => cmd_keygen(&args[1..]),
+ Some("endorse") => cmd_endorse(&args[1..]),
Some("pack") => cmd_pack(&args[1..]),
Some("index") => cmd_index(&args[1..]),
Some("merge") => cmd_merge(&args[1..]),
@@ -368,6 +376,13 @@ fn check(dir: &Path) -> Result<(), String> {
for c in manifest.unknown_categories() {
println!(" warning: unknown category {c:?} (ignored); use {}", ide_extension::CATEGORIES.join(", "));
}
+ if manifest.declared_categories.len() > ide_extension::MAX_CATEGORIES {
+ println!(
+ " warning: only the first {} known categories are used; {} are declared",
+ ide_extension::MAX_CATEGORIES,
+ manifest.declared_categories.len()
+ );
+ }
if !manifest.activation.is_empty() {
let events: Vec = manifest.activation.iter().map(ToString::to_string).collect();
println!(" activation: {}", events.join(", "));
@@ -500,6 +515,20 @@ fn cmd_keygen(args: &[String]) -> Result<(), String> {
Ok(())
}
+fn cmd_endorse(args: &[String]) -> Result<(), String> {
+ let a = Args::parse(args, &[], &["key", "publisher"])?;
+ let usage = "usage: openidea-plugin endorse --key --publisher ID";
+ let ([new], Some(key), Some(publisher)) = (a.positional.as_slice(), a.value("key"), a.value("publisher")) else {
+ return Err(usage.into());
+ };
+ if !package::publisher_id_ok(publisher) {
+ return Err(format!("invalid publisher id {publisher:?}"));
+ }
+ let endorsement = read_key(key)?.endorse(publisher, new.trim());
+ println!("{}", serde_json::to_string_pretty(&endorsement).map_err(|e| e.to_string())?);
+ Ok(())
+}
+
/// A fresh scratch directory, removed when dropped.
fn scratch_dir(what: &str) -> Result {
tempfile::Builder::new().prefix(&format!("openidea-plugin-{what}-")).tempdir().map_err(|e| e.to_string())
@@ -546,33 +575,25 @@ fn cmd_pack(args: &[String]) -> Result<(), String> {
}
fn cmd_index(args: &[String]) -> Result<(), String> {
- let a = Args::parse(args, &[], &["publisher", "name", "key", "out", "download-base"])?;
+ let a = Args::parse(args, &[], &["publisher", "name", "key", "previous-key", "out", "download-base"])?;
let dir = canonical(&a.dir()?)?;
let out = a.value("out").map(PathBuf::from).unwrap_or_else(|| dir.join("index.json"));
let base = a.value("download-base").map(|b| b.trim_end_matches('/'));
// Revocations and other publishers of an existing index are kept.
let mut index = match std::fs::read_to_string(&out) {
- Ok(text) => Index::parse(&text)?,
+ Ok(text) => current_format(Index::parse(&text)?, &out.to_string_lossy())?,
Err(_) => Index { format: package::INDEX_FORMAT, ..Index::default() },
};
let publisher = match (a.value("publisher"), a.value("key")) {
(Some(publisher), Some(key)) => {
let key = read_key(key)?;
let name = a.value("name").unwrap_or(publisher);
- // Earlier keys of the publisher stay listed (packages they signed remain valid until revoked).
- let current = key.public_base64();
- let earlier: Vec = index
- .publishers
- .iter()
- .filter(|p| p.id == publisher)
- .flat_map(|p| p.keys.iter().filter(|k| k.key != current).cloned())
- .collect();
- index.publishers.retain(|p| p.id != publisher);
- let keys: Vec =
- std::iter::once(KeyEntry { key: current, revoked: false }).chain(earlier).collect();
- index.publishers.push(package::Publisher { id: publisher.into(), name: name.into(), keys });
+ let previous = a.value("previous-key").map(read_key).transpose()?;
+ index.publishers =
+ register_key(std::mem::take(&mut index.publishers), publisher, name, &key, previous.as_ref())?;
Some(publisher)
}
+ (None, None) if a.has("previous-key") => return Err("--previous-key goes with --publisher and --key".into()),
(None, None) => None,
_ => return Err("--publisher and --key go together".into()),
};
@@ -642,6 +663,60 @@ fn cmd_index(args: &[String]) -> Result<(), String> {
write_index(&index, &out)
}
+/// `publishers` with `key` as `publisher`'s current key. Earlier keys stay listed
+/// (packages they signed remain valid until revoked); a key new to the
+/// publisher is endorsed by `previous`, one of them, so installed plugins
+/// signed with it update without asking.
+fn register_key(
+ mut publishers: Vec,
+ publisher: &str,
+ name: &str,
+ key: &PublisherKey,
+ previous: Option<&PublisherKey>,
+) -> Result, String> {
+ let current = key.public_base64();
+ let earlier: Vec = publishers.iter().filter(|p| p.id == publisher).flat_map(|p| p.keys.clone()).collect();
+ let entry = match earlier.iter().find(|k| k.key == current) {
+ Some(listed) => listed.clone(),
+ None => {
+ let endorsement = match previous {
+ Some(prev) => {
+ let prev_key = prev.public_base64();
+ if !earlier.iter().any(|k| k.key == prev_key && !k.revoked) {
+ return Err(format!("--previous-key is not one of {publisher}'s valid keys in the index"));
+ }
+ Some(prev.endorse(publisher, ¤t))
+ }
+ None => {
+ if earlier.iter().any(|k| !k.revoked) {
+ println!(
+ " warning: {publisher}'s new key is not endorsed (--previous-key): users who installed \
+ its plugins will be asked to confirm the change when they update"
+ );
+ }
+ None
+ }
+ };
+ KeyEntry { endorsement, ..KeyEntry::new(current.clone()) }
+ }
+ };
+ let keys = std::iter::once(entry).chain(earlier.into_iter().filter(|k| k.key != current)).collect();
+ publishers.retain(|p| p.id != publisher);
+ publishers.push(package::Publisher { id: publisher.into(), name: name.into(), keys });
+ Ok(publishers)
+}
+
+/// `index`, unless a newer `openidea-plugin` wrote it (rewriting it would drop what this one can't read).
+fn current_format(index: Index, file: &str) -> Result {
+ if index.is_newer() {
+ return Err(format!(
+ "{file}: index format {} is newer than this tool writes; update openidea-plugin",
+ index.format
+ ));
+ }
+ Ok(index)
+}
+
fn cmd_merge(args: &[String]) -> Result<(), String> {
let a = Args::parse(args, &[], &["keep", "out"])?;
let [base, other] = a.positional.as_slice() else {
@@ -649,9 +724,11 @@ fn cmd_merge(args: &[String]) -> Result<(), String> {
};
let keep = a.value("keep").ok_or("--keep is required")?;
let out = a.value("out").unwrap_or(base);
- let mut index = Index::parse(&std::fs::read_to_string(base).map_err(|e| format!("{base}: {e}"))?)?;
+ let mut index =
+ current_format(Index::parse(&std::fs::read_to_string(base).map_err(|e| format!("{base}: {e}"))?)?, base)?;
let bytes = read_market_url(other, 16 << 20)?;
- let mut theirs = Index::parse(&String::from_utf8(bytes).map_err(|_| format!("{other}: not UTF-8"))?)?;
+ let theirs = Index::parse(&String::from_utf8(bytes).map_err(|_| format!("{other}: not UTF-8"))?)?;
+ let mut theirs = current_format(theirs, other)?;
// Their downloads are relative to their index, not to ours.
for e in &mut theirs.plugins {
e.download = resolve_market_href(other, &e.download);
@@ -673,6 +750,25 @@ fn write_index(index: &Index, out: &Path) -> Result<(), String> {
mod tests {
use super::*;
+ #[test]
+ fn a_new_key_is_endorsed_by_the_previous_one() {
+ let (k1, k2, k3) =
+ (PublisherKey::from_seed([1; 32]), PublisherKey::from_seed([2; 32]), PublisherKey::from_seed([3; 32]));
+ let first = register_key(Vec::new(), "acme", "ACME", &k1, None).unwrap();
+ let rotated = register_key(first.clone(), "acme", "ACME", &k2, Some(&k1)).unwrap();
+ let index = Index { format: package::INDEX_FORMAT, publishers: rotated.clone(), ..Index::default() };
+ assert!(index.succeeds("acme", &k1.public_base64(), &k2.public_base64()));
+ assert_eq!(rotated[0].keys.len(), 2, "the earlier key stays listed");
+ // Registering the current key again keeps its endorsement.
+ let again = register_key(rotated.clone(), "acme", "ACME", &k2, None).unwrap();
+ assert_eq!(again, rotated);
+ // Only a key the publisher has can endorse.
+ assert!(register_key(first.clone(), "acme", "ACME", &k2, Some(&k3)).unwrap_err().contains("--previous-key"));
+ // Without one the new key is listed unendorsed.
+ let unendorsed = register_key(first, "acme", "ACME", &k3, None).unwrap();
+ assert!(unendorsed[0].keys[0].endorsement.is_none());
+ }
+
#[test]
fn names() {
assert_eq!(slugify("My Cool_Plugin"), "my-cool-plugin");
@@ -776,7 +872,7 @@ mod tests {
publishers: vec![package::Publisher {
id: "alice".into(),
name: "Alice".into(),
- keys: vec![KeyEntry { key: keys[1].1.clone(), revoked: false }],
+ keys: vec![KeyEntry::new(keys[1].1.clone())],
}],
..Index::default()
};
diff --git a/crates/ide-host/src/plugins/market.rs b/crates/ide-host/src/plugins/market.rs
index deab1ebce..061b5df29 100644
--- a/crates/ide-host/src/plugins/market.rs
+++ b/crates/ide-host/src/plugins/market.rs
@@ -3,11 +3,14 @@
//! plugins, and revocations. The host keeps the last index it read
//! (`market-index.json`), checks signatures against it, and disables what it
//! revokes — at start-up and on every refresh (the desktop refreshes daily).
+//! Release builds pin the official publisher to the keys they were built
+//! with, so no index can sign `openidea.*` plugins with another key.
-use super::{PackageFile, PluginHost, PluginSource, PluginStatus};
+use super::{PackageFile, PluginEvent, PluginHost, PluginSource, PluginStatus, PluginUpdate, install};
use ide_core::{IdeError, IdeResult};
use ide_extension::package::{self, Index, IndexEntry, PackageSignature, SIGNATURE_FILE};
use ide_extension::{MANIFEST_FILE, Manifest};
+use semver::Version;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
@@ -15,6 +18,9 @@ use std::path::{Path, PathBuf};
/// the app, next to the signed packages of the official plugins.
pub const OFFICIAL_INDEX: &str = "https://github.com/l0ng-ai/openidea/releases/latest/download/plugins-index.json";
+/// The publisher of the official plugins.
+pub const OFFICIAL_PUBLISHER: &str = "openidea";
+
/// The last index read, in the plugins directory.
pub const INDEX_FILE: &str = "market-index.json";
const DOWNLOADS: &str = ".downloads";
@@ -37,6 +43,30 @@ pub struct MarketPlugin {
pub compatible: bool,
}
+impl MarketPlugin {
+ /// A version this IDE can load, newer than the one installed.
+ pub fn is_update(&self) -> bool {
+ let newer = |installed: &String| match (Version::parse(&self.entry.version), Version::parse(installed)) {
+ (Ok(offered), Ok(installed)) => offered > installed,
+ _ => false,
+ };
+ self.compatible && self.installed.as_ref().is_some_and(newer)
+ }
+}
+
+/// The official publisher's keys this IDE was built with
+/// (`OPENIDEA_OFFICIAL_PLUGIN_KEYS`: base64, comma-separated). Development
+/// builds have none: the index decides.
+pub(super) fn built_in_official_keys() -> Vec {
+ let keys = option_env!("OPENIDEA_OFFICIAL_PLUGIN_KEYS").unwrap_or_default();
+ keys.split(',').map(str::trim).filter(|k| !k.is_empty()).map(str::to_owned).collect()
+}
+
+fn pinned(mut index: Index, official_keys: &[String]) -> Index {
+ index.pin_publisher(OFFICIAL_PUBLISHER, "OpenIDEA", official_keys);
+ index
+}
+
/// Reads `url`: `http(s)://…`, `file://…` or a local path.
pub fn read_url(url: &str, max: usize) -> Result, String> {
if url.starts_with("https://") || url.starts_with("http://") {
@@ -108,9 +138,16 @@ pub fn resolve(base: &str, href: &str) -> String {
}
impl PluginHost {
- pub(super) fn load_market_index(dir: &Path) -> Option {
+ pub(super) fn load_market_index(dir: &Path, official_keys: &[String]) -> Option {
let text = std::fs::read_to_string(dir.join(INDEX_FILE)).ok()?;
- Index::parse(&text).map_err(|e| tracing::warn!("{INDEX_FILE}: {e}")).ok()
+ let index = Index::parse(&text).map_err(|e| tracing::warn!("{INDEX_FILE}: {e}")).ok()?;
+ Some(pinned(index, official_keys))
+ }
+
+ /// Pins the official publisher to `keys` (base64) instead of the built-in
+ /// ones, from the next index read.
+ pub fn set_official_keys(&self, keys: Vec) {
+ *self.official_keys.write() = keys;
}
/// The marketplace index URL: the user's, else the default (none: no marketplace).
@@ -128,14 +165,16 @@ impl PluginHost {
self.save_state();
}
- /// Reads the index again, keeps it, and applies its revocations.
+ /// Reads the index again, keeps it, and applies its revocations. Announces
+ /// updates of installed plugins it hasn't announced yet.
pub fn refresh_market(&self) -> IdeResult> {
let url = self.market_url().ok_or_else(|| IdeError::unavailable("no plugin marketplace is configured"))?;
let index = read_index(&url)?;
if let Ok(json) = serde_json::to_string(&index) {
let _ = std::fs::write(self.plugins_dir.join(INDEX_FILE), json);
}
- *self.market.write() = Some(index);
+ let newer = index.is_newer().then_some(index.format);
+ *self.market.write() = Some(pinned(index, &self.official_keys.read()));
// Trust may have changed: who signed what, what is revoked.
let installed: Vec<_> =
self.plugins.read().values().filter(|p| p.source == PluginSource::Installed).cloned().collect();
@@ -144,7 +183,41 @@ impl PluginHost {
plugin.meta.lock().publisher = publisher;
}
self.apply_revocations();
- Ok(self.market_plugins())
+ if let Some(format) = newer {
+ return Err(IdeError::unavailable(format!(
+ "the marketplace needs a newer OpenIDEA (index format {format}); its revocations still apply"
+ )));
+ }
+ let plugins = self.market_plugins();
+ self.announce_updates(&plugins);
+ Ok(plugins)
+ }
+
+ /// Tells the windows about updates once per set of versions: a later
+ /// refresh offering the same ones says nothing.
+ fn announce_updates(&self, plugins: &[MarketPlugin]) {
+ let updates: Vec = plugins
+ .iter()
+ .filter(|p| p.is_update())
+ .map(|p| PluginUpdate {
+ id: p.entry.id.clone(),
+ name: p.entry.name.clone(),
+ version: p.entry.version.clone(),
+ })
+ .collect();
+ let key: Vec = updates.iter().map(|u| format!("{}@{}", u.id, u.version)).collect();
+ if std::mem::replace(&mut *self.announced_updates.lock(), key.clone()) != key && !updates.is_empty() {
+ self.emit(None, PluginEvent::UpdatesAvailable { updates });
+ }
+ }
+
+ /// Whether the package in `src` would replace the installed `id`'s copy
+ /// with one signed by another key than that copy, without that key's
+ /// endorsement: the user confirms the change of signer.
+ pub(super) fn signer_changed(&self, id: &str, src: &Path) -> bool {
+ let Some(current) = install::active_dir(&self.plugins_dir.join(id)) else { return false };
+ let market = self.market.read();
+ !package::signer_continues(market.as_ref(), read_signature(¤t).as_ref(), read_signature(src).as_ref())
}
/// The plugins of the last index read.
diff --git a/crates/ide-host/src/plugins/mod.rs b/crates/ide-host/src/plugins/mod.rs
index c32c028c0..0b82a9350 100644
--- a/crates/ide-host/src/plugins/mod.rs
+++ b/crates/ide-host/src/plugins/mod.rs
@@ -37,7 +37,7 @@ pub mod web;
pub use api::{FileSecretStore, HostWorkspace, PluginWorkspace, SecretStore};
pub use icons::{map_asset as icon_theme_map_asset, sanitize_svg, svg_prefix as icon_theme_svg_prefix};
pub use market::{
- INDEX_FILE as MARKET_INDEX_FILE, MarketPlugin, OFFICIAL_INDEX as OFFICIAL_MARKET_INDEX,
+ INDEX_FILE as MARKET_INDEX_FILE, MarketPlugin, OFFICIAL_INDEX as OFFICIAL_MARKET_INDEX, OFFICIAL_PUBLISHER,
read_url as read_market_url, resolve as resolve_market_href,
};
pub use points::{COMMANDS, CommandDecl, register_builtin_points};
@@ -245,12 +245,16 @@ pub struct Consent {
/// Optional capabilities the user accepted (`net:crates.io`, …). Required
/// ones are implied by consenting at all.
pub optional: Vec,
+ /// The user accepted a package signed by another key than the installed
+ /// copy ([`InstallPreview::signer_changed`]).
+ #[serde(default)]
+ pub signer_change: bool,
}
impl Consent {
/// Accept everything the plugin asks for (tests, trusted official plugins).
pub fn all() -> Self {
- Self { optional: vec!["*".into()] }
+ Self { optional: vec!["*".into()], signer_change: true }
}
fn accepts(&self, cap: &Capability) -> bool {
@@ -277,6 +281,14 @@ pub struct InstallPreview {
pub unsigned: bool,
/// The verified publisher.
pub publisher: Option,
+ /// Replaces an installed copy signed with another key, which didn't
+ /// endorse this package's (or this one is unsigned): the user confirms it
+ /// ([`Consent::signer_change`]).
+ pub signer_changed: bool,
+ /// Something to confirm: a first install, an unsigned package, another
+ /// signer, or capabilities the installed version wasn't granted or
+ /// refused. Without, an update installs with the grants it has.
+ pub needs_consent: bool,
}
type ProjectPolicy = Arc bool + Send + Sync>;
@@ -323,6 +335,10 @@ pub struct PluginHost {
pins: Arc>>,
/// The last marketplace index read: trust root for signatures, revocations.
market: RwLock
)}
+ {req.signerChanged && (
+
+
+ This version is signed with a different key than the installed one, and the publisher's earlier key
+ doesn't vouch for it. Only continue if you know the publisher changed keys.
+
+
+ )}
{caps.length === 0 ? (
This plugin needs no special permissions.
diff --git a/src/features/plugins/index.ts b/src/features/plugins/index.ts
index 9a90df93b..57e1c11a0 100644
--- a/src/features/plugins/index.ts
+++ b/src/features/plugins/index.ts
@@ -33,7 +33,7 @@ import { defineBuiltinUiPoints } from "./points";
import { startViewUi } from "./uiPoints";
import { importIntellijTheme, showInstalledIntellijThemes } from "./intellijImport";
import { IntellijThemesDialogHost } from "./IntellijThemesDialog";
-import { installFromDisk, refreshPlugins, reloadPlugin, SETTINGS_PAGE_ID, usePlugins } from "./store";
+import { installFromDisk, refreshPlugins, reloadPlugin, SETTINGS_PAGE_ID, updateAllPlugins, usePlugins } from "./store";
function registerPluginsPage(): () => void {
return registerSettingsPage({
@@ -89,6 +89,16 @@ export function activate(): () => void {
await installFromDisk(typeof arg === "string" ? arg : undefined);
},
},
+ {
+ id: "plugins.updateAll",
+ title: "Update All Plugins",
+ category: "Plugins",
+ icon: "plugin",
+ keywords: ["upgrade", "marketplace", "new version"],
+ run: async () => {
+ await updateAllPlugins();
+ },
+ },
{
id: "plugins.importIntellijTheme",
title: "Import IntelliJ Theme from File…",
diff --git a/src/features/plugins/store.ts b/src/features/plugins/store.ts
index c8defd2d1..797b7658b 100644
--- a/src/features/plugins/store.ts
+++ b/src/features/plugins/store.ts
@@ -71,6 +71,8 @@ export interface ConsentRequest {
capabilities: CapabilityInfo[];
/** Unsigned package (from disk or a project folder). */
unsigned: boolean;
+ /** Replaces an installed copy signed with another key that didn't vouch for this one. */
+ signerChanged?: boolean;
warnings: string[];
}
@@ -209,8 +211,13 @@ export async function setMarketUrl(url: string | null): Promise {
await refreshMarket(true);
}
+/** A version this IDE can load, newer than the one installed. */
+export function isUpdate(entry: MarketPlugin): boolean {
+ return entry.compatible && !!entry.installed && compareVersions(entry.version, entry.installed) > 0;
+}
+
/** Downloads (and checks) a marketplace plugin, then the install page as from disk. */
-export async function installFromMarket(id: string): Promise {
+export async function installFromMarket(id: string, options: InstallOptions = {}): Promise {
usePlugins.setState({ installing: true });
let dir: string;
try {
@@ -225,11 +232,27 @@ export async function installFromMarket(id: string): Promise {
});
return null;
}
- const installed = await installFromDisk(dir);
+ const installed = await installFromDisk(dir, false, options);
void refreshMarket(false);
return installed;
}
+/**
+ * Installs every update the marketplace offers, one after another. Updates
+ * that need nothing confirmed install right away; the others show their
+ * install page. Returns how many were installed.
+ */
+export async function updateAllPlugins(): Promise {
+ await refreshMarket(true);
+ const updates = (usePlugins.getState().market ?? []).filter(isUpdate);
+ let updated = 0;
+ for (const entry of updates) if (await installFromMarket(entry.id, { quiet: true })) updated++;
+ if (updated > 0) {
+ notifications().notify({ group: "plugins", level: "info", title: `Updated ${updated} plugins` });
+ }
+ return updated;
+}
+
/** Settings › Plugins › Roll Back: the version before the last update. */
export async function rollBackPlugin(id: string): Promise {
try {
@@ -274,12 +297,22 @@ async function packageFolder(backend: PluginsBackend, picked: string): Promise {
+export async function installFromDisk(
+ path?: string,
+ packageFile = false,
+ options: InstallOptions = {},
+): Promise {
const backend = pluginsBackend();
let dir: string | null = path ?? null;
try {
@@ -300,24 +333,29 @@ export async function installFromDisk(path?: string, packageFile = false): Promi
let installed: string | null = null;
try {
const preview = await backend.previewInstall(dir);
- const consent = await askConsent({
- pluginId: preview.id,
- name: preview.name,
- version: preview.version,
- kind: "install",
- capabilities: preview.capabilities,
- unsigned: preview.unsigned,
- warnings: preview.warnings,
- });
+ const consent: Consent | null = preview.needsConsent
+ ? await askConsent({
+ pluginId: preview.id,
+ name: preview.name,
+ version: preview.version,
+ kind: "install",
+ capabilities: preview.capabilities,
+ unsigned: preview.unsigned,
+ signerChanged: preview.signerChanged,
+ warnings: preview.warnings,
+ })
+ : { optional: preview.capabilities.filter((c) => c.optional && c.granted).map((c) => c.id) };
if (!consent) return null;
const info = await backend.installDir(dir, consent);
installed = info.status === "failed" ? null : info.id;
- notifications().notify({
- group: "plugins",
- level: info.status === "failed" ? "warning" : "info",
- title: `Installed ${info.name} ${info.version}`,
- body: info.status === "failed" ? (info.error ?? "The plugin failed to start.") : undefined,
- });
+ if (info.status === "failed" || !options.quiet) {
+ notifications().notify({
+ group: "plugins",
+ level: info.status === "failed" ? "warning" : "info",
+ title: `Installed ${info.name} ${info.version}`,
+ body: info.status === "failed" ? (info.error ?? "The plugin failed to start.") : undefined,
+ });
+ }
return info.id;
} catch (e) {
notifications().notify({ group: "plugins", level: "error", title: "Could not install plugin", body: errorText(e) });
diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json
index 507c77d7e..02cc037fd 100644
--- a/src/platform/locales/text/zh.json
+++ b/src/platform/locales/text/zh.json
@@ -6069,5 +6069,13 @@
"AI": "人工智能",
"Frameworks": "框架",
"All Categories": "全部分类",
- "Category": "分类"
+ "Category": "分类",
+ "Update All Plugins": "更新全部插件",
+ "Update All Plugins ({updates})": "更新全部插件({updates})",
+ "Update All": "全部更新",
+ "Show Plugins": "查看插件",
+ "Plugin update available": "有插件可更新",
+ "{length} plugin updates available": "{length} 个插件可更新",
+ "Updated {updated} plugins": "已更新 {updated} 个插件",
+ "This version is signed with a different key than the installed one, and the publisher's earlier key doesn't vouch for it. Only continue if you know the publisher changed keys.": "此版本的签名密钥与已安装的版本不同,且发布者之前的密钥没有为它背书。只有在确认发布者更换了密钥时才继续。"
}