diff --git a/.github/workflows/plugins-index.yml b/.github/workflows/plugins-index.yml new file mode 100644 index 00000000..bbf1d256 --- /dev/null +++ b/.github/workflows/plugins-index.yml @@ -0,0 +1,46 @@ +name: Plugin index + +# Refreshes the community plugins in the latest release's plugins-index.json +# (what released apps read) from the registry, l0ng-ai/openidea-plugins. The +# official plugins, their key and revocations stay as the release wrote them. +on: + schedule: + - cron: "17 * * * *" + workflow_dispatch: + # Sent by the registry when its index changes. + repository_dispatch: + types: [community-plugins] + +permissions: + contents: write + +concurrency: + group: plugins-index + +env: + COMMUNITY_PLUGINS_INDEX: https://github.com/l0ng-ai/openidea-plugins/releases/download/index/index.json + +jobs: + merge: + runs-on: ubuntu-latest + steps: + - name: Merge the community index + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + cd "$RUNNER_TEMP" + tag="$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName -q .tagName)" + tool=openidea-plugin-linux-x86_64 + if ! gh release download "$tag" --repo "$GITHUB_REPOSITORY" -p plugins-index.json -p "$tool.gz"; then + echo "::notice::$tag has no plugin index or openidea-plugin tool yet" + exit 0 + fi + gunzip "$tool.gz" && chmod +x "$tool" + cp plugins-index.json before.json + "./$tool" merge plugins-index.json "$COMMUNITY_PLUGINS_INDEX" --keep openidea + if cmp -s before.json plugins-index.json; then + echo "unchanged" + else + gh release upload "$tag" plugins-index.json --repo "$GITHUB_REPOSITORY" --clobber + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3874f17e..9a4af689 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,6 +18,7 @@ env: CARGO_TERM_COLOR: always CARGO_INCREMENTAL: 0 TAG: ${{ github.event.inputs.tag || github.ref_name }} + COMMUNITY_PLUGINS_INDEX: https://github.com/l0ng-ai/openidea-plugins/releases/download/index/index.json jobs: # Creates (or reuses) one draft release that every platform build uploads into. @@ -25,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 }}" } @@ -35,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: @@ -57,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: @@ -184,7 +202,11 @@ jobs: # (releases/latest/download/plugins-index.json). They are not in the # installers: users install them from Settings › Plugins. Without the # signing key (secret PLUGIN_SIGNING_KEY: the file `openidea-plugin keygen` - # writes) nothing is published and the release goes on. + # writes) nothing is published and the release goes on. The index also lists + # the community plugins of the registry (COMMUNITY_PLUGINS_INDEX; refreshed + # between releases by plugins-index.yml), and the release carries the + # openidea-plugin tool for Linux x64, which that workflow and the registry's + # checks run. plugins: needs: create-release name: Official plugins @@ -237,11 +259,17 @@ jobs: version="$(sed -n 's/^version *= *"\(.*\)".*/\1/p' "$dir/plugin.toml" | head -1)" "$tool" pack "$dir" --key "$key" --publisher openidea --out "$out/$id-$version.oiplugin" done - # The published index's revocations and earlier keys carry over. + # The published index's revocations, earlier keys and community plugins carry over. curl -fsSL "https://github.com/$GITHUB_REPOSITORY/releases/latest/download/plugins-index.json" \ -o "$out/plugins-index.json" || rm -f "$out/plugins-index.json" - "$tool" index "$out" --publisher openidea --name OpenIDEA --key "$key" --out "$out/plugins-index.json" - gh release upload "$TAG" "$out"/*.oiplugin "$out/plugins-index.json" --clobber + # Downloads point at this release: older versions kept for older IDEs stay where they were published. + "$tool" index "$out" --publisher openidea --name OpenIDEA --key "$key" --out "$out/plugins-index.json" \ + --download-base "https://github.com/$GITHUB_REPOSITORY/releases/download/$TAG" + "$tool" merge "$out/plugins-index.json" "$COMMUNITY_PLUGINS_INDEX" --keep openidea || + echo "::warning::the community plugins were not refreshed" + gzip -9 -c "$tool" > "$out/openidea-plugin-linux-x86_64.gz" + gh release upload "$TAG" "$out"/*.oiplugin "$out/plugins-index.json" \ + "$out/openidea-plugin-linux-x86_64.gz" --clobber # The updater manifest (latest.json), built once from the signed bundles every build uploaded. # Fails when a platform is missing, so a release never ships a manifest without it. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6d10aacf..5dfa9ffd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,5 +31,10 @@ Project layout, coding rules, commands and checks are in [AGENTS.md](AGENTS.md). - If a change needs a product decision you can't make, ask in the issue and stop rather than guessing. +Plugins: pull requests here add or change official plugins (`plugins/official/`) +and the plugin API. Community plugins live in their authors' repositories and +are listed through [l0ng-ai/openidea-plugins](https://github.com/l0ng-ai/openidea-plugins); +see [`plugins/README.md`](plugins/README.md) § 8.1. + A check on each PR adds `needs-info` when the issue link or the verification section is missing, and removes it once they're filled in. diff --git a/Cargo.lock b/Cargo.lock index f0265c3b..6713938e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4276,6 +4276,7 @@ dependencies = [ "regex-syntax", "reqwest", "rustix", + "semver", "serde", "serde_json", "specta", diff --git a/crates/ide-extension/src/lib.rs b/crates/ide-extension/src/lib.rs index 9089067a..fdf63d95 100644 --- a/crates/ide-extension/src/lib.rs +++ b/crates/ide-extension/src/lib.rs @@ -27,8 +27,8 @@ mod when; pub use capability::{Capability, CapabilityInfo, Risk, host_matches}; pub use index::ContributionIndex; pub use manifest::{ - ActivationEvent, Capabilities, ENGINE_VERSION, EVENT_KINDS, LEGACY_API, MANIFEST_FILE, Manifest, check_relative, - engine_compatible, validate_item_id, validate_plugin_id, validate_point_id, + ActivationEvent, CATEGORIES, Capabilities, ENGINE_VERSION, EVENT_KINDS, LEGACY_API, MANIFEST_FILE, MAX_CATEGORIES, + Manifest, check_relative, engine_compatible, validate_item_id, validate_plugin_id, validate_point_id, }; pub use point::{Commit, Disposable, ExtensionPoint, MAX_PACKAGE_FILE, Package, Placement, Prepared, decode}; pub use registry::{ContributionInfo, ExtensionRegistry, PreparedSet}; diff --git a/crates/ide-extension/src/manifest.rs b/crates/ide-extension/src/manifest.rs index 99820d5f..d079cff2 100644 --- a/crates/ide-extension/src/manifest.rs +++ b/crates/ide-extension/src/manifest.rs @@ -35,6 +35,11 @@ pub const MANIFEST_FILE: &str = "plugin.toml"; pub const ENGINE_VERSION: &str = "1.0.0"; /// The 0.1 API, still accepted for contribution-only packages and 0.1 components. 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"]; +/// Categories a plugin is listed under; later ones are ignored. +pub const MAX_CATEGORIES: usize = 3; #[derive(Clone, Debug, PartialEq)] pub struct Manifest { @@ -47,6 +52,8 @@ pub struct Manifest { pub license: Option, /// Release notes of this version (Settings › Plugins › What's New). pub change_notes: Option, + /// Marketplace categories as declared (see [`Manifest::categories`]). + pub declared_categories: Vec, /// `engines.openidea` (a semver requirement), or the legacy `openidea-api`. pub engine: String, /// Written in the 0.1 format. @@ -166,6 +173,8 @@ struct Raw { #[serde(default)] change_notes: Option, #[serde(default)] + categories: Vec, + #[serde(default)] engines: Option, /// 0.1 format. #[serde(default)] @@ -243,6 +252,7 @@ impl Manifest { homepage: raw.homepage, license: raw.license, change_notes: raw.change_notes, + declared_categories: raw.categories, engine, legacy, wasm: raw.wasm, @@ -290,6 +300,9 @@ impl Manifest { return Err(format!("duplicate activation event {event}")); } } + for c in &self.declared_categories { + validate_item_id(c, "category")?; + } for (point, decls) in &self.contributes { validate_point_id(point)?; if !matches!(decls, toml::Value::Array(_) | toml::Value::Table(_)) { @@ -325,6 +338,39 @@ impl Manifest { } } +impl Manifest { + /// Its marketplace categories, in [`CATEGORIES`] order: the first + /// [`MAX_CATEGORIES`] declared that this IDE knows; without any, those its + /// contributions imply (themes, icon themes, languages), else `other`. + pub fn categories(&self) -> Vec { + let pick = |f: &dyn Fn(&str) -> bool| CATEGORIES.iter().filter(|c| f(c)).map(|c| c.to_string()).collect(); + let mut known: Vec<&str> = Vec::new(); + for d in &self.declared_categories { + if CATEGORIES.contains(&d.as_str()) && !known.contains(&d.as_str()) && known.len() < MAX_CATEGORIES { + known.push(d); + } + } + let mut out: Vec = pick(&|c| known.contains(&c)); + if out.is_empty() { + out = pick(&|c| match c { + "themes" => self.contributes.contains_key("themes"), + "icon-themes" => self.contributes.contains_key("iconThemes"), + "languages" => self.contributes.contains_key("languages"), + _ => false, + }); + } + if out.is_empty() { + out.push("other".into()); + } + out + } + + /// Declared categories this IDE doesn't know (ignored). + pub fn unknown_categories(&self) -> Vec<&str> { + self.declared_categories.iter().map(String::as_str).filter(|c| !CATEGORIES.contains(c)).collect() + } +} + /// Whether this host can load a plugin written for `engine`. pub fn engine_compatible(engine: &str, legacy: bool) -> bool { if legacy { @@ -417,6 +463,26 @@ mod tests { assert!(ActivationEvent::parse("on-event:scheduled").is_err(), "declared with on-schedule"); } + #[test] + fn categories_are_declared_or_implied() { + let head = "id = \"acme.x\"\nname = \"X\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n"; + let m = |rest: &str| Manifest::parse(&format!("{head}{rest}")); + // Declared, in the catalogue's order; unknown ones (a newer IDE's) are ignored. + let declared = m("categories = [\"tools\", \"vcs\", \"robots\"]\n").unwrap(); + assert_eq!(declared.categories(), ["vcs", "tools"]); + assert_eq!(declared.unknown_categories(), ["robots"]); + // None declared: implied by the contributions, else `other`. + let theme = m("[[contributes.themes]]\nid = \"t\"\n[[contributes.languages]]\nid = \"l\"\n").unwrap(); + assert_eq!(theme.categories(), ["languages", "themes"]); + 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"]); + // 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()); + } + #[test] fn parses_the_02_format() { let m = Manifest::parse(FULL).unwrap(); diff --git a/crates/ide-extension/src/package.rs b/crates/ide-extension/src/package.rs index b8d3988d..f1bc9c04 100644 --- a/crates/ide-extension/src/package.rs +++ b/crates/ide-extension/src/package.rs @@ -9,9 +9,19 @@ //! proves who published the bytes; whether to trust that publisher is the //! index's call. //! - **Index** (`index.json`): the publishers and their keys (the trust root), -//! the published plugins, and revocations. A publisher owns the id prefix -//! `.`; a revoked key invalidates everything it signed, a -//! revoked plugin version is disabled where it is installed. +//! the published plugin versions, and revocations. A publisher owns the id +//! prefix `.`; a revoked key invalidates everything it signed, a +//! 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 _; @@ -40,6 +50,24 @@ fn signed_message(id: &str, version: &str, digest: &str) -> Vec { format!("openidea-plugin/1\n{id}\n{version}\n{digest}").into_bytes() } +fn endorsement_message(publisher: &str, key: &str) -> Vec { + format!("openidea-key/1\n{publisher}\n{key}").into_bytes() +} + +/// Whether `signature` (base64) is `key`'s (base64) Ed25519 signature of +/// `message`; malformed keys and signatures are errors. +fn verify_ed25519(key: &str, message: &[u8], signature: &str) -> Result { + let key: [u8; 32] = B64 + .decode(key) + .ok() + .and_then(|b| b.try_into().ok()) + .ok_or_else(|| "signature: malformed public key".to_owned())?; + let key = VerifyingKey::from_bytes(&key).map_err(|_| "signature: invalid public key".to_owned())?; + let sig: [u8; 64] = + B64.decode(signature).ok().and_then(|b| b.try_into().ok()).ok_or_else(|| "signature: malformed".to_owned())?; + Ok(key.verify_strict(message, &Signature::from_bytes(&sig)).is_ok()) +} + /// `SIGNATURE.json` of a signed package. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -74,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 { @@ -88,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()) + } } } @@ -242,6 +268,10 @@ pub struct Index { pub plugins: Vec, #[serde(default)] pub revoked: Vec, + /// The marketplace moved: its index is at this URL (relative to this + /// one, or absolute), and the IDE reads that one instead. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub moved: Option, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)] @@ -253,7 +283,7 @@ pub struct Publisher { pub keys: Vec, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, specta::Type)] #[serde(rename_all = "camelCase")] pub struct KeyEntry { /// Ed25519 public key, base64. @@ -261,6 +291,27 @@ pub struct KeyEntry { /// Packages signed with it are no longer trusted. #[serde(default)] pub revoked: bool, + /// The publisher's earlier key vouching for this one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub endorsement: Option, +} + +impl KeyEntry { + pub fn new(key: String) -> Self { + Self { key, ..Self::default() } + } +} + +/// A publisher's earlier key vouching for a newer one: plugins installed from +/// packages signed with the earlier key update to ones signed with the newer +/// key without asking the user. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct Endorsement { + /// The endorsing key, base64. + pub key: String, + /// Its Ed25519 signature of `openidea-key/1\n\n`, base64. + pub signature: String, } /// A published plugin version. @@ -281,6 +332,34 @@ pub struct IndexEntry { pub digest: String, #[serde(default)] pub capabilities: Vec, + /// The plugin API versions it loads in (a semver requirement): its + /// manifest's `engines.openidea` and the API its component was built for. + /// 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. + #[serde(default)] + pub categories: Vec, +} + +impl IndexEntry { + /// Whether this IDE can load the version. + pub fn compatible(&self) -> bool { + crate::engine_compatible(&self.engine, false) + } + + fn semver(&self) -> Option { + semver::Version::parse(&self.version).ok() + } +} + +/// `engines.openidea` of a manifest narrowed to the API its component was built +/// for (`major.minor`, if it has code): the requirement an index lists. +pub fn engine_requirement(manifest_engine: &str, built_for: Option<&semver::Version>) -> String { + match built_for { + Some(v) => format!("{manifest_engine}, ^{}.{}", v.major, v.minor), + None => manifest_engine.to_owned(), + } } /// A plugin (all versions when `versions` is empty) that must not run. @@ -294,12 +373,71 @@ pub struct Revocation { } impl Index { + /// Parses an index. One in a newer format keeps what must reach every IDE — + /// revocations, publisher keys (revoked ones) and `moved` — and lists no + /// plugins ([`Index::is_newer`]): the IDE needs updating to install from it. pub fn parse(text: &str) -> Result { - let index: Index = serde_json::from_str(text).map_err(|e| format!("index: {e}"))?; - if index.format != INDEX_FORMAT { - return Err(format!("index format {} is not supported (this IDE reads {INDEX_FORMAT})", index.format)); + let value: serde_json::Value = serde_json::from_str(text).map_err(|e| format!("index: {e}"))?; + let format = value.get("format").and_then(serde_json::Value::as_u64).ok_or("index: `format` is missing")?; + if format == u64::from(INDEX_FORMAT) { + return serde_json::from_value(value).map_err(|e| format!("index: {e}")); } - Ok(index) + if format < u64::from(INDEX_FORMAT) { + return Err(format!("index format {format} is not supported (this IDE reads {INDEX_FORMAT})")); + } + fn each(value: &serde_json::Value, field: &str) -> Vec { + let items = value.get(field).and_then(serde_json::Value::as_array); + items.into_iter().flatten().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect() + } + Ok(Index { + format: u32::try_from(format).unwrap_or(u32::MAX), + publishers: each(&value, "publishers"), + plugins: Vec::new(), + revoked: each(&value, "revoked"), + moved: value.get("moved").and_then(serde_json::Value::as_str).map(str::to_owned), + }) + } + + /// Written in a format newer than this IDE reads: it lists no plugins. + pub fn is_newer(&self) -> bool { + self.format > INDEX_FORMAT + } + + /// The version of each plugin to offer, and whether this IDE can load it: + /// the newest it can load, else the newest. Revoked versions are skipped; + /// plugins come in the order they are first listed. + pub fn offered(&self) -> Vec<(&IndexEntry, bool)> { + let mut ids: Vec<&str> = Vec::new(); + for e in &self.plugins { + if !ids.contains(&e.id.as_str()) { + ids.push(&e.id); + } + } + ids.into_iter() + .filter_map(|id| { + let versions: Vec<&IndexEntry> = + self.plugins.iter().filter(|e| e.id == id && self.revocation(id, &e.version).is_none()).collect(); + let newest = |loadable: bool| { + versions.iter().copied().filter(|e| !loadable || e.compatible()).max_by_key(|e| e.semver()) + }; + newest(true).map(|e| (e, true)).or_else(|| newest(false).map(|e| (e, false))) + }) + .collect() + } + + /// Drops the versions no IDE would pick: revoked ones, and of each plugin + /// all but the newest version per API requirement. + pub fn prune_versions(&mut self) { + let plugins = std::mem::take(&mut self.plugins); + let mut kept: Vec = Vec::new(); + for e in plugins.into_iter().filter(|e| self.revocation(&e.id, &e.version).is_none()) { + match kept.iter_mut().find(|k| k.id == e.id && k.engine == e.engine) { + Some(k) if e.semver() > k.semver() => *k = e, + Some(_) => {} + None => kept.push(e), + } + } + self.plugins = kept; } /// The revocation that applies to `id` at `version`. @@ -335,6 +473,127 @@ impl Index { sig.check(id, version, digest)?; 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 + /// speak for its own publishers, and none of them may be in `keep`. + pub fn include(&mut self, other: Index, keep: &[&str]) -> Result<(), String> { + let mut theirs: Vec<&str> = Vec::new(); + for p in &other.publishers { + if !publisher_id_ok(&p.id) { + return Err(format!("invalid publisher id {:?}: use a-z, 0-9, '-' and '_'", p.id)); + } + if keep.contains(&p.id.as_str()) { + return Err(format!("may not list publisher {:?}", p.id)); + } + if theirs.contains(&p.id.as_str()) { + return Err(format!("publisher {:?} is listed twice", p.id)); + } + theirs.push(&p.id); + } + let owned_by = |id: &str| theirs.iter().any(|p| owns(p, id)); + for e in &other.plugins { + 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)); + } + let kept = |id: &str| keep.iter().any(|p| owns(p, id)); + self.publishers.retain(|p| keep.contains(&p.id.as_str())); + self.plugins.retain(|e| keep.contains(&e.publisher.as_str())); + self.revoked.retain(|r| kept(&r.id)); + self.publishers.extend(other.publishers); + self.plugins.extend(other.plugins); + self.revoked.extend(other.revoked); + Ok(()) + } +} + +/// 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('.')) +} + +/// A publisher id is a single segment of a plugin id. +pub fn publisher_id_ok(id: &str) -> bool { + !id.contains('.') && crate::validate_plugin_id(id).is_ok() } #[cfg(test)] @@ -354,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 { @@ -362,6 +621,7 @@ mod tests { versions: vec!["1.0.0".into()], reason: "malware".into(), }], + moved: None, } } @@ -399,6 +659,138 @@ mod tests { assert_eq!(PublisherKey::from_base64(&key.seed_base64()).unwrap().public_base64(), key.public_base64()); } + fn entry(id: &str, publisher: &str) -> IndexEntry { + IndexEntry { + id: id.into(), + name: id.into(), + version: "1.0.0".into(), + description: String::new(), + authors: Vec::new(), + publisher: publisher.into(), + download: format!("https://example.com/{id}.oiplugin"), + digest: "d".into(), + capabilities: Vec::new(), + engine: "^1.0".into(), + categories: Vec::new(), + } + } + + fn version(id: &str, version: &str, engine: &str) -> IndexEntry { + IndexEntry { version: version.into(), engine: engine.into(), ..entry(id, "acme") } + } + + #[test] + fn the_ide_is_offered_the_newest_version_it_can_load() { + let host = crate::ENGINE_VERSION; + let newer = semver::Version::parse(host).unwrap().major + 1; + let mut index = Index { + format: INDEX_FORMAT, + plugins: vec![ + version("acme.a", "1.0.0", &format!("^{host}")), + version("acme.a", "1.2.0", &format!("^{host}")), + version("acme.a", "2.0.0", &format!("^{newer}.0")), + version("acme.b", "0.1.0", &format!("^{newer}.0")), + version("acme.b", "0.2.0", &format!("^{newer}.1")), + version("acme.c", "1.0.0", ""), + version("acme.c", "1.1.0", ""), + ], + revoked: vec![Revocation { id: "acme.c".into(), versions: vec!["1.1.0".into()], reason: "r".into() }], + ..Index::default() + }; + let offered = |index: &Index| { + index.offered().into_iter().map(|(e, ok)| (e.id.clone(), e.version.clone(), ok)).collect::>() + }; + let expected = [ + ("acme.a".to_owned(), "1.2.0".to_owned(), true), + ("acme.b".to_owned(), "0.2.0".to_owned(), false), + // No `engine`: listed, but no IDE installs it. + ("acme.c".to_owned(), "1.0.0".to_owned(), false), + ]; + assert_eq!(offered(&index), expected); + + // Pruning keeps the newest version per requirement, which changes nothing offered. + index.prune_versions(); + let left: Vec<_> = index.plugins.iter().map(|e| format!("{} {}", e.id, e.version)).collect(); + assert_eq!(left, ["acme.a 1.2.0", "acme.a 2.0.0", "acme.b 0.1.0", "acme.b 0.2.0", "acme.c 1.0.0"]); + assert_eq!(offered(&index), expected); + + let built_for = semver::Version::new(1, 3, 0); + assert_eq!(engine_requirement("^1.0", Some(&built_for)), "^1.0, ^1.3"); + assert_eq!(engine_requirement("^1.0", None), "^1.0"); + assert!(semver::VersionReq::parse(&engine_requirement("^1.0", Some(&built_for))).is_ok()); + } + + fn publisher(id: &str) -> Publisher { + Publisher { id: id.into(), name: id.into(), keys: vec![KeyEntry::new(format!("{id}-key"))] } + } + + fn revocation(id: &str) -> Revocation { + Revocation { id: id.into(), versions: Vec::new(), reason: "r".into() } + } + + #[test] + fn including_an_index_replaces_everything_but_the_kept_publishers() { + let mut official = Index { + format: INDEX_FORMAT, + publishers: vec![publisher("openidea"), publisher("gone")], + plugins: vec![entry("openidea.a", "openidea"), entry("gone.x", "gone")], + revoked: vec![revocation("openidea.old"), revocation("gone.x")], + moved: None, + }; + let community = Index { + format: INDEX_FORMAT, + publishers: vec![publisher("alice")], + plugins: vec![entry("alice.arthas", "alice")], + revoked: vec![revocation("alice.bad")], + moved: None, + }; + official.include(community.clone(), &["openidea"]).unwrap(); + let ids = |v: &[IndexEntry]| v.iter().map(|e| e.id.clone()).collect::>(); + assert_eq!(official.publishers.iter().map(|p| p.id.as_str()).collect::>(), ["openidea", "alice"]); + assert_eq!(ids(&official.plugins), ["openidea.a", "alice.arthas"]); + assert_eq!(official.revoked.iter().map(|r| r.id.as_str()).collect::>(), ["openidea.old", "alice.bad"]); + + // Including again is idempotent. + let once = official.clone(); + official.include(community.clone(), &["openidea"]).unwrap(); + assert_eq!(official, once); + } + + #[test] + fn an_included_index_speaks_only_for_its_own_publishers() { + let base = Index { format: INDEX_FORMAT, publishers: vec![publisher("openidea")], ..Index::default() }; + let with = |publishers: Vec, plugins: Vec, revoked: Vec| Index { + format: INDEX_FORMAT, + publishers, + plugins, + revoked, + moved: None, + }; + let cases = [ + (with(vec![publisher("openidea")], vec![], vec![]), "may not list"), + (with(vec![publisher("alice"), publisher("alice")], vec![], vec![]), "listed twice"), + (with(vec![publisher("openidea.x")], vec![], vec![]), "invalid publisher"), + (with(vec![publisher("alice")], vec![entry("openidea.a", "alice")], vec![]), "not a plugin of"), + (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(); + let err = index.include(other, &["openidea"]).unwrap_err(); + assert!(err.contains(error), "{err}"); + assert_eq!(index, base); + } + } + #[test] fn unpacking_refuses_paths_leaving_the_package() { let tmp = tempfile::tempdir().unwrap(); @@ -413,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)]) { @@ -462,4 +854,102 @@ mod tests { assert!(err.contains("is not an OpenIDEA plugin"), "{name}: {err}"); } } + + #[test] + fn a_newer_index_still_revokes_and_moves() { + let newer = format!( + r#"{{"format": {}, "plugins": [{{"anything": "new"}}], "shape": "changed", + "publishers": [{{"id": "acme", "name": "ACME", "keys": [{{"key": "k1", "revoked": true}}]}}, {{"id": 5}}], + "revoked": [{{"id": "acme.bad", "reason": "malware"}}, {{"oops": 1}}], + "moved": "https://example.com/next.json"}}"#, + INDEX_FORMAT + 1 + ); + let index = Index::parse(&newer).unwrap(); + assert!(index.is_newer()); + assert!(index.plugins.is_empty() && index.offered().is_empty()); + assert_eq!(index.revocation("acme.bad", "2.0.0").unwrap().reason, "malware"); + assert!(index.key_revoked("k1")); + assert_eq!(index.moved.as_deref(), Some("https://example.com/next.json")); + // Fields this IDE doesn't know are ignored in its own format. + let current = format!(r#"{{"format": {INDEX_FORMAT}, "later": true, "plugins": []}}"#); + assert!(!Index::parse(¤t).unwrap().is_newer()); + assert!(Index::parse(r#"{"format": 0}"#).unwrap_err().contains("not supported")); + assert!(Index::parse(r#"{"plugins": []}"#).is_err()); + } + + fn keys<'a>(index: &'a mut Index, publisher: &str) -> &'a mut Vec { + &mut index.publishers.iter_mut().find(|p| p.id == publisher).unwrap().keys + } + + #[test] + fn rotated_keys_continue_through_endorsements() { + let (k1, k2, k3) = + (PublisherKey::from_seed([1; 32]), PublisherKey::from_seed([2; 32]), PublisherKey::from_seed([3; 32])); + let mut index = index(&k1); + let endorsed = |by: &PublisherKey, key: &PublisherKey| KeyEntry { + endorsement: Some(by.endorse("acme", &key.public_base64())), + ..KeyEntry::new(key.public_base64()) + }; + keys(&mut index, "acme").push(endorsed(&k1, &k2)); + keys(&mut index, "acme").push(endorsed(&k2, &k3)); + let (p1, p2, p3) = (k1.public_base64(), k2.public_base64(), k3.public_base64()); + assert!(index.succeeds("acme", &p1, &p1)); + assert!(index.succeeds("acme", &p1, &p3), "through k2"); + assert!(!index.succeeds("acme", &p3, &p1), "not backwards"); + assert!(!index.succeeds("other", &p1, &p2)); + // An endorsement for another publisher, or by a revoked key, doesn't count. + let mut foreign = index.clone(); + keys(&mut foreign, "acme")[1].endorsement = Some(k1.endorse("evil", &p2)); + assert!(!foreign.succeeds("acme", &p1, &p2)); + let mut stolen = index.clone(); + keys(&mut stolen, "acme")[0].revoked = true; + assert!(!stolen.succeeds("acme", &p1, &p2)); + + let sig = |key: &PublisherKey| key.sign("acme", "acme.tools", "1.0.0", "d"); + assert!(signer_continues(Some(&index), Some(&sig(&k1)), Some(&sig(&k3)))); + assert!(signer_continues(None, Some(&sig(&k1)), Some(&sig(&k1)))); + assert!(!signer_continues(None, Some(&sig(&k1)), Some(&sig(&k2))), "no index to vouch"); + let stranger = PublisherKey::from_seed([4; 32]); + assert!(!signer_continues(Some(&index), Some(&sig(&k1)), Some(&sig(&stranger)))); + assert!(!signer_continues(Some(&index), Some(&sig(&k1)), None), "signed, then unsigned"); + assert!(signer_continues(Some(&index), None, Some(&sig(&stranger))), "unsigned pins nothing"); + } + + #[test] + fn pinned_publishers_keep_their_built_in_keys() { + let (root, next, intruder) = + (PublisherKey::from_seed([1; 32]), PublisherKey::from_seed([2; 32]), PublisherKey::from_seed([5; 32])); + let mut index = index(&intruder); + index.publishers[0].id = "openidea".into(); + keys(&mut index, "openidea").push(KeyEntry { + endorsement: Some(root.endorse("openidea", &next.public_base64())), + ..KeyEntry::new(next.public_base64()) + }); + let roots = [root.public_base64()]; + let listed = |index: &Index| -> Vec<(String, bool)> { + let mut index = index.clone(); + index.pin_publisher("openidea", "OpenIDEA", &roots); + keys(&mut index, "openidea").iter().map(|k| (k.key.clone(), k.revoked)).collect() + }; + // The intruder's key goes, the successor the root endorsed stays, the root is added. + assert_eq!(listed(&index), [(next.public_base64(), false), (root.public_base64(), false)]); + let mut pinned = index.clone(); + pinned.pin_publisher("openidea", "OpenIDEA", &roots); + let digest = "d"; + let signed = |k: &PublisherKey| k.sign("openidea", "openidea.tools", "1.0.0", digest); + assert!(pinned.verify(&signed(&intruder), "openidea.tools", "1.0.0", digest).is_err()); + assert!(pinned.verify(&signed(&next), "openidea.tools", "1.0.0", digest).is_ok()); + // A revoked root stays revoked, and what it endorsed is no longer trusted. + keys(&mut index, "openidea").push(KeyEntry { revoked: true, ..KeyEntry::new(root.public_base64()) }); + assert_eq!(listed(&index), [(root.public_base64(), true)]); + + // A publisher missing from the index is added with its roots. + let mut empty = Index { format: INDEX_FORMAT, ..Index::default() }; + empty.pin_publisher("openidea", "OpenIDEA", &[root.public_base64()]); + assert_eq!(empty.verify(&signed(&root), "openidea.tools", "1.0.0", digest).unwrap(), "openidea"); + // No roots: the index decides. + let mut open = index.clone(); + open.pin_publisher("openidea", "OpenIDEA", &[]); + assert_eq!(open, index); + } } diff --git a/crates/ide-host/Cargo.toml b/crates/ide-host/Cargo.toml index 70c843e7..a8473b6b 100644 --- a/crates/ide-host/Cargo.toml +++ b/crates/ide-host/Cargo.toml @@ -46,6 +46,7 @@ ide-extension.workspace = true ide-plugin.workspace = true ide-tools = { path = "../ide-tools" } ide-docker = { path = "../ide-docker" } +semver = "1" serde.workspace = true serde_json.workspace = true toml.workspace = true diff --git a/crates/ide-host/src/bin/openidea-plugin/main.rs b/crates/ide-host/src/bin/openidea-plugin/main.rs index 99b73630..c0bee922 100644 --- a/crates/ide-host/src/bin/openidea-plugin/main.rs +++ b/crates/ide-host/src/bin/openidea-plugin/main.rs @@ -3,13 +3,15 @@ //! ```text //! openidea-plugin new [--template command|theme|icon-theme|language] [--id ID] [--name NAME] [--sdk PATH] //! openidea-plugin build [dir] [--debug] # cargo → dist/plugin.wasm, then `check` -//! openidea-plugin check [dir] # validate the package as the IDE would install it +//! openidea-plugin check [dir|package] # validate the package as the IDE would install it //! openidea-plugin link [dir] # load the folder in the IDE without installing it //! openidea-plugin unlink [dir] //! openidea-plugin links # list linked folders //! openidea-plugin keygen # a publisher's Ed25519 key +//! openidea-plugin endorse --key OLD --publisher ID # vouch for a new key //! openidea-plugin pack [dir] [--key F --publisher ID] [--out FILE] # -.oiplugin -//! openidea-plugin index --publisher ID --name NAME --key F # marketplace index.json +//! openidea-plugin index [--publisher ID --name NAME --key F [--previous-key F]] # marketplace index.json +//! openidea-plugin merge --keep ID # list another index's publishers //! ``` //! //! `link` writes `links.json` in the IDE's plugins directory @@ -20,7 +22,7 @@ mod templates; use ide_extension::{Manifest, validate_plugin_id}; -use ide_host::plugins::PluginHost; +use ide_host::plugins::{PluginHost, read_market_url, resolve_market_href}; use ide_plugin::{Limits, PluginEngine}; use std::path::{Path, PathBuf}; use std::process::{Command, ExitCode}; @@ -33,15 +35,27 @@ commands: new [--template T] [--id ID] [--name NAME] [--sdk PATH] create a plugin; T = command (default), theme, icon-theme, language build [dir] [--debug] compile a Rust plugin to dist/plugin.wasm and check it - check [dir] validate the package as the IDE would install it + check [dir|package] validate the package (folder or .oiplugin) as the IDE would install it link [dir] load the folder in the IDE without installing it unlink [dir] stop loading a linked folder links list linked folders keygen create a publisher signing key (keep the file secret) + endorse --key F --publisher ID + print an earlier key's endorsement of a new one (JSON), for an + index the publisher doesn't write with `index --previous-key` pack [dir] [--key F --publisher ID] [--out FILE] write -.oiplugin, signed with the key - index --publisher ID --name NAME --key F [--out FILE] - write the marketplace index.json for the packages in dir + index [--publisher ID --name NAME --key F [--previous-key F]] [--out FILE] + [--download-base URL] + write the marketplace index.json for the packages in dir: with a + key, register it and list that publisher's plugins (earlier + versions stay for older IDEs); without, list only these packages, + each checked against the index's publishers. A new key replacing + an earlier one is endorsed with --previous-key, so installed + plugins update to it without asking + merge --keep ID [--out FILE] + replace every publisher but ID in index with those of other-index + (a path or URL), their plugins and revocations environment: OPENIDEA_PLUGINS_DIR the IDE's plugins directory (default: the app data directory)"; @@ -51,13 +65,15 @@ fn main() -> ExitCode { let result = match args.first().map(String::as_str) { Some("new") => cmd_new(&args[1..]), Some("build") => cmd_build(&args[1..]), - Some("check") => dir_arg(&args[1..]).and_then(|d| check(&d)), + Some("check") => dir_arg(&args[1..]).and_then(|d| check_path(&d)), Some("link") => dir_arg(&args[1..]).and_then(|d| link(&d, true)), 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..]), Some("--version" | "-V") => { println!("openidea-plugin {} (plugin API {})", env!("CARGO_PKG_VERSION"), ide_extension::ENGINE_VERSION); Ok(()) @@ -323,10 +339,21 @@ fn ensure_wasm_target() { /// Validates `dir` exactly as Install from Disk would, and checks the /// component imports only the plugin API. +/// [`check`] of a folder, or of a package file unpacked as installing would. +fn check_path(path: &Path) -> Result<(), String> { + if !path.is_file() { + return check(path); + } + let tmp = scratch_dir("unpack")?; + let dir = tmp.path().join("package"); + package::unpack(path, &dir).and_then(|_| check(&dir)) +} + fn check(dir: &Path) -> Result<(), String> { let dir = canonical(dir)?; - let scratch = std::env::temp_dir().join(format!("openidea-plugin-check-{}", std::process::id())); - let result = (|| { + let tmp = scratch_dir("check")?; + let scratch = tmp.path().join("host"); + (|| { let host = PluginHost::new(scratch.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()) .map_err(|e| e.to_string())?; let preview = host.preview_install(&dir).map_err(|e| e.to_string())?; @@ -345,6 +372,17 @@ fn check(dir: &Path) -> Result<(), String> { } else { println!(" code: none (contributions only)"); } + println!(" categories: {}", manifest.categories().join(", ")); + for c in manifest.unknown_categories() { + println!(" warning: unknown category {c:?} (ignored); use {}", ide_extension::CATEGORIES.join(", ")); + } + if manifest.declared_categories.len() > ide_extension::MAX_CATEGORIES { + println!( + " warning: only the first {} known categories are used; {} are declared", + ide_extension::MAX_CATEGORIES, + manifest.declared_categories.len() + ); + } if !manifest.activation.is_empty() { let events: Vec = manifest.activation.iter().map(ToString::to_string).collect(); println!(" activation: {}", events.join(", ")); @@ -361,9 +399,7 @@ fn check(dir: &Path) -> Result<(), String> { } println!("ok"); Ok(()) - })(); - let _ = std::fs::remove_dir_all(&scratch); - result + })() } fn human_size(bytes: u64) -> String { @@ -479,14 +515,31 @@ fn cmd_keygen(args: &[String]) -> Result<(), String> { Ok(()) } +fn cmd_endorse(args: &[String]) -> Result<(), String> { + let a = Args::parse(args, &[], &["key", "publisher"])?; + let usage = "usage: openidea-plugin endorse --key --publisher ID"; + let ([new], Some(key), Some(publisher)) = (a.positional.as_slice(), a.value("key"), a.value("publisher")) else { + return Err(usage.into()); + }; + if !package::publisher_id_ok(publisher) { + return Err(format!("invalid publisher id {publisher:?}")); + } + let endorsement = read_key(key)?.endorse(publisher, new.trim()); + println!("{}", serde_json::to_string_pretty(&endorsement).map_err(|e| e.to_string())?); + Ok(()) +} + +/// A fresh scratch directory, removed when dropped. +fn scratch_dir(what: &str) -> Result { + tempfile::Builder::new().prefix(&format!("openidea-plugin-{what}-")).tempdir().map_err(|e| e.to_string()) +} + /// A scratch host to list a package's files as the IDE would. fn with_scratch_host(f: impl FnOnce(&PluginHost) -> Result) -> Result { - let scratch = std::env::temp_dir().join(format!("openidea-plugin-pack-{}", std::process::id())); - let result = PluginHost::new(scratch.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()) + let tmp = scratch_dir("host")?; + PluginHost::new(tmp.path().join("host"), Arc::new(|_: Option<&str>, _| {}), Limits::default()) .map_err(|e| e.to_string()) - .and_then(|h| f(&h)); - let _ = std::fs::remove_dir_all(&scratch); - result + .and_then(|h| f(&h)) } fn cmd_pack(args: &[String]) -> Result<(), String> { @@ -522,29 +575,28 @@ fn cmd_pack(args: &[String]) -> Result<(), String> { } fn cmd_index(args: &[String]) -> Result<(), String> { - let a = Args::parse(args, &[], &["publisher", "name", "key", "out"])?; + let a = Args::parse(args, &[], &["publisher", "name", "key", "previous-key", "out", "download-base"])?; let dir = canonical(&a.dir()?)?; - let publisher = a.value("publisher").ok_or("--publisher is required")?; - let name = a.value("name").unwrap_or(publisher); - let key = read_key(a.value("key").ok_or("--key is required")?)?; 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() }, }; - // Earlier keys of the publisher stay listed (packages they signed remain valid until revoked). - let current = key.public_base64(); - let earlier: Vec = index - .publishers - .iter() - .filter(|p| p.id == publisher) - .flat_map(|p| p.keys.iter().filter(|k| k.key != current).cloned()) - .collect(); - index.publishers.retain(|p| p.id != publisher); - let keys: Vec = std::iter::once(KeyEntry { key: current, revoked: false }).chain(earlier).collect(); - index.publishers.push(package::Publisher { id: publisher.into(), name: name.into(), keys }); - index.plugins.clear(); + 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); + 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()), + }; let mut packages: Vec = std::fs::read_dir(&dir) .map_err(|e| e.to_string())? .flatten() @@ -552,35 +604,144 @@ fn cmd_index(args: &[String]) -> Result<(), String> { .filter(|p| p.extension().is_some_and(|e| e == package::PACKAGE_EXTENSION)) .collect(); packages.sort(); + let engine = PluginEngine::new()?; + let mut added: Vec = Vec::new(); for path in packages { - let scratch = std::env::temp_dir().join(format!("openidea-plugin-index-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&scratch); - let unpacked = package::unpack(&path, &scratch)?; + let tmp = scratch_dir("index")?; + let scratch = tmp.path().join("package"); let entry = (|| { + let unpacked = package::unpack(&path, &scratch)?; let m = Manifest::load(&scratch)?; let files = with_scratch_host(|h| h.package_files_of(&scratch).map_err(|e| e.to_string()))?; let digest = package::package_digest(&scratch, &files).map_err(|e| e.to_string())?; - let sig = unpacked.signature.as_ref().ok_or_else(|| format!("{}: not signed", path.display()))?; - sig.check(&m.id, &m.version, &digest).map_err(|e| format!("{}: {e}", path.display()))?; + let sig = unpacked.signature.as_ref().ok_or("not signed")?; + let signer = index.verify(sig, &m.id, &m.version, &digest)?; + if publisher.is_some_and(|p| p != signer) { + return Err(format!("signed by {signer}, not {}", publisher.unwrap_or_default())); + } + if added.iter().any(|e| (&e.id, &e.version) == (&m.id, &m.version)) { + return Err(format!("another package of {} {} is in the folder", m.id, m.version)); + } + if m.legacy { + return Err("plugin.toml uses the 0.1 format; see plugins/README.md".into()); + } + let built_for = match &m.wasm { + Some(wasm) => engine.check_component(&engine.compile(&scratch.join(wasm))?)?, + None => None, + }; + let file = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); Ok::<_, String>(IndexEntry { id: m.id.clone(), name: m.name.clone(), version: m.version.clone(), description: m.description.clone(), authors: m.authors.clone(), - publisher: sig.publisher.clone(), - download: path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), + publisher: signer, + download: base.map_or_else(|| file.clone(), |b| format!("{b}/{file}")), digest, capabilities: m.capabilities.all().map(|c| c.to_string()).collect(), + engine: package::engine_requirement(&m.engine, built_for.as_ref()), + categories: m.categories(), }) })(); - let _ = std::fs::remove_dir_all(&scratch); - let entry = entry?; - println!(" {} {}", entry.id, entry.version); - index.plugins.push(entry); + let entry = entry.map_err(|e| format!("{}: {e}", path.display()))?; + println!(" {} {} (plugin API {})", entry.id, entry.version, entry.engine); + added.push(entry); } - let json = serde_json::to_string_pretty(&index).map_err(|e| e.to_string())?; - std::fs::write(&out, json).map_err(|e| format!("{}: {e}", out.display()))?; + // A publisher's earlier versions of these plugins stay for IDEs that can't load the new + // ones (pruned below); plugins no longer in the folder go. + index.plugins.retain(|e| match publisher { + Some(p) => { + e.publisher != p + || (added.iter().any(|n| n.id == e.id) + && !added.iter().any(|n| (&n.id, &n.version) == (&e.id, &e.version))) + } + None => false, + }); + index.plugins.extend(added); + index.prune_versions(); + write_index(&index, &out) +} + +/// `publishers` with `key` as `publisher`'s current key. Earlier keys stay listed +/// (packages they signed remain valid until revoked); a key new to the +/// publisher is endorsed by `previous`, one of them, so installed plugins +/// signed with it update without asking. +fn register_key( + mut publishers: Vec, + publisher: &str, + name: &str, + key: &PublisherKey, + previous: Option<&PublisherKey>, +) -> Result, String> { + let current = key.public_base64(); + let earlier: Vec = publishers.iter().filter(|p| p.id == publisher).flat_map(|p| p.keys.clone()).collect(); + let entry = match earlier.iter().find(|k| k.key == current) { + Some(listed) => listed.clone(), + None => { + let endorsement = match previous { + Some(prev) => { + let prev_key = prev.public_base64(); + if !earlier.iter().any(|k| k.key == prev_key && !k.revoked) { + return Err(format!("--previous-key is not one of {publisher}'s valid keys in the index")); + } + Some(prev.endorse(publisher, ¤t)) + } + None => { + if earlier.iter().any(|k| !k.revoked) { + println!( + " warning: {publisher}'s new key is not endorsed (--previous-key): users who installed \ + its plugins will be asked to confirm the change when they update" + ); + } + None + } + }; + KeyEntry { endorsement, ..KeyEntry::new(current.clone()) } + } + }; + let keys = std::iter::once(entry).chain(earlier.into_iter().filter(|k| k.key != current)).collect(); + publishers.retain(|p| p.id != publisher); + publishers.push(package::Publisher { id: publisher.into(), name: name.into(), keys }); + Ok(publishers) +} + +/// `index`, unless a newer `openidea-plugin` wrote it (rewriting it would drop what this one can't read). +fn current_format(index: Index, file: &str) -> Result { + if index.is_newer() { + return Err(format!( + "{file}: index format {} is newer than this tool writes; update openidea-plugin", + index.format + )); + } + Ok(index) +} + +fn cmd_merge(args: &[String]) -> Result<(), String> { + let a = Args::parse(args, &[], &["keep", "out"])?; + let [base, other] = a.positional.as_slice() else { + return Err("usage: openidea-plugin merge --keep ID [--out FILE]".into()); + }; + let keep = a.value("keep").ok_or("--keep is required")?; + let out = a.value("out").unwrap_or(base); + 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 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); + } + let count = theirs.plugins.len(); + index.include(theirs, &[keep]).map_err(|e| format!("{other}: {e}"))?; + println!(" {count} plugins from {other}"); + write_index(&index, Path::new(out)) +} + +fn write_index(index: &Index, out: &Path) -> Result<(), String> { + let json = serde_json::to_string_pretty(index).map_err(|e| e.to_string())?; + std::fs::write(out, json).map_err(|e| format!("{}: {e}", out.display()))?; println!("-> {} ({} plugins)", out.display(), index.plugins.len()); Ok(()) } @@ -589,6 +750,25 @@ fn cmd_index(args: &[String]) -> 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"); @@ -601,6 +781,126 @@ mod tests { assert_eq!(relative(Path::new("/x/y"), Path::new("/z")), None); } + /// A theme package `id` signed by `publisher` with `key`, packed into `out`. + fn signed_theme(tmp: &Path, id: &str, publisher: &str, key: &str, out: &Path) { + signed_theme_version(tmp, id, "0.1.0", "^1.0", publisher, key, out); + } + + fn signed_theme_version(tmp: &Path, id: &str, version: &str, engine: &str, publisher: &str, key: &str, out: &Path) { + let src = tmp.join("src").join(format!("{id}-{version}")); + let vars = templates::Vars { id, name: id, sdk: None }; + for (rel, content) in templates::render(templates::Template::Theme, &vars) { + let path = src.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + let content = content + .replace("version = \"0.1.0\"", &format!("version = \"{version}\"")) + .replace("engines.openidea = \"^1.0\"", &format!("engines.openidea = \"{engine}\"")); + std::fs::write(path, content).unwrap(); + } + std::fs::create_dir_all(out).unwrap(); + let args = [src.to_str().unwrap(), "--key", key, "--publisher", publisher, "--out"] + .into_iter() + .map(String::from) + .chain([out.join(format!("{id}-{version}.oiplugin")).to_string_lossy().into_owned()]) + .collect::>(); + cmd_pack(&args).unwrap(); + } + + #[test] + fn a_release_keeps_the_versions_older_ides_need() { + let tmp = tempfile::tempdir().unwrap(); + let t = tmp.path(); + let key = t.join("acme.key"); + std::fs::write(&key, PublisherKey::from_seed([5; 32]).seed_base64()).unwrap(); + let key = key.to_string_lossy().into_owned(); + let index_file = t.join("index.json"); + let release = |n: usize, packages: &[(&str, &str, &str)]| { + let dir = t.join(format!("release-{n}")); + for (id, version, engine) in packages { + signed_theme_version(t, id, version, engine, "acme", &key, &dir); + } + let base = format!("https://example.com/download/v{n}"); + let args = [dir.to_str().unwrap(), "--publisher", "acme", "--key", &key, "--download-base", &base]; + cmd_index(&strings(&[&args[..], &["--out", index_file.to_str().unwrap()]].concat())).unwrap(); + let index = Index::parse(&std::fs::read_to_string(&index_file).unwrap()).unwrap(); + index.plugins.iter().map(|e| format!("{} {} {}", e.id, e.version, e.download)).collect::>() + }; + release(1, &[("acme.a", "1.0.0", "^1.0"), ("acme.b", "1.0.0", "^1.0")]); + // Same requirement: the newer version replaces it; acme.b is gone from the release. + assert_eq!( + release(2, &[("acme.a", "1.1.0", "^1.0")]), + ["acme.a 1.1.0 https://example.com/download/v2/acme.a-1.1.0.oiplugin"] + ); + // Another API requirement: the last version for the earlier one stays, where it was published. + assert_eq!( + release(3, &[("acme.a", "2.0.0", ">=1.0, <2")]), + [ + "acme.a 1.1.0 https://example.com/download/v2/acme.a-1.1.0.oiplugin", + "acme.a 2.0.0 https://example.com/download/v3/acme.a-2.0.0.oiplugin", + ] + ); + let index = Index::parse(&std::fs::read_to_string(&index_file).unwrap()).unwrap(); + assert_eq!(index.offered()[0].0.version, "2.0.0", "the newest this IDE can load"); + } + + fn strings(args: &[&str]) -> Vec { + args.iter().map(|a| a.to_string()).collect() + } + + #[test] + fn official_and_community_indexes_merge() { + let tmp = tempfile::tempdir().unwrap(); + let t = tmp.path(); + let mut keys = Vec::new(); + for (name, seed) in [("openidea", 1), ("alice", 2), ("carol", 3)] { + let file = t.join(format!("{name}.key")); + let key = PublisherKey::from_seed([seed; 32]); + std::fs::write(&file, key.seed_base64()).unwrap(); + keys.push((file.to_string_lossy().into_owned(), key.public_base64())); + } + let (official, community) = (t.join("official"), t.join("community")); + signed_theme(t, "openidea.night", "openidea", &keys[0].0, &official); + signed_theme(t, "alice.dawn", "alice", &keys[1].0, &community); + let official_index = official.join("index.json"); + let community_index = community.join("index.json"); + let release = strings(&[official.to_str().unwrap(), "--publisher", "openidea", "--key", &keys[0].0]); + cmd_index(&release).unwrap(); + + // The community index lists its publishers; `index` without a key checks packages against them. + let skeleton = Index { + format: package::INDEX_FORMAT, + publishers: vec![package::Publisher { + id: "alice".into(), + name: "Alice".into(), + keys: vec![KeyEntry::new(keys[1].1.clone())], + }], + ..Index::default() + }; + write_index(&skeleton, &community_index).unwrap(); + cmd_index(&strings(&[community.to_str().unwrap()])).unwrap(); + + cmd_merge(&strings(&[ + official_index.to_str().unwrap(), + community_index.to_str().unwrap(), + "--keep", + "openidea", + ])) + .unwrap(); + // A release rebuilds the official plugins and keeps the community's. + cmd_index(&release).unwrap(); + let index = Index::parse(&std::fs::read_to_string(&official_index).unwrap()).unwrap(); + let listed: Vec<_> = index.plugins.iter().map(|e| (e.id.as_str(), e.publisher.as_str())).collect(); + assert_eq!(listed, [("alice.dawn", "alice"), ("openidea.night", "openidea")]); + assert_eq!(index.plugins[1].categories, ["themes"], "implied by the theme"); + assert_eq!(index.plugins[1].download, "openidea.night-0.1.0.oiplugin"); + assert!(Path::new(&index.plugins[0].download).is_absolute(), "{}", index.plugins[0].download); + + // A package by a publisher the index doesn't know is refused. + signed_theme(t, "carol.noon", "carol", &keys[2].0, &community); + let err = cmd_index(&strings(&[community.to_str().unwrap()])).unwrap_err(); + assert!(err.contains("unknown publisher"), "{err}"); + } + #[test] fn every_template_is_a_valid_package() { for t in templates::Template::ALL { diff --git a/crates/ide-host/src/bin/openidea-plugin/templates.rs b/crates/ide-host/src/bin/openidea-plugin/templates.rs index fc23cdd0..6c1c04e0 100644 --- a/crates/ide-host/src/bin/openidea-plugin/templates.rs +++ b/crates/ide-host/src/bin/openidea-plugin/templates.rs @@ -82,6 +82,9 @@ name = "__NAME__" version = "0.1.0" description = "Adds two sample commands." authors = [] +# Marketplace categories (up to 3): languages, themes, icon-themes, linters, +# vcs, ai, frameworks, tools, other. +categories = ["tools"] engines.openidea = "^1.0" # Built by `openidea-plugin build`. The code is loaded the first time one of # its commands runs; the commands below show in Find Action before that. diff --git a/crates/ide-host/src/plugins/market.rs b/crates/ide-host/src/plugins/market.rs index 4ab77fdb..061b5df2 100644 --- a/crates/ide-host/src/plugins/market.rs +++ b/crates/ide-host/src/plugins/market.rs @@ -3,11 +3,14 @@ //! plugins, and revocations. The host keeps the last index it read //! (`market-index.json`), checks signatures against it, and disables what it //! revokes — at start-up and on every refresh (the desktop refreshes daily). +//! Release builds pin the official publisher to the keys they were built +//! with, so no index can sign `openidea.*` plugins with another key. -use super::{PackageFile, PluginHost, PluginSource, PluginStatus}; +use super::{PackageFile, PluginEvent, PluginHost, PluginSource, PluginStatus, PluginUpdate, install}; use ide_core::{IdeError, IdeResult}; use ide_extension::package::{self, Index, IndexEntry, PackageSignature, SIGNATURE_FILE}; use ide_extension::{MANIFEST_FILE, Manifest}; +use semver::Version; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; @@ -15,6 +18,9 @@ use std::path::{Path, PathBuf}; /// the app, next to the signed packages of the official plugins. pub const OFFICIAL_INDEX: &str = "https://github.com/l0ng-ai/openidea/releases/latest/download/plugins-index.json"; +/// The publisher of the official plugins. +pub const OFFICIAL_PUBLISHER: &str = "openidea"; + /// The last index read, in the plugins directory. pub const INDEX_FILE: &str = "market-index.json"; const DOWNLOADS: &str = ".downloads"; @@ -22,6 +28,8 @@ const MAX_INDEX: usize = 16 << 20; const MAX_PACKAGE: usize = 256 << 20; const MAX_STAGED_FILES: usize = 256; const MAX_STAGED_BYTES: usize = 16 << 20; +/// `moved` links followed before giving up (a loop, or a misconfiguration). +const MAX_MOVES: usize = 3; /// A marketplace entry with what is installed. #[derive(Clone, Debug, Serialize, Deserialize, specta::Type)] @@ -31,6 +39,32 @@ pub struct MarketPlugin { pub entry: IndexEntry, /// Installed version, if any. pub installed: Option, + /// Whether this IDE can load `entry` (the newest version it can, else the newest). + pub compatible: bool, +} + +impl MarketPlugin { + /// A version this IDE can load, newer than the one installed. + pub fn is_update(&self) -> bool { + let newer = |installed: &String| match (Version::parse(&self.entry.version), Version::parse(installed)) { + (Ok(offered), Ok(installed)) => offered > installed, + _ => false, + }; + self.compatible && self.installed.as_ref().is_some_and(newer) + } +} + +/// The official publisher's keys this IDE was built with +/// (`OPENIDEA_OFFICIAL_PLUGIN_KEYS`: base64, comma-separated). Development +/// builds have none: the index decides. +pub(super) fn built_in_official_keys() -> Vec { + let keys = option_env!("OPENIDEA_OFFICIAL_PLUGIN_KEYS").unwrap_or_default(); + keys.split(',').map(str::trim).filter(|k| !k.is_empty()).map(str::to_owned).collect() +} + +fn pinned(mut index: Index, official_keys: &[String]) -> Index { + index.pin_publisher(OFFICIAL_PUBLISHER, "OpenIDEA", official_keys); + index } /// Reads `url`: `http(s)://…`, `file://…` or a local path. @@ -58,6 +92,33 @@ pub fn read_url(url: &str, max: usize) -> Result, String> { Ok(bytes) } +/// The index at `url`, following `moved`, with downloads made absolute against +/// where it was read from. +fn read_index(url: &str) -> IdeResult { + let mut url = url.to_owned(); + for _ in 0..=MAX_MOVES { + let bytes = read_url(&url, MAX_INDEX).map_err(IdeError::unavailable)?; + let text = String::from_utf8(bytes).map_err(|_| IdeError::invalid("the index is not UTF-8"))?; + let mut index = Index::parse(&text).map_err(IdeError::invalid)?; + match index.moved.take().filter(|m| !m.trim().is_empty()) { + Some(next) => { + let next = resolve(&url, &next); + if url.starts_with("https://") && !next.starts_with("https://") { + return Err(IdeError::invalid(format!("the marketplace moved to {next}, which isn't https"))); + } + url = next; + } + None => { + for e in &mut index.plugins { + e.download = resolve(&url, &e.download); + } + return Ok(index); + } + } + } + Err(IdeError::unavailable(format!("the marketplace index moved more than {MAX_MOVES} times"))) +} + /// `href` relative to the index at `base`: a URL, or a local path (with the /// platform's separators, e.g. `C:\\market\\index.json`). pub fn resolve(base: &str, href: &str) -> String { @@ -77,9 +138,16 @@ pub fn resolve(base: &str, href: &str) -> String { } impl PluginHost { - pub(super) fn load_market_index(dir: &Path) -> Option { + pub(super) fn load_market_index(dir: &Path, official_keys: &[String]) -> Option { let text = std::fs::read_to_string(dir.join(INDEX_FILE)).ok()?; - Index::parse(&text).map_err(|e| tracing::warn!("{INDEX_FILE}: {e}")).ok() + let index = Index::parse(&text).map_err(|e| tracing::warn!("{INDEX_FILE}: {e}")).ok()?; + Some(pinned(index, official_keys)) + } + + /// Pins the official publisher to `keys` (base64) instead of the built-in + /// ones, from the next index read. + pub fn set_official_keys(&self, keys: Vec) { + *self.official_keys.write() = keys; } /// The marketplace index URL: the user's, else the default (none: no marketplace). @@ -97,14 +165,16 @@ impl PluginHost { self.save_state(); } - /// Reads the index again, keeps it, and applies its revocations. + /// Reads the index again, keeps it, and applies its revocations. Announces + /// updates of installed plugins it hasn't announced yet. pub fn refresh_market(&self) -> IdeResult> { let url = self.market_url().ok_or_else(|| IdeError::unavailable("no plugin marketplace is configured"))?; - let bytes = read_url(&url, MAX_INDEX).map_err(IdeError::unavailable)?; - let text = String::from_utf8(bytes).map_err(|_| IdeError::invalid("the index is not UTF-8"))?; - let index = Index::parse(&text).map_err(IdeError::invalid)?; - let _ = std::fs::write(self.plugins_dir.join(INDEX_FILE), &text); - *self.market.write() = Some(index); + 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); + } + 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(); @@ -113,7 +183,41 @@ impl PluginHost { plugin.meta.lock().publisher = publisher; } self.apply_revocations(); - Ok(self.market_plugins()) + if let Some(format) = newer { + return Err(IdeError::unavailable(format!( + "the marketplace needs a newer OpenIDEA (index format {format}); its revocations still apply" + ))); + } + let plugins = self.market_plugins(); + self.announce_updates(&plugins); + Ok(plugins) + } + + /// Tells the windows about updates once per set of versions: a later + /// refresh offering the same ones says nothing. + fn announce_updates(&self, plugins: &[MarketPlugin]) { + let updates: Vec = plugins + .iter() + .filter(|p| p.is_update()) + .map(|p| PluginUpdate { + id: p.entry.id.clone(), + name: p.entry.name.clone(), + version: p.entry.version.clone(), + }) + .collect(); + let key: Vec = updates.iter().map(|u| format!("{}@{}", u.id, u.version)).collect(); + if std::mem::replace(&mut *self.announced_updates.lock(), key.clone()) != key && !updates.is_empty() { + self.emit(None, PluginEvent::UpdatesAvailable { updates }); + } + } + + /// Whether the package in `src` would replace the installed `id`'s copy + /// with one signed by another key than that copy, without that key's + /// endorsement: the user confirms the change of signer. + pub(super) fn signer_changed(&self, id: &str, src: &Path) -> bool { + let Some(current) = install::active_dir(&self.plugins_dir.join(id)) else { return false }; + let market = self.market.read(); + !package::signer_continues(market.as_ref(), read_signature(¤t).as_ref(), read_signature(src).as_ref()) } /// The plugins of the last index read. @@ -121,16 +225,14 @@ impl PluginHost { let Some(index) = self.market.read().clone() else { return Vec::new() }; let plugins = self.plugins.read(); index - .plugins - .iter() - .filter(|e| index.revocation(&e.id, &e.version).is_none()) - .cloned() - .map(|entry| { + .offered() + .into_iter() + .map(|(entry, compatible)| { let installed = plugins .get(&entry.id) .filter(|p| p.source == PluginSource::Installed) .map(|p| p.manifest().version.clone()); - MarketPlugin { entry, installed } + MarketPlugin { entry: entry.clone(), installed, compatible } }) .collect() } @@ -141,12 +243,18 @@ impl PluginHost { pub fn download_market_plugin(&self, id: &str) -> IdeResult { let url = self.market_url().ok_or_else(|| IdeError::unavailable("no plugin marketplace is configured"))?; let index = self.market.read().clone().ok_or_else(|| IdeError::unavailable("refresh the marketplace first"))?; - let entry = index - .plugins - .iter() - .find(|e| e.id == id) - .cloned() + let (entry, compatible) = index + .offered() + .into_iter() + .find(|(e, _)| e.id == id) + .map(|(e, ok)| (e.clone(), ok)) .ok_or_else(|| IdeError::not_found(format!("{id} is not in the marketplace")))?; + if !compatible { + return Err(IdeError::unavailable(format!( + "{} {} needs plugin API {}: update OpenIDEA", + entry.name, entry.version, entry.engine + ))); + } let bytes = read_url(&resolve(&url, &entry.download), MAX_PACKAGE).map_err(IdeError::unavailable)?; let downloads = self.plugins_dir.join(DOWNLOADS); std::fs::create_dir_all(&downloads)?; diff --git a/crates/ide-host/src/plugins/mod.rs b/crates/ide-host/src/plugins/mod.rs index 208da138..0b82a935 100644 --- a/crates/ide-host/src/plugins/mod.rs +++ b/crates/ide-host/src/plugins/mod.rs @@ -36,7 +36,10 @@ 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}; +pub use market::{ + 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}; pub(crate) use providers::editor_providers as editor_providers_for; pub use providers::{ProviderKind, set_editor_providers}; @@ -242,12 +245,16 @@ pub struct Consent { /// Optional capabilities the user accepted (`net:crates.io`, …). Required /// ones are implied by consenting at all. pub optional: Vec, + /// The user accepted a package signed by another key than the installed + /// copy ([`InstallPreview::signer_changed`]). + #[serde(default)] + pub signer_change: bool, } impl Consent { /// Accept everything the plugin asks for (tests, trusted official plugins). pub fn all() -> Self { - Self { optional: vec!["*".into()] } + Self { optional: vec!["*".into()], signer_change: true } } fn accepts(&self, cap: &Capability) -> bool { @@ -274,6 +281,14 @@ pub struct InstallPreview { pub unsigned: bool, /// The verified publisher. pub publisher: Option, + /// Replaces an installed copy signed with another key, which didn't + /// endorse this package's (or this one is unsigned): the user confirms it + /// ([`Consent::signer_change`]). + pub signer_changed: bool, + /// Something to confirm: a first install, an unsigned package, another + /// signer, or capabilities the installed version wasn't granted or + /// refused. Without, an update installs with the grants it has. + pub needs_consent: bool, } type ProjectPolicy = Arc bool + Send + Sync>; @@ -320,6 +335,10 @@ pub struct PluginHost { pins: Arc>>, /// The last marketplace index read: trust root for signatures, revocations. market: RwLock>, + /// The official publisher's keys the index is pinned to (none: as listed). + official_keys: RwLock>, + /// `id@version` of the updates last announced. + announced_updates: Mutex>, /// The marketplace used while the user has set none (the app's official one). default_market_url: RwLock>, /// Updates not confirmed yet (the new version hasn't started once): what @@ -358,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)), @@ -703,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 = - 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(), @@ -721,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, }) } @@ -746,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))); @@ -2211,6 +2253,7 @@ fn placeholder_manifest(dir: &Path) -> Manifest { homepage: None, license: None, change_notes: None, + declared_categories: Vec::new(), engine: ide_extension::ENGINE_VERSION.into(), legacy: false, wasm: None, diff --git a/crates/ide-host/src/plugins/remote_host.rs b/crates/ide-host/src/plugins/remote_host.rs index 71be9116..947c0b9b 100644 --- a/crates/ide-host/src/plugins/remote_host.rs +++ b/crates/ide-host/src/plugins/remote_host.rs @@ -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)); diff --git a/crates/ide-host/src/plugins/types.rs b/crates/ide-host/src/plugins/types.rs index db3b18f9..fc13800f 100644 --- a/crates/ide-host/src/plugins/types.rs +++ b/crates/ide-host/src/plugins/types.rs @@ -138,12 +138,24 @@ pub struct PluginsSnapshot { pub ui: Vec, } +/// 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 }, #[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. diff --git a/crates/ide-host/tests/distribution.rs b/crates/ide-host/tests/distribution.rs index 540073a5..a8d97934 100644 --- a/crates/ide-host/tests/distribution.rs +++ b/crates/ide-host/tests/distribution.rs @@ -131,25 +131,44 @@ 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 `.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![], + engine: "^1.0".into(), + categories: vec!["tools".into()], }; (out, entry) } @@ -166,10 +185,11 @@ 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, + moved: None, }; let path = dir.join("index.json"); std::fs::write(&path, serde_json::to_string(&index).unwrap()).unwrap(); @@ -260,3 +280,210 @@ fn package_files_install_from_disk_unsigned() { let info = f.host.install_from_dir(&dir, &Consent::all()).unwrap(); assert_eq!((info.status, info.publisher), (PluginStatus::Inactive, None)); } + +#[test] +fn the_marketplace_offers_the_newest_version_this_ide_can_load() { + let f = fixture(); + let market = f.work.join("market"); + std::fs::create_dir_all(&market).unwrap(); + let key = PublisherKey::from_seed([3; 32]); + let (_, old) = signed_hello(&market, "1.0.0", &key, &f.host); + let (_, new) = signed_hello(&market, "2.0.0", &key, &f.host); + let next_major = ide_extension::ENGINE_VERSION.split('.').next().unwrap().parse::().unwrap() + 1; + let new = IndexEntry { engine: format!("^{next_major}.0"), ..new }; + f.host.set_market_url(Some(write_index(&market, &key, vec![old.clone(), new.clone()], vec![], false))); + + let listed = f.host.refresh_market().unwrap(); + assert_eq!((listed.len(), listed[0].entry.version.as_str(), listed[0].compatible), (1, "1.0.0", true)); + let dir = f.host.download_market_plugin("acme.hello").unwrap(); + assert_eq!(f.host.preview_install(&dir).unwrap().version, "1.0.0"); + + // Only a version for a newer IDE: shown as such, not downloaded. + write_index(&market, &key, vec![new], vec![], false); + let listed = f.host.refresh_market().unwrap(); + assert_eq!((listed[0].entry.version.as_str(), listed[0].compatible), ("2.0.0", false)); + let err = f.host.download_market_plugin("acme.hello").unwrap_err(); + assert!(err.message.contains("update OpenIDEA"), "{}", err.message); +} + +#[test] +fn a_moved_marketplace_is_read_where_it_moved_to() { + 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); + write_index(&market, &key, vec![entry], vec![], false); + // The old address points at the new one; downloads resolve against where the index is. + let old = f.work.join("old"); + std::fs::create_dir_all(&old).unwrap(); + std::fs::write(old.join("index.json"), r#"{"format": 1, "moved": "../market/index.json"}"#).unwrap(); + f.host.set_market_url(Some(old.join("index.json").to_string_lossy().into_owned())); + + let listed = f.host.refresh_market().unwrap(); + assert_eq!(listed[0].entry.id, "acme.hello"); + let dir = f.host.download_market_plugin("acme.hello").unwrap(); + assert_eq!(f.host.preview_install(&dir).unwrap().version, "1.0.0"); + + // A loop gives up. + std::fs::write(old.join("index.json"), r#"{"format": 1, "moved": "index.json"}"#).unwrap(); + 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, plugins: Vec) -> 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) -> Publisher { + Publisher { id: id.into(), name: id.into(), keys } +} + +fn updates_announced(events: &Events) -> Vec> { + 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)); +} diff --git a/plugins/README.md b/plugins/README.md index d90839c1..ae53436b 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -79,6 +79,7 @@ authors = ["You "] homepage = "https://example.com" # optional license = "MIT" # optional change-notes = "1.2.0 — …" # shown under What's New +categories = ["tools"] # marketplace categories, see below engines.openidea = "^1.0" # the plugin API this package needs wasm = "dist/plugin.wasm" # omit for data-only plugins activation = ["on-language:markdown"] # optional, see below @@ -94,6 +95,13 @@ category = "Word Tools" # group in Search Everywhere / Keymap when = "editorFocus" # optional: same expressions as built-in actions ``` +`categories` (up to 3) file the plugin in the marketplace: `languages`, +`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, 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 installed or loaded. Contribution types the IDE does not know (a newer API) are @@ -739,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 ` (§1). Listed with a _linked_ tag; capabilities are granted per folder. - **Project plugins:** put (or symlink) the package in @@ -758,6 +772,9 @@ files). openidea-plugin keygen acme.key # once; keep the file secret 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 --key acme.key --publisher acme # when rotating keys ``` A **marketplace** is static: an `index.json` and the packages next to it, on @@ -773,8 +790,68 @@ 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": ""` 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; 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 +plugin, the newest version per API requirement, so users of an older IDE keep +the last version built for them. + +`index` with a key registers the publisher and lists the folder's packages +as its plugins (their earlier versions stay as above; plugins no longer in the +folder go), keeping the other publishers'; `--download-base` makes downloads +absolute, so a version stays where it was first published. Without a key, it +lists every package in the folder, each checked against the publishers the +index already has. `merge` takes the +publishers, plugins and revocations of another index (downloads made absolute) +in place of every publisher but `--keep`; `check` also takes a `.oiplugin`. + +### 8.1 Official and community plugins + +The IDE's default marketplace is `plugins-index.json` of the latest release. +It lists two kinds of plugins: + +- **Official** (`plugins/official/`, ids `openidea.*`): maintained in this + repository, built and signed by its release workflow, and kept working as + the API changes. New official plugins are proposed in an issue first; they + cover general needs or show how to use part of the API. +- **Community** (their authors' ids and keys): developed in their authors' + repositories and listed through the registry, + [l0ng-ai/openidea-plugins](https://github.com/l0ng-ai/openidea-plugins). + An author registers a publisher (an id prefix, its public key and the GitHub + accounts that may change it) and adds each plugin version by pull request; + the registry checks the package (digest, signature, `openidea-plugin check`) + and publishes its index, which `plugins-index.yml` merges into the release's + within the hour. Its README has the rules. + +Pull requests that add a plugin to this repository are for official plugins. +A plugin that needs a new extension point brings the extension point here, as +its own pull request, and the plugin to the registry. ## 9. Testing the host diff --git a/plugins/official/ai-completion/dist/plugin.wasm b/plugins/official/ai-completion/dist/plugin.wasm index ee11a42e..c51a0c67 100644 Binary files a/plugins/official/ai-completion/dist/plugin.wasm and b/plugins/official/ai-completion/dist/plugin.wasm differ diff --git a/plugins/official/ai-completion/dist/plugin.wasm.sources b/plugins/official/ai-completion/dist/plugin.wasm.sources index 43ce4cdf..8baa3d1f 100644 --- a/plugins/official/ai-completion/dist/plugin.wasm.sources +++ b/plugins/official/ai-completion/dist/plugin.wasm.sources @@ -1 +1 @@ -bf264a97dc918f24ac00f0e9e0d0373b68391a96921b44345b466c2a087c5283 +aa09a92c0ba9d85a01c56bbf4455e40ae44f005b443ce1002bbe8011408ff975 diff --git a/plugins/official/ai-completion/plugin.toml b/plugins/official/ai-completion/plugin.toml index 8cf1c563..bf21db21 100644 --- a/plugins/official/ai-completion/plugin.toml +++ b/plugins/official/ai-completion/plugin.toml @@ -5,6 +5,7 @@ description = "Inline code suggestions (ghost text) from a local model (Ollama) authors = ["OpenIDEA contributors"] license = "Apache-2.0" change-notes = "0.1.0 — first release: local (Ollama) and OpenAI backends; the key stays in the system keychain." +categories = ["ai"] engines.openidea = "^1.0" wasm = "dist/plugin.wasm" activation = ["on-command:*"] diff --git a/plugins/official/checkstyle/dist/plugin.wasm b/plugins/official/checkstyle/dist/plugin.wasm index b7508504..98fccd27 100644 Binary files a/plugins/official/checkstyle/dist/plugin.wasm and b/plugins/official/checkstyle/dist/plugin.wasm differ diff --git a/plugins/official/checkstyle/dist/plugin.wasm.sources b/plugins/official/checkstyle/dist/plugin.wasm.sources index 6192fb92..1c46bf44 100644 --- a/plugins/official/checkstyle/dist/plugin.wasm.sources +++ b/plugins/official/checkstyle/dist/plugin.wasm.sources @@ -1 +1 @@ -6095c7e30658b93d79270e6355d42941a4cfe13eeb8bada07acc7ae462194853 +908c2fd177ad2bb9a8a8492505151c3b81579191156c5ec53f705127f50a8ee9 diff --git a/plugins/official/checkstyle/plugin.toml b/plugins/official/checkstyle/plugin.toml index 444157b6..1732ad30 100644 --- a/plugins/official/checkstyle/plugin.toml +++ b/plugins/official/checkstyle/plugin.toml @@ -5,6 +5,7 @@ description = "Checks Java files with Checkstyle when you save them: problems in authors = ["OpenIDEA contributors"] license = "Apache-2.0" change-notes = "0.1.0 — first release: check on save (a newer save cancels the running check), Google style by default, quick fixes." +categories = ["linters"] engines.openidea = "^1.0" wasm = "dist/plugin.wasm" activation = ["on-event:file-saved", "on-command:*"] diff --git a/plugins/official/csv-editor/dist/plugin.wasm b/plugins/official/csv-editor/dist/plugin.wasm index 539a1dce..f3529166 100644 Binary files a/plugins/official/csv-editor/dist/plugin.wasm and b/plugins/official/csv-editor/dist/plugin.wasm differ diff --git a/plugins/official/csv-editor/dist/plugin.wasm.sources b/plugins/official/csv-editor/dist/plugin.wasm.sources index d1b11a17..fcd50761 100644 --- a/plugins/official/csv-editor/dist/plugin.wasm.sources +++ b/plugins/official/csv-editor/dist/plugin.wasm.sources @@ -1 +1 @@ -b96941af697ce7a6c1c8ef1539d34aa7c23d2d41dd1b2ccffdfda5648f90cf56 +64270fa0f5b3084c78764f09b8ce161742fdbff9c28fb90590da39594e558db1 diff --git a/plugins/official/csv-editor/plugin.toml b/plugins/official/csv-editor/plugin.toml index 06d920a5..e42c1d7a 100644 --- a/plugins/official/csv-editor/plugin.toml +++ b/plugins/official/csv-editor/plugin.toml @@ -5,6 +5,7 @@ description = "Edit CSV and TSV files as a table. Edits go to the file's text, s authors = ["OpenIDEA contributors"] license = "Apache-2.0" change-notes = "0.1.0 — first release: table editor for .csv and .tsv with cell editing, inserting and deleting rows; large files load the visible rows only." +categories = ["tools"] engines.openidea = "^1.0" wasm = "dist/plugin.wasm" activation = ["on-view:table"] diff --git a/plugins/official/excalidraw/plugin.toml b/plugins/official/excalidraw/plugin.toml index 70bf7ad0..fdfc495b 100644 --- a/plugins/official/excalidraw/plugin.toml +++ b/plugins/official/excalidraw/plugin.toml @@ -5,6 +5,7 @@ description = "Draw and edit Excalidraw diagrams (.excalidraw). The drawing is t authors = ["OpenIDEA contributors"] license = "Apache-2.0" change-notes = "0.1.0 — first release: the Excalidraw canvas next to (or instead of) the file's text, kept in step both ways; export to PNG and SVG through the save dialog; a file icon and a New › Excalidraw Drawing template. Based on the Excalidraw editor proposed by Deng Liu (#738)." +categories = ["tools"] engines.openidea = "^1.0" [capabilities] diff --git a/plugins/official/git-toolbox/dist/plugin.wasm b/plugins/official/git-toolbox/dist/plugin.wasm index 9479f183..c7d8ad40 100644 Binary files a/plugins/official/git-toolbox/dist/plugin.wasm and b/plugins/official/git-toolbox/dist/plugin.wasm differ diff --git a/plugins/official/git-toolbox/dist/plugin.wasm.sources b/plugins/official/git-toolbox/dist/plugin.wasm.sources index fd509401..0bcb7925 100644 --- a/plugins/official/git-toolbox/dist/plugin.wasm.sources +++ b/plugins/official/git-toolbox/dist/plugin.wasm.sources @@ -1 +1 @@ -ad545605b33c6f4e55a06a376333de3162739ed920b5d7d277942e50a4420083 +22876f842f727a2b9aa292986df3393f5b5f30c5c34319f5665f7d2701c8accf diff --git a/plugins/official/git-toolbox/plugin.toml b/plugins/official/git-toolbox/plugin.toml index ff5bbfce..d0f02117 100644 --- a/plugins/official/git-toolbox/plugin.toml +++ b/plugins/official/git-toolbox/plugin.toml @@ -5,6 +5,7 @@ description = "Fetches your repositories in the background and shows the branch authors = ["OpenIDEA contributors"] license = "Apache-2.0" change-notes = "0.1.0 — first release: auto-fetch every 5 minutes, ahead/behind in the status bar, Fetch Now." +categories = ["vcs"] engines.openidea = "^1.0" wasm = "dist/plugin.wasm" # Fetches on schedule (also after being reclaimed while idle) and refreshes diff --git a/plugins/official/graphviz/dist/plugin.wasm b/plugins/official/graphviz/dist/plugin.wasm index fa2c8114..337a9781 100644 Binary files a/plugins/official/graphviz/dist/plugin.wasm and b/plugins/official/graphviz/dist/plugin.wasm differ diff --git a/plugins/official/graphviz/dist/plugin.wasm.sources b/plugins/official/graphviz/dist/plugin.wasm.sources index 8e261ce0..f750b77c 100644 --- a/plugins/official/graphviz/dist/plugin.wasm.sources +++ b/plugins/official/graphviz/dist/plugin.wasm.sources @@ -1 +1 @@ -de353be96a6b66629901f55a8027a0f27107a7ac2fb9d891089db1b7fd716299 +14c60782eca79a25cf2c38d58c05be0eb97c4afc607d4cc83302d1445d094aab diff --git a/plugins/official/graphviz/plugin.toml b/plugins/official/graphviz/plugin.toml index 04f87a2e..8cf06eb4 100644 --- a/plugins/official/graphviz/plugin.toml +++ b/plugins/official/graphviz/plugin.toml @@ -5,6 +5,7 @@ description = "Graphviz DOT files (.dot, .gv) with a live preview beside the tex authors = ["OpenIDEA contributors"] license = "Apache-2.0" change-notes = "0.1.0 — first release: live preview (Graphviz compiled to WebAssembly), layout engines, zoom, errors as problems, SVG and PNG export." +categories = ["languages", "tools"] engines.openidea = "^1.0" wasm = "dist/plugin.wasm" activation = ["on-view:preview"] diff --git a/plugins/official/key-promoter/dist/plugin.wasm b/plugins/official/key-promoter/dist/plugin.wasm index daefc884..9ef9e3d0 100644 Binary files a/plugins/official/key-promoter/dist/plugin.wasm and b/plugins/official/key-promoter/dist/plugin.wasm differ diff --git a/plugins/official/key-promoter/dist/plugin.wasm.sources b/plugins/official/key-promoter/dist/plugin.wasm.sources index 9965326d..069068aa 100644 --- a/plugins/official/key-promoter/dist/plugin.wasm.sources +++ b/plugins/official/key-promoter/dist/plugin.wasm.sources @@ -1 +1 @@ -345c39f818e4950914bf3fcc795fac9e30ff2d87ad3e66f24c7bc00800f92980 +7f5aacde2b8a8bcee5365c8cbda91045195688899c6537dc2159037cca74a672 diff --git a/plugins/official/key-promoter/plugin.toml b/plugins/official/key-promoter/plugin.toml index 10f032b4..3a430fb9 100644 --- a/plugins/official/key-promoter/plugin.toml +++ b/plugins/official/key-promoter/plugin.toml @@ -5,6 +5,7 @@ description = "Shows the keyboard shortcut of an action you ran with the mouse, authors = ["OpenIDEA contributors"] license = "Apache-2.0" change-notes = "0.1.0 — first release: shortcut tips, statistics, per-action \"don't show again\"." +categories = ["tools"] engines.openidea = "^1.0" wasm = "dist/plugin.wasm" # Actions arrive in batches (a few seconds apart while the plugin is idle), diff --git a/plugins/official/license-header/dist/plugin.wasm b/plugins/official/license-header/dist/plugin.wasm index 6cd6d293..1c82e278 100644 Binary files a/plugins/official/license-header/dist/plugin.wasm and b/plugins/official/license-header/dist/plugin.wasm differ diff --git a/plugins/official/license-header/dist/plugin.wasm.sources b/plugins/official/license-header/dist/plugin.wasm.sources index 01859b4b..7dc3c5ab 100644 --- a/plugins/official/license-header/dist/plugin.wasm.sources +++ b/plugins/official/license-header/dist/plugin.wasm.sources @@ -1 +1 @@ -6a4210a3978f058b3526427eb41ef398924e8c011cc4dc53c19f5419d31501f3 +45a7ee993b5c234b0d25e5b05ef60e714fa3edf63d6bd0d6a29b13587c8d09ce diff --git a/plugins/official/license-header/plugin.toml b/plugins/official/license-header/plugin.toml index af970e2e..c32b4176 100644 --- a/plugins/official/license-header/plugin.toml +++ b/plugins/official/license-header/plugin.toml @@ -5,6 +5,7 @@ description = "Keeps a copyright and SPDX license header at the top of source fi authors = ["OpenIDEA contributors"] license = "Apache-2.0" change-notes = "0.1.0 — first release: missing headers reported on open and save, Add License Header quick fix and command, project scan in the background." +categories = ["tools"] engines.openidea = "^1.0" wasm = "dist/plugin.wasm" activation = ["on-event:file-opened", "on-event:file-saved", "on-command:*"] diff --git a/plugins/official/mybatis/dist/plugin.wasm b/plugins/official/mybatis/dist/plugin.wasm index 5ed3800a..ca729516 100644 Binary files a/plugins/official/mybatis/dist/plugin.wasm and b/plugins/official/mybatis/dist/plugin.wasm differ diff --git a/plugins/official/mybatis/dist/plugin.wasm.sources b/plugins/official/mybatis/dist/plugin.wasm.sources index 0241ff75..758553fc 100644 --- a/plugins/official/mybatis/dist/plugin.wasm.sources +++ b/plugins/official/mybatis/dist/plugin.wasm.sources @@ -1 +1 @@ -f7df58bb0ab2b92370b148de2614174ec1ff9b01c0078b5c20bd331f82298616 +d5cf81455ae8b2b0fd6d1402d8775913bd7999db62b9bdf87675890ce52b1e80 diff --git a/plugins/official/mybatis/plugin.toml b/plugins/official/mybatis/plugin.toml index fac11916..d5a55c69 100644 --- a/plugins/official/mybatis/plugin.toml +++ b/plugins/official/mybatis/plugin.toml @@ -5,6 +5,7 @@ description = "Jumps between MyBatis mapper interfaces and their XML statements authors = ["OpenIDEA contributors"] license = "Apache-2.0" change-notes = "0.1.0 — first release: navigation both ways, line markers, id and parameter completion." +categories = ["frameworks"] engines.openidea = "^1.0" wasm = "dist/plugin.wasm" activation = ["on-command:*"] diff --git a/plugins/official/regex-tester/dist/plugin.wasm b/plugins/official/regex-tester/dist/plugin.wasm index 9eed421b..1d39d764 100644 Binary files a/plugins/official/regex-tester/dist/plugin.wasm and b/plugins/official/regex-tester/dist/plugin.wasm differ diff --git a/plugins/official/regex-tester/dist/plugin.wasm.sources b/plugins/official/regex-tester/dist/plugin.wasm.sources index b9543105..013089c4 100644 --- a/plugins/official/regex-tester/dist/plugin.wasm.sources +++ b/plugins/official/regex-tester/dist/plugin.wasm.sources @@ -1 +1 @@ -d2e35f3e4ee6b699899ea43fdf96d5ec99f0730c502f94fd6f3246e605be99a7 +227c8150c6ad7a5a20236764f149c876cbbc3b082763bf2e7fc05de99726b740 diff --git a/plugins/official/regex-tester/plugin.toml b/plugins/official/regex-tester/plugin.toml index a889b96a..22e45435 100644 --- a/plugins/official/regex-tester/plugin.toml +++ b/plugins/official/regex-tester/plugin.toml @@ -5,6 +5,7 @@ description = "Try a regular expression against sample text: matches highlighted authors = ["OpenIDEA contributors"] license = "Apache-2.0" change-notes = "0.1.0 — first release: live matching with flags, capture groups, replacement preview, the editor's selection as test text." +categories = ["tools"] engines.openidea = "^1.0" wasm = "dist/plugin.wasm" activation = ["on-view:regex", "on-command:*"] diff --git a/plugins/official/solidity/dist/plugin.wasm b/plugins/official/solidity/dist/plugin.wasm index 3cb17fd7..4fd185f6 100644 Binary files a/plugins/official/solidity/dist/plugin.wasm and b/plugins/official/solidity/dist/plugin.wasm differ diff --git a/plugins/official/solidity/dist/plugin.wasm.sources b/plugins/official/solidity/dist/plugin.wasm.sources index d7a74f4c..20e8de03 100644 --- a/plugins/official/solidity/dist/plugin.wasm.sources +++ b/plugins/official/solidity/dist/plugin.wasm.sources @@ -1 +1 @@ -3893bd8ad06d3e19c0112081c6643b3bf3c67f5a184098432a6fd338426cd682 +bbadcaa7a86a3e0a3f8be6d86aed2229d5cdfd128fda3435b4d64c7e19ad1df2 diff --git a/plugins/official/solidity/plugin.toml b/plugins/official/solidity/plugin.toml index cc03892b..2ef3c864 100644 --- a/plugins/official/solidity/plugin.toml +++ b/plugins/official/solidity/plugin.toml @@ -5,6 +5,7 @@ description = "Solidity support: file detection, highlighting, structure view an authors = ["OpenIDEA contributors"] license = "Apache-2.0" change-notes = "0.1.0 — first release: .sol detection, highlighting, structure view, folding; language server via @nomicfoundation/solidity-language-server or solc." +categories = ["languages"] engines.openidea = "^1.0" # Highlighting, structure and folding come from this component; it is loaded # when the first Solidity file is opened. diff --git a/plugins/official/string-tools/dist/plugin.wasm b/plugins/official/string-tools/dist/plugin.wasm index 1c33e3e4..b38c2f9c 100644 Binary files a/plugins/official/string-tools/dist/plugin.wasm and b/plugins/official/string-tools/dist/plugin.wasm differ diff --git a/plugins/official/string-tools/dist/plugin.wasm.sources b/plugins/official/string-tools/dist/plugin.wasm.sources index 2dbf488f..7c78b969 100644 --- a/plugins/official/string-tools/dist/plugin.wasm.sources +++ b/plugins/official/string-tools/dist/plugin.wasm.sources @@ -1 +1 @@ -a88c39d5782369329efa55b12330daa906e5c8d91252bd57c7343648840bac79 +09cc92e8e731f8f59f3857a71f9a30cc3d9e04a1c32092920f573b0f26592855 diff --git a/plugins/official/string-tools/plugin.toml b/plugins/official/string-tools/plugin.toml index 18ff2bb5..9ff53b75 100644 --- a/plugins/official/string-tools/plugin.toml +++ b/plugins/official/string-tools/plugin.toml @@ -5,6 +5,7 @@ description = "Case conversion (camelCase, snake_case, kebab-case…), encoding authors = ["OpenIDEA contributors"] license = "Apache-2.0" change-notes = "0.1.0 — first release: 26 commands in four groups." +categories = ["tools"] engines.openidea = "^1.0" wasm = "dist/plugin.wasm" # The commands are declared below, so Find Action and the keymap list them diff --git a/src/bindings.ts b/src/bindings.ts index c78b7e45..324f2122 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -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. */ @@ -4139,6 +4144,14 @@ export type IndexEntry = { /** [`package_digest`] of its contents. */ digest: string, capabilities?: string[], + /** + * The plugin API versions it loads in (a semver requirement): its + * manifest's `engines.openidea` and the API its component was built for. + * Required: an entry without one is offered to no IDE. + */ + engine?: string, + /** Marketplace categories (`CATEGORIES`); an IDE ignores those it doesn't know. */ + categories?: string[], }; export type IndexStatus = { @@ -4296,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. */ @@ -4871,6 +4896,8 @@ export type MarkdownSettings = { export type MarketPlugin = { /** Installed version, if any. */ installed: string | null, + /** Whether this IDE can load `entry` (the newest version it can, else the newest). */ + compatible: boolean, } & IndexEntry; export type Markup = { @@ -5244,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. */ @@ -5353,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, diff --git a/src/features/plugins/PluginsPage.module.css b/src/features/plugins/PluginsPage.module.css index 1f4b0e8d..a2764a19 100644 --- a/src/features/plugins/PluginsPage.module.css +++ b/src/features/plugins/PluginsPage.module.css @@ -380,6 +380,14 @@ min-width: 0; } +.categorySelect { + width: 100%; +} + +.detailsNotice { + margin: 0 0 12px; +} + .marketOverview { padding: 12px 16px; overflow: auto; diff --git a/src/features/plugins/PluginsPage.tsx b/src/features/plugins/PluginsPage.tsx index 33a2065a..3f8da6c6 100644 --- a/src/features/plugins/PluginsPage.tsx +++ b/src/features/plugins/PluginsPage.tsx @@ -18,6 +18,7 @@ import { IconButton, Input, SearchField, + Select, Spinner, Tabs, showContextMenu, @@ -28,6 +29,7 @@ import { RISK_LABEL, sortByRisk } from "./consent"; import { itemsOf, uiPoint } from "./projection"; import { compareVersions, + isUpdate, grantPlugin, installFromDisk, installFromMarket, @@ -41,6 +43,7 @@ import { setPluginCapability, setPluginEnabled, uninstallPlugin, + updateAllPlugins, usePlugins, } from "./store"; import { useProject } from "@/platform/project"; @@ -536,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 @@ -572,6 +581,79 @@ function gearMenu(plugins: readonly PluginInfo[], actions: GearActions): MenuIte ]; } +const CATEGORIES = [ + "languages", + "themes", + "icon-themes", + "linters", + "vcs", + "ai", + "frameworks", + "tools", + "other", +] as const; +type Category = (typeof CATEGORIES)[number]; + +function categoryLabel(c: Category): string { + switch (c) { + case "languages": + return tx("Languages"); + case "themes": + return tx("Themes"); + case "icon-themes": + return tx("Icon Themes"); + case "linters": + return tx("Linters & Formatters"); + case "vcs": + return tx("Version Control"); + case "ai": + return tx("AI"); + case "frameworks": + return tx("Frameworks"); + case "tools": + return tx("Tools"); + case "other": + return tx("Other"); + } +} + +/** A marketplace plugin's categories this IDE knows; `other` without any. */ +function categoriesOf(m: MarketPlugin): Category[] { + const known = CATEGORIES.filter((c) => m.categories?.includes(c)); + return known.length > 0 ? known : ["other"]; +} + +/** Filters the marketplace by category; lists the categories that have plugins. */ +function CategoryFilter({ + market, + value, + onChange, +}: { + market: readonly MarketPlugin[]; + value: Category | "all"; + onChange: (value: Category | "all") => void; +}) { + const counts = new Map(); + for (const m of market) for (const c of categoriesOf(m)) counts.set(c, (counts.get(c) ?? 0) + 1); + const options = [ + { value: "all" as const, label: tx("All Categories"), detail: String(market.length) }, + ...CATEGORIES.filter((c) => counts.has(c)).map((c) => ({ + value: c, + label: categoryLabel(c), + detail: String(counts.get(c)), + })), + ]; + return ( +