дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
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.
Цей коміт міститься в:
1 джерело
fa7710cd4a
коміт
0024a1c705
23 files changed
+1058
-114
No files matched your search
@@ -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:
|
||||
|
||||
Створено
@@ -4276,6 +4276,7 @@ dependencies = [
|
||||
"regex-syntax",
|
||||
"reqwest",
|
||||
"rustix",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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<String> {
|
||||
let pick = |f: &dyn Fn(&str) -> bool| CATEGORIES.iter().filter(|c| f(c)).map(|c| c.to_string()).collect();
|
||||
let mut out: Vec<String> = 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<String> = 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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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<u8> {
|
||||
format!("openidea-plugin/1\n{id}\n{version}\n{digest}").into_bytes()
|
||||
}
|
||||
|
||||
fn endorsement_message(publisher: &str, key: &str) -> Vec<u8> {
|
||||
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<bool, String> {
|
||||
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<KeyEntry>,
|
||||
}
|
||||
|
||||
#[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<Endorsement>,
|
||||
}
|
||||
|
||||
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<publisher>\n<endorsed key>`, base64.
|
||||
pub signature: String,
|
||||
}
|
||||
|
||||
/// A published plugin version.
|
||||
@@ -289,7 +334,7 @@ pub struct IndexEntry {
|
||||
pub capabilities: Vec<String>,
|
||||
/// 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<semver::Version> {
|
||||
@@ -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<Self, String> {
|
||||
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<T: serde::de::DeserializeOwned>(value: &serde_json::Value, field: &str) -> Vec<T> {
|
||||
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<KeyEntry> {
|
||||
&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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
//! openidea-plugin unlink [dir]
|
||||
//! openidea-plugin links # list linked folders
|
||||
//! openidea-plugin keygen <key-file> # a publisher's Ed25519 key
|
||||
//! openidea-plugin endorse <new-public-key> --key OLD --publisher ID # vouch for a new key
|
||||
//! openidea-plugin pack [dir] [--key F --publisher ID] [--out FILE] # <id>-<version>.oiplugin
|
||||
//! openidea-plugin index <dir> [--publisher ID --name NAME --key F] # marketplace index.json
|
||||
//! openidea-plugin index <dir> [--publisher ID --name NAME --key F [--previous-key F]] # marketplace index.json
|
||||
//! openidea-plugin merge <index> <other-index> --keep ID # list another index's publishers
|
||||
//! ```
|
||||
//!
|
||||
@@ -39,13 +40,19 @@ commands:
|
||||
unlink [dir] stop loading a linked folder
|
||||
links list linked folders
|
||||
keygen <key-file> create a publisher signing key (keep the file secret)
|
||||
endorse <new-public-key> --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 <id>-<version>.oiplugin, signed with the key
|
||||
index <dir> [--publisher ID --name NAME --key F] [--out FILE] [--download-base URL]
|
||||
index <dir> [--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 <index> <other-index> --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<String> = 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 <new-public-key> --key <earlier-key-file> --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::TempDir, String> {
|
||||
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<KeyEntry> = 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<KeyEntry> =
|
||||
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<package::Publisher>,
|
||||
publisher: &str,
|
||||
name: &str,
|
||||
key: &PublisherKey,
|
||||
previous: Option<&PublisherKey>,
|
||||
) -> Result<Vec<package::Publisher>, String> {
|
||||
let current = key.public_base64();
|
||||
let earlier: Vec<KeyEntry> = 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<Index, String> {
|
||||
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()
|
||||
};
|
||||
|
||||
@@ -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<String> {
|
||||
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<Vec<u8>, 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<Index> {
|
||||
pub(super) fn load_market_index(dir: &Path, official_keys: &[String]) -> Option<Index> {
|
||||
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<String>) {
|
||||
*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<Vec<MarketPlugin>> {
|
||||
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<PluginUpdate> = 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<String> = 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.
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<String>,
|
||||
/// 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<dyn Fn(&Path) -> bool + Send + Sync>;
|
||||
@@ -323,6 +335,10 @@ pub struct PluginHost {
|
||||
pins: Arc<RwLock<HashMap<(String, String), ProgramPin>>>,
|
||||
/// The last marketplace index read: trust root for signatures, revocations.
|
||||
market: RwLock<Option<ide_extension::package::Index>>,
|
||||
/// The official publisher's keys the index is pinned to (none: as listed).
|
||||
official_keys: RwLock<Vec<String>>,
|
||||
/// `id@version` of the updates last announced.
|
||||
announced_updates: Mutex<Vec<String>>,
|
||||
/// The marketplace used while the user has set none (the app's official one).
|
||||
default_market_url: RwLock<Option<String>>,
|
||||
/// Updates not confirmed yet (the new version hasn't started once): what
|
||||
@@ -361,9 +377,12 @@ impl PluginHost {
|
||||
policy_pins.read().get(&(plugin.to_owned(), cap.to_string())).cloned()
|
||||
}));
|
||||
let shared = api::Shared::new(sink.clone(), plugins_dir.join(".data"), pins.clone(), ui.clone());
|
||||
let market = RwLock::new(Self::load_market_index(&plugins_dir));
|
||||
let official_keys = market::built_in_official_keys();
|
||||
let market = RwLock::new(Self::load_market_index(&plugins_dir, &official_keys));
|
||||
let host = Self {
|
||||
market,
|
||||
official_keys: RwLock::new(official_keys),
|
||||
announced_updates: Mutex::default(),
|
||||
default_market_url: RwLock::new(None),
|
||||
updates: Mutex::default(),
|
||||
project_policy: RwLock::new(Arc::new(|_| false)),
|
||||
@@ -706,14 +725,27 @@ impl PluginHost {
|
||||
}
|
||||
let publisher = self.verified_publisher(src);
|
||||
let m = &pkg.manifest;
|
||||
let replaces = self.plugins.read().get(&m.id).map(|p| p.manifest().version.clone());
|
||||
// An update shows what the installed version was granted.
|
||||
let grant = replaces
|
||||
.as_ref()
|
||||
.and_then(|_| self.ledger.lock().get(&Subject::Installed(m.id.clone())).cloned())
|
||||
.filter(|g| g.confirmed);
|
||||
let granted = |c: &Capability| grant.as_ref().is_some_and(|g| g.has(c));
|
||||
let mut capabilities: Vec<CapabilityInfo> =
|
||||
m.capabilities.all().map(|c| CapabilityInfo::new(c, m.capabilities.is_optional(c), false)).collect();
|
||||
m.capabilities.all().map(|c| CapabilityInfo::new(c, m.capabilities.is_optional(c), granted(c))).collect();
|
||||
for extra in &set.capabilities {
|
||||
if !m.declares(extra) {
|
||||
capabilities.push(CapabilityInfo::new(extra, true, false));
|
||||
capabilities.push(CapabilityInfo::new(extra, true, granted(extra)));
|
||||
}
|
||||
}
|
||||
capabilities.sort_by(|a, b| b.risk.cmp(&a.risk).then(a.id.cmp(&b.id)));
|
||||
let signer_changed = self.signer_changed(&m.id, src);
|
||||
let answered = |c: &CapabilityInfo| {
|
||||
c.granted || (c.optional && grant.as_ref().is_some_and(|g| g.declined.contains(&c.id)))
|
||||
};
|
||||
let needs_consent =
|
||||
grant.is_none() || publisher.is_none() || signer_changed || !capabilities.iter().all(answered);
|
||||
Ok(InstallPreview {
|
||||
id: m.id.clone(),
|
||||
name: m.name.clone(),
|
||||
@@ -724,9 +756,11 @@ impl PluginHost {
|
||||
capabilities,
|
||||
contributions: counts(m),
|
||||
warnings: set.warnings.clone(),
|
||||
replaces: self.plugins.read().get(&m.id).map(|p| p.manifest().version.clone()),
|
||||
replaces,
|
||||
unsigned: publisher.is_none(),
|
||||
publisher,
|
||||
signer_changed,
|
||||
needs_consent,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -749,6 +783,11 @@ impl PluginHost {
|
||||
if let Some(problem) = self.signature_problem(src) {
|
||||
return Err(IdeError::invalid(format!("refusing {id}: {problem}")));
|
||||
}
|
||||
if !consent.signer_change && self.signer_changed(&id, src) {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"refusing {id}: it is signed by another key than the installed version; confirm the change of signer"
|
||||
)));
|
||||
}
|
||||
let version = pkg.manifest.version.clone();
|
||||
if let Some(r) = self.market.read().as_ref().and_then(|i| i.revocation(&id, &version)) {
|
||||
return Err(IdeError::invalid(format!("{id} {version} was revoked: {}", r.reason)));
|
||||
|
||||
@@ -272,7 +272,8 @@ impl RemotePlugins {
|
||||
report.failed.push(format!("{}: invalid plugin id", pkg.id));
|
||||
continue;
|
||||
}
|
||||
let consent = Consent { optional: pkg.granted.clone() };
|
||||
// The desktop asked the user, change of signer included.
|
||||
let consent = Consent { optional: pkg.granted.clone(), signer_change: true };
|
||||
if self.current_digest(&pkg.id).as_deref() == Some(pkg.digest.as_str()) {
|
||||
if let Err(e) = self.follow_grants(&pkg, &consent) {
|
||||
report.failed.push(format!("{}: {e}", pkg.id));
|
||||
|
||||
@@ -138,12 +138,24 @@ pub struct PluginsSnapshot {
|
||||
pub ui: Vec<UiContribution>,
|
||||
}
|
||||
|
||||
/// A newer version of an installed plugin in the marketplace.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginUpdate {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
/// Backend → UI notifications (Tauri event `ide://plugin`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum PluginEvent {
|
||||
/// Plugins, their status or their contributions changed: re-fetch `plugin_list`.
|
||||
CommandsChanged,
|
||||
/// The marketplace offers newer versions of installed plugins (announced
|
||||
/// once per set of versions).
|
||||
UpdatesAvailable { updates: Vec<PluginUpdate> },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Notification { plugin_id: String, level: NotificationLevel, title: String, body: String },
|
||||
/// A plugin's status bar item was set (`item`) or removed.
|
||||
|
||||
@@ -131,22 +131,39 @@ fn installs_from_before_versioning_keep_working_and_move_on_update() {
|
||||
|
||||
/// `hello` as `acme.hello` at `version`, packed and signed with `key`.
|
||||
fn signed_hello(dir: &Path, version: &str, key: &PublisherKey, host: &PluginHost) -> (PathBuf, IndexEntry) {
|
||||
let src = variant(&repo().join("crates/ide-plugin/tests/fixtures/hello"), dir, &format!("src-{version}"), |t| {
|
||||
t.replace("id = \"hello\"", "id = \"acme.hello\"")
|
||||
.replace("version = \"0.3.0\"", &format!("version = \"{version}\""))
|
||||
});
|
||||
signed(dir, "acme", version, key, host, |t| t.to_owned())
|
||||
}
|
||||
|
||||
/// `hello` as `<publisher>.hello` at `version`, its manifest changed by
|
||||
/// `edit`, packed and signed with `key`.
|
||||
fn signed(
|
||||
dir: &Path,
|
||||
publisher: &str,
|
||||
version: &str,
|
||||
key: &PublisherKey,
|
||||
host: &PluginHost,
|
||||
edit: impl Fn(&str) -> String,
|
||||
) -> (PathBuf, IndexEntry) {
|
||||
let id = format!("{publisher}.hello");
|
||||
let src =
|
||||
variant(&repo().join("crates/ide-plugin/tests/fixtures/hello"), dir, &format!("src-{id}-{version}"), |t| {
|
||||
edit(
|
||||
&t.replace("id = \"hello\"", &format!("id = \"{id}\""))
|
||||
.replace("version = \"0.3.0\"", &format!("version = \"{version}\"")),
|
||||
)
|
||||
});
|
||||
let files = host.package_files_of(&src).unwrap();
|
||||
let digest = package::package_digest(&src, &files).unwrap();
|
||||
let sig = key.sign("acme", "acme.hello", version, &digest);
|
||||
let out = dir.join(format!("acme.hello-{version}.oiplugin"));
|
||||
let sig = key.sign(publisher, &id, version, &digest);
|
||||
let out = dir.join(format!("{id}-{version}.oiplugin"));
|
||||
package::pack(&src, &files, &out, Some(&sig)).unwrap();
|
||||
let entry = IndexEntry {
|
||||
id: "acme.hello".into(),
|
||||
id: id.clone(),
|
||||
name: "Hello".into(),
|
||||
version: version.into(),
|
||||
description: String::new(),
|
||||
authors: vec![],
|
||||
publisher: "acme".into(),
|
||||
publisher: publisher.into(),
|
||||
download: out.file_name().unwrap().to_string_lossy().into_owned(),
|
||||
digest,
|
||||
capabilities: vec![],
|
||||
@@ -168,7 +185,7 @@ fn write_index(
|
||||
publishers: vec![Publisher {
|
||||
id: "acme".into(),
|
||||
name: "ACME".into(),
|
||||
keys: vec![KeyEntry { key: key.public_base64(), revoked: key_revoked }],
|
||||
keys: vec![KeyEntry { revoked: key_revoked, ..KeyEntry::new(key.public_base64()) }],
|
||||
}],
|
||||
plugins,
|
||||
revoked,
|
||||
@@ -313,3 +330,160 @@ fn a_moved_marketplace_is_read_where_it_moved_to() {
|
||||
let err = f.host.refresh_market().unwrap_err();
|
||||
assert!(err.message.contains("moved more than"), "{}", err.message);
|
||||
}
|
||||
|
||||
/// `dir/index.json` listing `publishers` and `plugins`; its path.
|
||||
fn index_of(dir: &Path, publishers: Vec<Publisher>, plugins: Vec<IndexEntry>) -> String {
|
||||
let index = Index { format: package::INDEX_FORMAT, publishers, plugins, ..Index::default() };
|
||||
let path = dir.join("index.json");
|
||||
std::fs::write(&path, serde_json::to_string(&index).unwrap()).unwrap();
|
||||
path.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn publisher(id: &str, keys: Vec<KeyEntry>) -> Publisher {
|
||||
Publisher { id: id.into(), name: id.into(), keys }
|
||||
}
|
||||
|
||||
fn updates_announced(events: &Events) -> Vec<Vec<String>> {
|
||||
events
|
||||
.lock()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
PluginEvent::UpdatesAvailable { updates } => {
|
||||
Some(updates.iter().map(|u| format!("{} {}", u.id, u.version)).collect())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// What the install page sends for an update it doesn't need to show: the grants the installed version has.
|
||||
fn as_granted(preview: &InstallPreview) -> Consent {
|
||||
let optional = preview.capabilities.iter().filter(|c| c.optional && c.granted).map(|c| c.id.clone()).collect();
|
||||
Consent { optional, signer_change: false }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn updates_keep_their_signer_and_need_no_consent_when_nothing_is_new() {
|
||||
let f = fixture();
|
||||
let market = f.work.join("market");
|
||||
std::fs::create_dir_all(&market).unwrap();
|
||||
let (k1, k2, k3) =
|
||||
(PublisherKey::from_seed([1; 32]), PublisherKey::from_seed([2; 32]), PublisherKey::from_seed([3; 32]));
|
||||
let same = |t: &str| t.to_owned();
|
||||
let (_, v1) = signed(&market, "acme", "1.0.0", &k1, &f.host, same);
|
||||
f.host.set_market_url(Some(index_of(
|
||||
&market,
|
||||
vec![publisher("acme", vec![KeyEntry::new(k1.public_base64())])],
|
||||
vec![v1.clone()],
|
||||
)));
|
||||
f.host.refresh_market().unwrap();
|
||||
let dir = f.host.download_market_plugin("acme.hello").unwrap();
|
||||
let first = f.host.preview_install(&dir).unwrap();
|
||||
assert!(first.needs_consent && !first.signer_changed, "a first install is confirmed");
|
||||
f.host.install_from_dir(&dir, &Consent::default()).unwrap();
|
||||
assert!(updates_announced(&f.events).is_empty());
|
||||
|
||||
// 1.1.0, same key and needs: announced once, installed with the grants it has.
|
||||
let (_, v11) = signed(&market, "acme", "1.1.0", &k1, &f.host, same);
|
||||
let keys1 = vec![KeyEntry::new(k1.public_base64())];
|
||||
index_of(&market, vec![publisher("acme", keys1.clone())], vec![v1.clone(), v11.clone()]);
|
||||
f.host.refresh_market().unwrap();
|
||||
f.host.refresh_market().unwrap();
|
||||
assert_eq!(updates_announced(&f.events), [["acme.hello 1.1.0"]], "once per set of versions");
|
||||
let dir = f.host.download_market_plugin("acme.hello").unwrap();
|
||||
let preview = f.host.preview_install(&dir).unwrap();
|
||||
assert_eq!(preview.replaces.as_deref(), Some("1.0.0"));
|
||||
assert!(!preview.needs_consent && !preview.signer_changed);
|
||||
assert!(preview.capabilities.iter().all(|c| c.granted), "shows what the installed version was granted");
|
||||
f.host.install_from_dir(&dir, &as_granted(&preview)).unwrap();
|
||||
assert!(f.host.market_plugins().iter().all(|p| !p.is_update()));
|
||||
|
||||
// 1.2.0 asks for something new: the user is asked.
|
||||
let (_, v12) = signed(&market, "acme", "1.2.0", &k1, &f.host, |t| {
|
||||
t.replace(
|
||||
"required = [\"editor.read\", \"editor.write\"]",
|
||||
"required = [\"editor.read\", \"editor.write\"]\noptional = [\"net:example.com\"]",
|
||||
)
|
||||
});
|
||||
index_of(&market, vec![publisher("acme", keys1.clone())], vec![v12.clone()]);
|
||||
f.host.refresh_market().unwrap();
|
||||
let dir = f.host.download_market_plugin("acme.hello").unwrap();
|
||||
let preview = f.host.preview_install(&dir).unwrap();
|
||||
assert!(preview.needs_consent && !preview.signer_changed);
|
||||
f.host.install_from_dir(&dir, &Consent::default()).unwrap();
|
||||
|
||||
// 1.3.0 signed with a key the installed one didn't endorse: another signer, the user's call.
|
||||
let (_, v13) = signed(&market, "acme", "1.3.0", &k2, &f.host, same);
|
||||
let keys12 = vec![KeyEntry::new(k1.public_base64()), KeyEntry::new(k2.public_base64())];
|
||||
index_of(&market, vec![publisher("acme", keys12)], vec![v13]);
|
||||
f.host.refresh_market().unwrap();
|
||||
let dir = f.host.download_market_plugin("acme.hello").unwrap();
|
||||
let preview = f.host.preview_install(&dir).unwrap();
|
||||
assert!(preview.signer_changed && preview.needs_consent);
|
||||
let err = f.host.install_from_dir(&dir, &as_granted(&preview)).unwrap_err();
|
||||
assert!(err.message.contains("another key"), "{}", err.message);
|
||||
assert_eq!(f.host.info_of("acme.hello").unwrap().version, "1.2.0", "nothing changed");
|
||||
f.host.install_from_dir(&dir, &Consent { signer_change: true, ..as_granted(&preview) }).unwrap();
|
||||
|
||||
// 1.4.0 signed with k3, which k2 endorsed: a rotation the installed signer vouched for.
|
||||
let (_, v14) = signed(&market, "acme", "1.4.0", &k3, &f.host, same);
|
||||
let keys = vec![
|
||||
KeyEntry { endorsement: Some(k2.endorse("acme", &k3.public_base64())), ..KeyEntry::new(k3.public_base64()) },
|
||||
KeyEntry::new(k2.public_base64()),
|
||||
];
|
||||
index_of(&market, vec![publisher("acme", keys)], vec![v14]);
|
||||
f.host.refresh_market().unwrap();
|
||||
let dir = f.host.download_market_plugin("acme.hello").unwrap();
|
||||
let preview = f.host.preview_install(&dir).unwrap();
|
||||
assert!(!preview.signer_changed && !preview.needs_consent);
|
||||
f.host.install_from_dir(&dir, &as_granted(&preview)).unwrap();
|
||||
assert_eq!(f.host.info_of("acme.hello").unwrap().version, "1.4.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_index_in_a_newer_format_still_revokes() {
|
||||
let f = fixture();
|
||||
let market = f.work.join("market");
|
||||
std::fs::create_dir_all(&market).unwrap();
|
||||
let key = PublisherKey::from_seed([3; 32]);
|
||||
let (_, entry) = signed_hello(&market, "1.0.0", &key, &f.host);
|
||||
f.host.set_market_url(Some(write_index(&market, &key, vec![entry], vec![], false)));
|
||||
f.host.refresh_market().unwrap();
|
||||
let dir = f.host.download_market_plugin("acme.hello").unwrap();
|
||||
f.host.install_from_dir(&dir, &Consent::all()).unwrap();
|
||||
|
||||
let newer = format!(
|
||||
r#"{{"format": {}, "plugins": "a shape this IDE can't read",
|
||||
"revoked": [{{"id": "acme.hello", "reason": "steals tokens"}}]}}"#,
|
||||
package::INDEX_FORMAT + 1
|
||||
);
|
||||
std::fs::write(market.join("index.json"), newer).unwrap();
|
||||
let err = f.host.refresh_market().unwrap_err();
|
||||
assert!(err.message.contains("newer OpenIDEA"), "{}", err.message);
|
||||
let info = f.host.info_of("acme.hello").unwrap();
|
||||
assert_eq!((info.status, info.error.as_deref()), (PluginStatus::Failed, Some("revoked: steals tokens")));
|
||||
assert!(f.host.market_plugins().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_plugins_need_the_key_this_ide_was_built_with() {
|
||||
let f = fixture();
|
||||
let market = f.work.join("market");
|
||||
std::fs::create_dir_all(&market).unwrap();
|
||||
let (root, intruder) = (PublisherKey::from_seed([1; 32]), PublisherKey::from_seed([6; 32]));
|
||||
f.host.set_official_keys(vec![root.public_base64()]);
|
||||
// The index lists the intruder's key for the official publisher.
|
||||
let (_, forged) = signed(&market, OFFICIAL_PUBLISHER, "1.0.0", &intruder, &f.host, |t| t.to_owned());
|
||||
let keys = vec![KeyEntry::new(intruder.public_base64())];
|
||||
f.host.set_market_url(Some(index_of(&market, vec![publisher(OFFICIAL_PUBLISHER, keys.clone())], vec![forged])));
|
||||
f.host.refresh_market().unwrap();
|
||||
let err = f.host.download_market_plugin("openidea.hello").unwrap_err();
|
||||
assert!(err.message.contains("trusted publisher"), "{}", err.message);
|
||||
|
||||
// Signed with the built-in key it installs, whatever keys the index lists.
|
||||
let (_, genuine) = signed(&market, OFFICIAL_PUBLISHER, "1.0.1", &root, &f.host, |t| t.to_owned());
|
||||
index_of(&market, vec![publisher(OFFICIAL_PUBLISHER, keys)], vec![genuine]);
|
||||
f.host.refresh_market().unwrap();
|
||||
let dir = f.host.download_market_plugin("openidea.hello").unwrap();
|
||||
assert_eq!(f.host.preview_install(&dir).unwrap().publisher.as_deref(), Some(OFFICIAL_PUBLISHER));
|
||||
}
|
||||
@@ -99,7 +99,8 @@ when = "editorFocus" # optional: same expressions as built-in actio
|
||||
`themes`, `icon-themes`, `linters` (linters and formatters), `vcs`, `ai`,
|
||||
`frameworks`, `tools`, `other`. Without them, a plugin contributing themes,
|
||||
icon themes or languages is filed under those, any other under `other`.
|
||||
Categories an IDE doesn't know are ignored (`check` warns about them).
|
||||
Categories an IDE doesn't know, and any after the first three it knows, are
|
||||
ignored (`check` warns about both).
|
||||
|
||||
All paths are relative to the package directory and may not leave it. A
|
||||
manifest is validated as a whole: if one contribution is invalid, nothing is
|
||||
@@ -746,6 +747,12 @@ granted, and starts the approved program.
|
||||
the version it replaced automatically (you're told why); _Roll Back to …_
|
||||
in the plugin's details does it by hand. Data (`storage`) is per plugin, not
|
||||
per version.
|
||||
- **Updates:** when the daily marketplace check finds newer versions of
|
||||
installed plugins, the IDE says so once, with _Update All_ (also in the gear
|
||||
menu and as the _Update All Plugins_ action). An update that asks for
|
||||
nothing new installs with the grants the installed version has; the install
|
||||
page is shown only for new capabilities, an unsigned package or another
|
||||
signer (see _Distribution_).
|
||||
- **Linked plugins:** `openidea-plugin link <dir>` (§1). Listed with a _linked_
|
||||
tag; capabilities are granted per folder.
|
||||
- **Project plugins:** put (or symlink) the package in
|
||||
@@ -767,6 +774,7 @@ openidea-plugin pack plugins/acme-tools --key acme.key --publisher acme
|
||||
openidea-plugin index market/ --publisher acme --name "ACME" --key acme.key
|
||||
openidea-plugin check acme.tools-1.0.0.oiplugin # a package as installing would check it
|
||||
openidea-plugin merge market/index.json https://example.com/index.json --keep acme
|
||||
openidea-plugin endorse <new public key> --key acme.key --publisher acme # when rotating keys
|
||||
```
|
||||
|
||||
A **marketplace** is static: an `index.json` and the packages next to it, on
|
||||
@@ -782,15 +790,32 @@ and the publisher's key, then shows the install page.
|
||||
- **Revocation:** the index can revoke plugin versions (or all versions) and
|
||||
publisher keys. The IDE reads the index daily; revoked plugins are disabled
|
||||
with the reason and the user is notified — also after a restart, before
|
||||
their code could run. A publisher rotates keys by adding the new one; older
|
||||
keys stay valid until revoked.
|
||||
their code could run.
|
||||
- **Key rotation:** a publisher rotates keys by registering the new one with
|
||||
`index --key new.key --previous-key old.key`: the old key endorses the new
|
||||
one, and older keys stay valid until revoked (`endorse` prints the
|
||||
endorsement for an index someone else writes, such as the registry's). An installed plugin updates
|
||||
silently only to a package signed with the key it was installed with, or
|
||||
one that key endorsed (directly or through endorsed keys; endorsements by
|
||||
revoked keys don't count). Another signer — an unendorsed key, or an
|
||||
unsigned package replacing a signed one — is shown on the install page and
|
||||
needs the user's confirmation.
|
||||
- **Official key:** release builds carry the official publisher's public key
|
||||
(`OPENIDEA_OFFICIAL_PLUGIN_KEYS`, derived from the release's signing key).
|
||||
`openidea.*` plugins must be signed with it, or with a key it endorsed,
|
||||
whatever keys an index lists for `openidea`.
|
||||
- **Format:** fields are only ever added to the index (an IDE ignores those it
|
||||
doesn't know), and `format` stays `1` unless older IDEs can't read the
|
||||
change. An IDE reading a newer format still applies its revocations and
|
||||
`moved`, lists no plugins, and asks the user to update.
|
||||
- **Moving:** an index with `"moved": "<url>"` sends the IDE to the index at
|
||||
that URL (relative or absolute; https stays https), so a marketplace can
|
||||
change hosts while released IDEs keep the address they were built with.
|
||||
|
||||
**Versions.** Each index entry records the plugin API it needs (`engine`: the
|
||||
manifest's `engines.openidea`, narrowed to the API its component was built
|
||||
for), and a plugin may be listed in several versions. Settings › Plugins
|
||||
for; an entry without one is offered to no IDE), and a plugin may be listed in
|
||||
several versions. Settings › Plugins
|
||||
offers the newest version this IDE can load; when there is none, it shows the
|
||||
plugin as incompatible and says which API it needs. Entries also carry the
|
||||
plugin's categories, which Settings › Plugins › Available filters by. Indexing keeps, of each
|
||||
|
||||
+31
-2
@@ -2239,6 +2239,11 @@ export type Consent = {
|
||||
* ones are implied by consenting at all.
|
||||
*/
|
||||
optional: string[],
|
||||
/**
|
||||
* The user accepted a package signed by another key than the installed
|
||||
* copy ([`InstallPreview::signer_changed`]).
|
||||
*/
|
||||
signerChange?: boolean,
|
||||
};
|
||||
|
||||
/** Build, Execution, Deployment › Console. */
|
||||
@@ -4142,7 +4147,7 @@ export type IndexEntry = {
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
engine?: string,
|
||||
/** Marketplace categories (`CATEGORIES`); an IDE ignores those it doesn't know. */
|
||||
@@ -4304,6 +4309,18 @@ export type InstallPreview = {
|
||||
unsigned: boolean,
|
||||
/** The verified publisher. */
|
||||
publisher: string | null,
|
||||
/**
|
||||
* 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`]).
|
||||
*/
|
||||
signerChanged: boolean,
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
needsConsent: boolean,
|
||||
};
|
||||
|
||||
/** A theme of a plugin installed in a JetBrains IDE. */
|
||||
@@ -5254,7 +5271,12 @@ export type PluginEditor = {
|
||||
/** Backend → UI notifications (Tauri event `ide://plugin`). */
|
||||
export type PluginEvent =
|
||||
/** Plugins, their status or their contributions changed: re-fetch `plugin_list`. */
|
||||
{ type: "commandsChanged" } | { type: "notification"; pluginId: string; level: NotificationLevel; title: string; body: string } |
|
||||
{ type: "commandsChanged" } |
|
||||
/**
|
||||
* The marketplace offers newer versions of installed plugins (announced
|
||||
* once per set of versions).
|
||||
*/
|
||||
{ type: "updatesAvailable"; updates: PluginUpdate[] } | { type: "notification"; pluginId: string; level: NotificationLevel; title: string; body: string } |
|
||||
/** A plugin's status bar item was set (`item`) or removed. */
|
||||
{ type: "statusItemChanged"; pluginId: string; id: string; item: StatusItem | null } |
|
||||
/** A plugin began a task: show it with the background tasks. */
|
||||
@@ -5363,6 +5385,13 @@ export type PluginStatusItem = {
|
||||
item: StatusItem,
|
||||
};
|
||||
|
||||
/** A newer version of an installed plugin in the marketplace. */
|
||||
export type PluginUpdate = {
|
||||
id: string,
|
||||
name: string,
|
||||
version: string,
|
||||
};
|
||||
|
||||
export type PluginsSnapshot = {
|
||||
plugins: PluginInfo[],
|
||||
activeTheme: ActiveTheme | null,
|
||||
|
||||
@@ -29,6 +29,7 @@ import { RISK_LABEL, sortByRisk } from "./consent";
|
||||
import { itemsOf, uiPoint } from "./projection";
|
||||
import {
|
||||
compareVersions,
|
||||
isUpdate,
|
||||
grantPlugin,
|
||||
installFromDisk,
|
||||
installFromMarket,
|
||||
@@ -42,6 +43,7 @@ import {
|
||||
setPluginCapability,
|
||||
setPluginEnabled,
|
||||
uninstallPlugin,
|
||||
updateAllPlugins,
|
||||
usePlugins,
|
||||
} from "./store";
|
||||
import { useProject } from "@/platform/project";
|
||||
@@ -537,9 +539,15 @@ interface GearActions {
|
||||
importInstalledIntellij: () => void;
|
||||
}
|
||||
|
||||
function gearMenu(plugins: readonly PluginInfo[], actions: GearActions): MenuItem[] {
|
||||
function gearMenu(plugins: readonly PluginInfo[], updates: number, actions: GearActions): MenuItem[] {
|
||||
const installed = plugins.filter((p) => p.source === "installed");
|
||||
return [
|
||||
{
|
||||
label: updates > 0 ? tx`Update All Plugins (${updates})` : "Update All Plugins",
|
||||
disabled: updates === 0,
|
||||
run: () => void updateAllPlugins(),
|
||||
},
|
||||
{ kind: "separator" },
|
||||
{ label: "Install Plugin from Disk…", icon: "package", run: () => actions.install(false) },
|
||||
{ label: "Install Plugin from Package File…", run: () => actions.install(true) },
|
||||
...(actions.canImport
|
||||
@@ -671,7 +679,7 @@ function MarketUrlBar() {
|
||||
}
|
||||
|
||||
function MarketRow({ entry, selected, onSelect }: { entry: MarketPlugin; selected: boolean; onSelect: () => void }) {
|
||||
const update = entry.compatible && entry.installed && compareVersions(entry.version, entry.installed) > 0;
|
||||
const update = isUpdate(entry);
|
||||
return (
|
||||
<li className={clsx(styles.row, selected && styles.rowSelected)}>
|
||||
<button
|
||||
@@ -703,7 +711,7 @@ function MarketRow({ entry, selected, onSelect }: { entry: MarketPlugin; selecte
|
||||
|
||||
function MarketDetails({ entry, onInstalled }: { entry: MarketPlugin; onInstalled: (id: string) => void }) {
|
||||
const installing = usePlugins((s) => s.installing);
|
||||
const update = entry.compatible && entry.installed && compareVersions(entry.version, entry.installed) > 0;
|
||||
const update = isUpdate(entry);
|
||||
const install = async () => {
|
||||
const id = await installFromMarket(entry.id);
|
||||
if (id) onInstalled(id);
|
||||
@@ -819,7 +827,7 @@ export function PluginsPage() {
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
showContextMenu(
|
||||
{ x: r.left, y: r.bottom + 2 },
|
||||
gearMenu(plugins, {
|
||||
gearMenu(plugins, (market ?? []).filter(isUpdate).length, {
|
||||
canImport: useProject.getState().project?.trusted === true,
|
||||
install: (packageFile) => void installFromDisk(undefined, packageFile).then(showInstalled),
|
||||
importIntellijFile: () => void importIntellijTheme().then(showInstalled),
|
||||
|
||||
@@ -63,6 +63,8 @@ function backend(overrides: Partial<PluginsBackend> = {}): PluginsBackend {
|
||||
replaces: null,
|
||||
unsigned: true,
|
||||
publisher: null,
|
||||
signerChanged: false,
|
||||
needsConsent: true,
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { act, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { EditorSelection, EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import type { PluginInfo, ProjectInfo } from "@/bindings";
|
||||
import type { Consent, InstallPreview, MarketPlugin, PluginInfo, ProjectInfo } from "@/bindings";
|
||||
import { _setActiveEditor } from "@/editor/api";
|
||||
import { executeAction, executeActionFrom, getAction, registerAction } from "@/platform/actions";
|
||||
import { useProject } from "@/platform/project";
|
||||
@@ -30,10 +30,12 @@ import { useTasks } from "@/workbench/tasks";
|
||||
import { defineBuiltinUiPoints } from "../points";
|
||||
import { clearUiContributions, defineUiPoint, syncUiContributions } from "../projection";
|
||||
import { catalogState, filterCatalog, filterPlugins, NO_REPOSITORY, PluginsPage } from "../PluginsPage";
|
||||
import { ConsentDialogHost, provideConsentDialog } from "../consent";
|
||||
import {
|
||||
adoptIconTheme,
|
||||
compareVersions,
|
||||
installFromDisk,
|
||||
updateAllPlugins,
|
||||
refreshPlugins,
|
||||
runPluginCommand,
|
||||
selectPluginTheme,
|
||||
@@ -719,6 +721,133 @@ describe("marketplace", () => {
|
||||
expect(screen.queryByRole("button", { name: "Install" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("updates", () => {
|
||||
const offered = (id: string, version: string, installed: string): MarketPlugin => ({
|
||||
id,
|
||||
name: id.toUpperCase(),
|
||||
version,
|
||||
description: "",
|
||||
authors: [],
|
||||
publisher: "acme",
|
||||
download: `${id}.oiplugin`,
|
||||
digest: "x",
|
||||
capabilities: [],
|
||||
engine: "^1.0",
|
||||
installed,
|
||||
compatible: true,
|
||||
});
|
||||
const preview = (id: string, version: string, extra: Partial<InstallPreview> = {}): InstallPreview => ({
|
||||
id,
|
||||
name: id.toUpperCase(),
|
||||
version,
|
||||
description: "",
|
||||
authors: [],
|
||||
hasCode: true,
|
||||
capabilities: [
|
||||
{ id: "editor.read", description: "Read", risk: "low", optional: false, granted: true },
|
||||
{ id: "net:a.example", description: "Net", risk: "medium", optional: true, granted: true },
|
||||
{ id: "net:b.example", description: "Net", risk: "medium", optional: true, granted: false },
|
||||
],
|
||||
contributions: [],
|
||||
warnings: [],
|
||||
replaces: "1.0.0",
|
||||
unsigned: false,
|
||||
publisher: "acme",
|
||||
signerChanged: false,
|
||||
needsConsent: false,
|
||||
...extra,
|
||||
});
|
||||
|
||||
function updatesBackend(previews: Record<string, InstallPreview>) {
|
||||
const base = createMemoryPluginsBackend([
|
||||
plugin({ id: "a", version: "1.0.0" }),
|
||||
plugin({ id: "b", version: "1.0.0" }),
|
||||
]);
|
||||
const consents: [string, Consent][] = [];
|
||||
setPluginsBackend({
|
||||
...base,
|
||||
marketUrl: async () => "https://plugins.example/index.json",
|
||||
market: async () => [
|
||||
offered("a", "1.1.0", "1.0.0"),
|
||||
offered("b", "2.0.0", "1.0.0"),
|
||||
offered("c", "1.0.0", "1.0.0"),
|
||||
],
|
||||
marketDownload: async (id) => id,
|
||||
previewInstall: async (dir) => previews[dir]!,
|
||||
installDir: async (dir, consent) => {
|
||||
consents.push([dir, consent]);
|
||||
return plugin({ id: dir, name: dir.toUpperCase(), version: previews[dir]!.version });
|
||||
},
|
||||
});
|
||||
return consents;
|
||||
}
|
||||
|
||||
it("installs updates with nothing to confirm without asking, keeping their grants", async () => {
|
||||
const asked: ConsentRequest[] = [];
|
||||
setConsentPrompt(async (req) => {
|
||||
asked.push(req);
|
||||
return { optional: [], signerChange: !!req.signerChanged };
|
||||
});
|
||||
const consents = updatesBackend({
|
||||
a: preview("a", "1.1.0"),
|
||||
b: preview("b", "2.0.0", { signerChanged: true, needsConsent: true }),
|
||||
});
|
||||
expect(await updateAllPlugins()).toBe(2);
|
||||
// `a` with the optional capability it had; `b` asked, with the change of signer.
|
||||
expect(asked.map((r) => [r.pluginId, r.signerChanged])).toEqual([["b", true]]);
|
||||
expect(consents).toEqual([
|
||||
["a", { optional: ["net:a.example"] }],
|
||||
["b", { optional: [], signerChange: true }],
|
||||
]);
|
||||
expect(notes.map((n) => n.title)).toEqual(["Updated 2 plugins"]);
|
||||
});
|
||||
|
||||
it("warns about another signer on the install page and confirms it", async () => {
|
||||
const consents = updatesBackend({ b: preview("b", "2.0.0", { signerChanged: true, needsConsent: true }) });
|
||||
const stop = provideConsentDialog();
|
||||
const user = userEvent.setup();
|
||||
render(<ConsentDialogHost />);
|
||||
const done = installFromDisk("b");
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("signed with a different key");
|
||||
await user.click(screen.getByRole("button", { name: "Install" }));
|
||||
await done;
|
||||
expect(consents[0]![1]).toMatchObject({ signerChange: true });
|
||||
stop();
|
||||
});
|
||||
|
||||
it("offers Update All in the gear menu while there are updates", async () => {
|
||||
setConsentPrompt(async () => ({ optional: [] }));
|
||||
const consents = updatesBackend({ a: preview("a", "1.1.0"), b: preview("b", "2.0.0") });
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<>
|
||||
<PluginsPage />
|
||||
<ContextMenuHost />
|
||||
</>,
|
||||
);
|
||||
await waitFor(() => expect(usePlugins.getState().market).toHaveLength(3));
|
||||
await user.click(screen.getByRole("button", { name: "Manage Plugins" }));
|
||||
await user.click(screen.getByRole("menuitem", { name: "Update All Plugins (2)" }));
|
||||
await waitFor(() => expect(consents.map(([id]) => id)).toEqual(["a", "b"]));
|
||||
});
|
||||
|
||||
it("announces updates with actions to install them or show the plugins", async () => {
|
||||
updatesBackend({ a: preview("a", "1.1.0"), b: preview("b", "2.0.0") });
|
||||
handlePluginEvent({
|
||||
type: "updatesAvailable",
|
||||
updates: [
|
||||
{ id: "a", name: "A", version: "1.1.0" },
|
||||
{ id: "b", name: "B", version: "2.0.0" },
|
||||
],
|
||||
});
|
||||
const note = notes.at(-1)!;
|
||||
expect(note).toMatchObject({ title: "2 plugin updates available", body: "A 1.1.0, B 2.0.0" });
|
||||
expect(note.actions?.map((a) => a.label)).toEqual(["Update All", "Show Plugins"]);
|
||||
note.actions![0]!.run();
|
||||
await waitFor(() => expect(notes.at(-1)?.title).toBe("Updated 2 plugins"));
|
||||
});
|
||||
});
|
||||
|
||||
it("offers to roll back an update", async () => {
|
||||
const base = createMemoryPluginsBackend([
|
||||
plugin({ id: "p", name: "P", version: "2.0.0", previousVersion: "1.0.0" }),
|
||||
|
||||
@@ -429,6 +429,8 @@ export function createMemoryPluginsBackend(
|
||||
replaces: plugins.find((p) => p.id === entry.id)?.version ?? null,
|
||||
unsigned: true,
|
||||
publisher: null,
|
||||
signerChanged: false,
|
||||
needsConsent: true,
|
||||
};
|
||||
};
|
||||
return {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* variables, notifications become balloons, consent requests open the
|
||||
* permission dialog, and opened files fire `on-language` / `on-file`.
|
||||
*/
|
||||
import type { ActiveTheme, PluginEvent } from "@/bindings";
|
||||
import type { ActiveTheme, PluginEvent, PluginUpdate } from "@/bindings";
|
||||
import { useEditorRegistry } from "@/editor/api";
|
||||
import { refreshLanguageInfos } from "@/editor/core/cm/languages";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
@@ -27,13 +27,14 @@ import {
|
||||
useThemeOverride,
|
||||
windowBackgroundHex,
|
||||
} from "@/workbench/theme";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { pluginsBackend } from "./backend";
|
||||
import { trackIconTheme } from "./iconThemes";
|
||||
import { defineBuiltinUiPoints } from "./points";
|
||||
import { clearUiContributions, syncUiContributions } from "./projection";
|
||||
import { handleRuntimeEvent, refreshEventKinds, startRuntimeUi } from "./runtimeUi";
|
||||
import { grantPlugin, refreshPlugins, selectPluginTheme, usePlugins } from "./store";
|
||||
import { grantPlugin, refreshPlugins, selectPluginTheme, updateAllPlugins, usePlugins } from "./store";
|
||||
|
||||
export { pluginActionId } from "./points";
|
||||
|
||||
@@ -244,11 +245,28 @@ function trackActiveFile(): () => void {
|
||||
|
||||
// --- events -----------------------------------------------------------------
|
||||
|
||||
function announceUpdates(updates: readonly PluginUpdate[]) {
|
||||
const names = updates.map((u) => `${u.name} ${u.version}`).join(", ");
|
||||
notifications().notify({
|
||||
group: "plugins",
|
||||
level: "info",
|
||||
title: updates.length === 1 ? "Plugin update available" : `${updates.length} plugin updates available`,
|
||||
body: names,
|
||||
actions: [
|
||||
{ label: "Update All", run: () => void updateAllPlugins() },
|
||||
{ label: "Show Plugins", run: () => void executeAction("plugins.settings") },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export function handlePluginEvent(e: PluginEvent) {
|
||||
switch (e.type) {
|
||||
case "commandsChanged":
|
||||
scheduleRefresh();
|
||||
break;
|
||||
case "updatesAvailable":
|
||||
announceUpdates(e.updates);
|
||||
break;
|
||||
case "notification": {
|
||||
const name = usePlugins.getState().plugins.find((p) => p.id === e.pluginId)?.name ?? e.pluginId;
|
||||
notifications().notify({ group: "plugins", level: e.level, title: e.title || name, body: e.body || undefined });
|
||||
|
||||
@@ -59,7 +59,7 @@ function ConsentDialog({ pending, onDone }: { pending: Pending; onDone: () => vo
|
||||
{
|
||||
label: req.kind === "install" ? tx("Install") : tx("Allow"),
|
||||
role: "default",
|
||||
onClick: () => finish({ optional: [...accepted] }),
|
||||
onClick: () => finish({ optional: [...accepted], signerChange: !!req.signerChanged }),
|
||||
},
|
||||
]}
|
||||
>
|
||||
@@ -69,6 +69,14 @@ function ConsentDialog({ pending, onDone }: { pending: Pending; onDone: () => vo
|
||||
<T>This package is not signed. Only continue if you trust where it came from.</T>
|
||||
</p>
|
||||
)}
|
||||
{req.signerChanged && (
|
||||
<p className={styles.consentWarning} role="alert">
|
||||
<T>
|
||||
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.
|
||||
</T>
|
||||
</p>
|
||||
)}
|
||||
{caps.length === 0 ? (
|
||||
<p>
|
||||
<T>This plugin needs no special permissions.</T>
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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<void> {
|
||||
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<string | null> {
|
||||
export async function installFromMarket(id: string, options: InstallOptions = {}): Promise<string | null> {
|
||||
usePlugins.setState({ installing: true });
|
||||
let dir: string;
|
||||
try {
|
||||
@@ -225,11 +232,27 @@ export async function installFromMarket(id: string): Promise<string | null> {
|
||||
});
|
||||
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<number> {
|
||||
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<void> {
|
||||
try {
|
||||
@@ -274,12 +297,22 @@ async function packageFolder(backend: PluginsBackend, picked: string): Promise<s
|
||||
return manifest ? picked.slice(0, manifest.index) : picked;
|
||||
}
|
||||
|
||||
interface InstallOptions {
|
||||
/** No notification when it installs (a batch reports once). */
|
||||
quiet?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks for a plugin (a folder, its `plugin.toml`, or an `.oiplugin` / `.zip`
|
||||
* package file), shows what it may do, and installs it with the user's
|
||||
* consent. Returns the new plugin id.
|
||||
* consent. An update with nothing to confirm installs with the grants it
|
||||
* has. Returns the new plugin id.
|
||||
*/
|
||||
export async function installFromDisk(path?: string, packageFile = false): Promise<string | null> {
|
||||
export async function installFromDisk(
|
||||
path?: string,
|
||||
packageFile = false,
|
||||
options: InstallOptions = {},
|
||||
): Promise<string | null> {
|
||||
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) });
|
||||
|
||||
@@ -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.": "此版本的签名密钥与已安装的版本不同,且发布者之前的密钥没有为它背书。只有在确认发布者更换了密钥时才继续。"
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача